Repository: metabase/metabase
Pull request #55102: Add function browser to the custom expression editor
Author: login: romeovs
Labels: name: no-backport
description: Do not backport this PR to any branch
name: .Team/Querying
name: PR-Env
description: See https://www.notion.so/metabase/Ephemeral-PR-Environments-c74a3710c87a460dbf4fc0007a001458
Created: 2025-03-13T14:52:46Z | Merged: 2025-03-19T09:13:32Z

Closes QUE-583

Adds the function browser to the custom expression editor.

### To verify
1. New question > Orders > Custom column
2. Click the `F` button in the top-right
3. The function browser should appear, try searching it and clicking a function
4. The function should be inserted where your cursor was, with the cursor between its `()`
5. Play around with different screen sizes with the browser open or closed
6. At 640px the whole editor should go full screen

<img width="1243" alt="Screenshot 2025-03-13 at 15 50 52" src="https://github.com/user-attachments/assets/b7022473-41aa-42f8-8c9d-3feaaf0af6ef" />
<img width="1230" alt="Screenshot 2025-03-13 at 15 50 48" src="https://github.com/user-attachments/assets/2897c5c0-8684-457a-a48c-5c13a7c6e795" />
<img width="572" alt="Screenshot 2025-03-13 at 15 52 07" src="https://github.com/user-attachments/assets/87813773-ab7b-4109-9b05-5b7a27d79d0c" />



Diff:
diff --git a/e2e/support/helpers/e2e-custom-column-helpers.ts b/e2e/support/helpers/e2e-custom-column-helpers.ts
index d7b148403804..4a1e0b735998 100644
--- a/e2e/support/helpers/e2e-custom-column-helpers.ts
+++ b/e2e/support/helpers/e2e-custom-column-helpers.ts
@@ -280,4 +280,7 @@ export const CustomExpressionEditor = {
   nameInput() {
     return cy.findByTestId("expression-name");
   },
+  functionBrowser() {
+    return cy.findByTestId("expression-editor-function-browser");
+  },
 };
diff --git a/e2e/test/scenarios/custom-column/custom-column-reproductions.cy.spec.js b/e2e/test/scenarios/custom-column/custom-column-reproductions.cy.spec.js
index 62fc8285fb11..ba5f8fcbf46d 100644
--- a/e2e/test/scenarios/custom-column/custom-column-reproductions.cy.spec.js
+++ b/e2e/test/scenarios/custom-column/custom-column-reproductions.cy.spec.js
@@ -1022,7 +1022,7 @@ describe("issue 49342", () => {
   });
 
   it("should not be possible to leave the expression input with the Tab key ", () => {
-    // This test used to be a repro for #49342, but the product feature change
+    // This test used to be a repro for #49342, but the product feature changed
     // so that the expression input can no longer be tabbed out of.
 
     H.openOrdersTable({ mode: "notebook" });
@@ -1036,6 +1036,7 @@ describe("issue 49342", () => {
     H.CustomExpressionEditor.nameInput().focus();
     H.CustomExpressionEditor.nameInput().realPress(["Shift", "Tab"]);
     H.CustomExpressionEditor.nameInput().realPress(["Shift", "Tab"]);
+    H.CustomExpressionEditor.nameInput().realPress(["Shift", "Tab"]);
     cy.focused().should("have.attr", "role", "textbox");
 
     cy.realPress(["Shift", "Tab"]);
diff --git a/e2e/test/scenarios/custom-column/custom-column.cy.spec.js b/e2e/test/scenarios/custom-column/custom-column.cy.spec.js
index 6c8e338cd158..ae2e545a5062 100644
--- a/e2e/test/scenarios/custom-column/custom-column.cy.spec.js
+++ b/e2e/test/scenarios/custom-column/custom-column.cy.spec.js
@@ -1381,3 +1381,119 @@ describe("scenarios > question > custom column > distinctIf", () => {
     cy.findByTestId("scalar-value").should("have.text", "147");
   });
 });
+
+describe("scenarios > question > custom column > function browser", () => {
+  beforeEach(() => {
+    H.restore();
+    cy.signInAsNormalUser();
+
+    H.openProductsTable({ mode: "notebook" });
+    H.addCustomColumn();
+  });
+
+  it("should be possible to insert functions by clicking them in the function browser", () => {
+    H.expressionEditorWidget().button("Function browser").click();
+
+    H.CustomExpressionEditor.functionBrowser()
+      .findByText("datetimeAdd")
+      .should("be.visible");
+    H.CustomExpressionEditor.functionBrowser()
+      .findByText("Adds some units of time to a date or timestamp value.")
+      .should("be.visible");
+
+    H.CustomExpressionEditor.functionBrowser()
+      .findByText("datetimeAdd")
+      .click();
+
+    H.CustomExpressionEditor.value().should("equal", "datetimeAdd()");
+
+    H.CustomExpressionEditor.functionBrowser().findByText("day").click();
+    H.CustomExpressionEditor.value().should("equal", "datetimeAdd(day())");
+
+    H.CustomExpressionEditor.type('"foo"{rightarrow}, ', { focus: false });
+    H.CustomExpressionEditor.value().should(
+      "equal",
+      'datetimeAdd(day("foo"), )',
+    );
+
+    H.CustomExpressionEditor.functionBrowser().findByText("day").click();
+    H.CustomExpressionEditor.value().should(
+      "equal",
+      'datetimeAdd(day("foo"), day())',
+    );
+  });
+
+  it("should be possible to replace text when inserting functions", () => {
+    H.CustomExpressionEditor.type("foo bar baz");
+    cy.realPress(["ArrowLeft"]);
+    cy.realPress(["ArrowLeft"]);
+    cy.realPress(["ArrowLeft"]);
+    cy.realPress(["ArrowLeft"]);
+    cy.realPress(["Shift", "ArrowLeft"]);
+    cy.realPress(["Shift", "ArrowLeft"]);
+    cy.realPress(["Shift", "ArrowLeft"]);
+
+    H.expressionEditorWidget().button("Function browser").click();
+    H.CustomExpressionEditor.functionBrowser().findByText("day").click();
+    H.CustomExpressionEditor.value().should("equal", "foo day() baz");
+  });
+
+  it("should be possible to filter functions in the function browser", () => {
+    H.expressionEditorWidget().button("Function browser").click();
+
+    H.CustomExpressionEditor.functionBrowser().within(() => {
+      cy.findByPlaceholderText("Search functions…").type("con");
+
+      cy.findByText("dayName").should("not.exist");
+      cy.findByText("concat").should("be.visible");
+      cy.findByText("second").should("be.visible");
+      //
+      cy.findByPlaceholderText("Search functions…").clear();
+      cy.findByText("dayName").should("be.visible");
+    });
+  });
+
+  it("should not show functions that are not supported by the current database", () => {
+    H.expressionEditorWidget().button("Function browser").click();
+
+    H.CustomExpressionEditor.functionBrowser().within(() => {
+      cy.findByPlaceholderText("Search functions…").type("convertTimezone");
+      cy.findByText("convertTimezone").should("not.exist");
+    });
+  });
+
+  it("should not show aggregations unless aggregating", () => {
+    H.expressionEditorWidget().button("Function browser").click();
+    H.CustomExpressionEditor.functionBrowser().within(() => {
+      cy.findByPlaceholderText("Search functions…").type("Count");
+      cy.findByText("Count").should("not.exist");
+    });
+    H.expressionEditorWidget().button("Cancel").click();
+
+    H.summarize({ mode: "notebook" });
+    H.popover().findByText("Custom Expression").click();
+
+    H.expressionEditorWidget().button("Function browser").click();
+    H.CustomExpressionEditor.functionBrowser().within(() => {
+      cy.findByPlaceholderText("Search aggregations…").type("Count");
+      cy.findByText("Count").should("be.visible");
+    });
+  });
+
+  it("show a message when no functions match the filter", () => {
+    H.expressionEditorWidget().button("Function browser").click();
+    H.CustomExpressionEditor.functionBrowser().within(() => {
+      cy.findByPlaceholderText("Search functions…").type("foobar");
+      cy.findByText("Didn't find any results").should("be.visible");
+    });
+  });
+
+  it("should not insert parens when the clause has no arguments", () => {
+    H.expressionEditorWidget().button("Function browser").click();
+    H.CustomExpressionEditor.functionBrowser().within(() => {
+      cy.findByPlaceholderText("Search functions…").type("now");
+      cy.findByText("now").click();
+    });
+    H.CustomExpressionEditor.value().should("equal", "now");
+  });
+});
diff --git a/e2e/test/scenarios/filters/filter.cy.spec.js b/e2e/test/scenarios/filters/filter.cy.spec.js
index 44799110c27a..5a48ef301bbf 100644
--- a/e2e/test/scenarios/filters/filter.cy.spec.js
+++ b/e2e/test/scenarios/filters/filter.cy.spec.js
@@ -298,7 +298,7 @@ describe("scenarios > question > filter", () => {
 
     // Avoid flakiness caused by CodeMirror not accepting the keypress
     // immediately
-    cy.wait(100);
+    cy.wait(200);
     cy.realPress("ArrowDown");
 
     H.CustomExpressionEditor.completion("ceil")
diff --git a/frontend/src/metabase-lib/v1/expressions/complete/functions.ts b/frontend/src/metabase-lib/v1/expressions/complete/functions.ts
index 24f3112e3188..7264f69b7a77 100644
--- a/frontend/src/metabase-lib/v1/expressions/complete/functions.ts
+++ b/frontend/src/metabase-lib/v1/expressions/complete/functions.ts
@@ -34,8 +34,7 @@ export function suggestFunctions({
   }
 
   const database = getDatabase(query, metadata);
-  const ALIASES = ["case"];
-  const functions = [...EXPRESSION_FUNCTIONS, ...ALIASES]
+  const functions = [...EXPRESSION_FUNCTIONS]
     .map(name => MBQL_CLAUSES[name])
     .filter(clause => clause && database?.hasFeature(clause.requiresFeature))
     .filter(function disableOffsetInFilterExpressions(clause) {
diff --git a/frontend/src/metabase-lib/v1/expressions/config.ts b/frontend/src/metabase-lib/v1/expressions/config.ts
index d5d7ed9b9eab..7f8ecce8ef3f 100644
--- a/frontend/src/metabase-lib/v1/expressions/config.ts
+++ b/frontend/src/metabase-lib/v1/expressions/config.ts
@@ -585,6 +585,7 @@ export const EXPRESSION_FUNCTIONS = new Set([
   "does-not-contain",
   // other
   "if",
+  "case",
   "coalesce",
 ]);
 
diff --git a/frontend/src/metabase-lib/v1/expressions/helper-text-strings.ts b/frontend/src/metabase-lib/v1/expressions/helper-text-strings.ts
index 6d4338a0d3a0..a8cd9e2274b7 100644
--- a/frontend/src/metabase-lib/v1/expressions/helper-text-strings.ts
+++ b/frontend/src/metabase-lib/v1/expressions/helper-text-strings.ts
@@ -42,16 +42,19 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "count",
     structure: "Count",
+    category: "aggregation",
     description: () => t`Returns the count of rows in the selected data.`,
   },
   {
     name: "cum-count",
     structure: "CumulativeCount",
+    category: "aggregation",
     description: () => t`The additive total of rows across a breakout.`,
   },
   {
     name: "sum",
     structure: "Sum",
+    category: "window",
     description: () => t`Adds up all the values of the column.`,
     args: [
       {
@@ -64,6 +67,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "cum-sum",
     structure: "CumulativeSum",
+    category: "window",
     description: () => t`The rolling sum of a column across a breakout.`,
     args: [
       {
@@ -76,6 +80,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "distinct",
     structure: "Distinct",
+    category: "aggregation",
     description: () => t`The number of distinct values in this column.`,
     args: [
       {
@@ -90,6 +95,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
     structure: "DistinctIf",
     description: () =>
       t`The count of distinct values in this column for rows where the condition is true.`,
+    category: "aggregation",
     args: [
       {
         name: t`column`,
@@ -106,6 +112,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "stddev",
     structure: "StandardDeviation",
+    category: "aggregation",
     description: () => t`Calculates the standard deviation of the column.`,
     args: [
       {
@@ -118,6 +125,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "offset",
     structure: "Offset",
+    category: "window",
     description: () =>
       t`Returns the value of an aggregation expression in a different row`,
     args: [
@@ -136,6 +144,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "avg",
     structure: "Average",
+    category: "aggregation",
     description: () => t`Returns the average of the values in the column.`,
     args: [
       {
@@ -148,6 +157,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "median",
     structure: "Median",
+    category: "aggregation",
     description: () => t`Returns the median of all the values of a column.`,
     args: [
       {
@@ -160,6 +170,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "min",
     structure: "Min",
+    category: "aggregation",
     description: () => t`Returns the smallest value found in the column`,
     args: [
       {
@@ -172,6 +183,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "max",
     structure: "Max",
+    category: "aggregation",
     description: () => t`Returns the largest value found in the column.`,
     args: [
       {
@@ -184,6 +196,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "share",
     structure: "Share",
+    category: "aggregation",
     description: () =>
       t`Returns the percent of rows in the data that match the condition, as a decimal.`,
     args: [
@@ -197,6 +210,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "count-where",
     structure: "CountIf",
+    category: "aggregation",
     description: () => t`Only counts rows where the condition is true.`,
     args: [
       {
@@ -209,6 +223,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "sum-where",
     structure: "SumIf",
+    category: "aggregation",
     description: () =>
       t`Sums up the specified column only for rows where the condition is true.`,
     args: [
@@ -227,6 +242,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "var",
     structure: "Variance",
+    category: "aggregation",
     description: () => t`Returns the numeric variance for a given column.`,
     args: [
       {
@@ -239,6 +255,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "median",
     structure: "Median",
+    category: "aggregation",
     description: () => t`Returns the median value of the specified column.`,
     args: [
       {
@@ -251,6 +268,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "percentile",
     structure: "Percentile",
+    category: "aggregation",
     description: () =>
       t`Returns the value of the column at the percentile value.`,
     args: [
@@ -269,6 +287,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "lower",
     structure: "lower",
+    category: "string",
     description: () => t`Returns the string of text in all lower case.`,
     args: [
       {
@@ -281,6 +300,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "upper",
     structure: "upper",
+    category: "string",
     description: () => t`Returns the text in all upper case.`,
     args: [
       {
@@ -293,6 +313,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "substring",
     structure: "substring",
+    category: "string",
     description: () => t`Returns a portion of the supplied text.`,
     args: [
       {
@@ -316,6 +337,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "regex-match-first",
     structure: "regexextract",
+    category: "string",
     description: () =>
       t`Extracts matching substrings according to a regular expression.`,
     args: [
@@ -335,6 +357,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "concat",
     structure: "concat",
+    category: "string",
     description: () => t`Combine two or more strings of text together.`,
     args: [
       {
@@ -358,6 +381,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "replace",
     structure: "replace",
+    category: "string",
     description: () => t`Replaces a part of the input text with new text.`,
     args: [
       {
@@ -380,6 +404,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "length",
     structure: "length",
+    category: "string",
     description: () => t`Returns the number of characters in text.`,
     args: [
       {
@@ -392,6 +417,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "trim",
     structure: "trim",
+    category: "string",
     description: () =>
       t`Removes leading and trailing whitespace from a string of text.`,
     args: [
@@ -405,6 +431,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "rtrim",
     structure: "rtrim",
+    category: "string",
     description: () => t`Removes trailing whitespace from a string of text.`,
     args: [
       {
@@ -417,6 +444,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "ltrim",
     structure: "ltrim",
+    category: "string",
     description: () => t`Removes leading whitespace from a string of text.`,
     args: [
       {
@@ -429,6 +457,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "host",
     structure: "host",
+    category: "string",
     description: () =>
       t`Extracts the host (domain name and TLD, eg. "metabase.com" from "status.metabase.com") from a URL or email`,
     args: [
@@ -442,6 +471,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "domain",
     structure: "domain",
+    category: "string",
     description: () =>
       t`Extracts the domain name (eg. "metabase") from a URL or email`,
     args: [
@@ -455,6 +485,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "subdomain",
     structure: "subdomain",
+    category: "string",
     description: () =>
       t`Extracts the first subdomain (eg. "status" from "status.metabase.com", "" from "bbc.co.uk") from a URL. Ignores "www".`,
     args: [
@@ -468,6 +499,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "month-name",
     structure: "monthName",
+    category: "date",
     description: () =>
       t`Returns the localized short name ("Apr") for the given month number (4)`,
     args: [
@@ -481,6 +513,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "quarter-name",
     structure: "quarterName",
+    category: "date",
     description: () => t`Returns a string like "Q1", given the quarter number`,
     args: [
       {
@@ -493,6 +526,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "day-name",
     structure: "dayName",
+    category: "date",
     description: () =>
       t`Returns the localized name of a day of the week, given the day's number.`,
     args: [
@@ -506,6 +540,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "abs",
     structure: "abs",
+    category: "math",
     description: () =>
       t`Returns the absolute (positive) value of the specified column.`,
     args: [
@@ -519,6 +554,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "floor",
     structure: "floor",
+    category: "math",
     description: () => t`Rounds a decimal number down.`,
     args: [
       {
@@ -531,6 +567,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "ceil",
     structure: "ceil",
+    category: "math",
     description: () => t`Rounds a decimal number up.`,
     args: [
       {
@@ -543,6 +580,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "round",
     structure: "round",
+    category: "math",
     description: () =>
       t`Rounds a decimal number either up or down to the nearest integer value.`,
     args: [
@@ -556,6 +594,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "sqrt",
     structure: "sqrt",
+    category: "math",
     description: () => t`Returns the square root.`,
     args: [
       {
@@ -568,6 +607,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "power",
     structure: "power",
+    category: "math",
     description: () => t`Raises a number to the power of the exponent value.`,
     args: [
       {
@@ -585,6 +625,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "log",
     structure: "log",
+    category: "math",
     description: () => t`Returns the base 10 log of the number.`,
     args: [
       {
@@ -597,6 +638,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "datetime-diff",
     structure: "datetimeDiff",
+    category: "date",
     description: () =>
       t`Get the difference between two datetime values (datetime2 minus datetime1) using the specified unit of time.`,
     args: [
@@ -621,6 +663,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "exp",
     structure: "exp",
+    category: "math",
     description: () =>
       t`Returns Euler's number, e, raised to the power of the supplied number.`,
     args: [
@@ -634,6 +677,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "contains",
     structure: "contains",
+    category: "string",
     description: () =>
       t`Returns true if string1 contains string2 within it (or string3, etc. if specified).`,
     args: [
@@ -662,6 +706,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "does-not-contain",
     structure: "doesNotContain",
+    category: "string",
     description: () =>
       t`Returns true if string1 does not contain string2 within it (and string3, etc. if specified).`,
     args: [
@@ -690,6 +735,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "starts-with",
     structure: "startsWith",
+    category: "string",
     description: () =>
       t`Returns true if the beginning of the string1 matches the string2 (or string3, etc. if specified).`,
     args: [
@@ -718,6 +764,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "ends-with",
     structure: "endsWith",
+    category: "string",
     description: () =>
       t`Returns true if the end of the string1 matches the string2 (or string3, etc. if specified).`,
     args: [
@@ -745,6 +792,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   },
   {
     name: "between",
+    category: "logical",
     structure: "between",
     description: () =>
       t`Checks a date or number column's values to see if they're within the specified range.`,
@@ -769,6 +817,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "interval",
     structure: "timeSpan",
+    category: "date",
     description: () => t`Gets a time interval of specified length`,
     args: [
       {
@@ -786,6 +835,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "time-interval",
     structure: "interval",
+    category: "date",
     description: () =>
       t`Checks a date column's values to see if they're within the relative range.`,
     args: [
@@ -809,6 +859,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "relative-time-interval",
     structure: "intervalStartingFrom",
+    category: "date",
     description: () =>
       t`Returns true if a column's value falls within an interval, starting from an initial, offsetting interval.`,
     args: [
@@ -842,6 +893,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "relative-datetime",
     structure: "relativeDateTime",
+    category: "date",
     description: () => t`Gets a timestamp relative to the current time`,
     args: [
       {
@@ -859,6 +911,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "is-null",
     structure: "isnull",
+    category: "logical",
     description: () => t`Checks if a column is null`,
     args: [
       {
@@ -872,6 +925,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "not-null",
     structure: "notnull",
+    category: "logical",
     description: () => t`Checks if a column is not null`,
     args: [
       {
@@ -884,6 +938,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "is-empty",
     structure: "isempty",
+    category: "string",
     description: () => t`Checks if a column is empty`,
     args: [
       {
@@ -897,6 +952,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "not-empty",
     structure: "notempty",
+    category: "string",
     description: () => t`Checks if a column is not empty`,
     args: [
       {
@@ -908,6 +964,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   },
   {
     name: "coalesce",
+    category: "logical",
     structure: "coalesce",
     description: () =>
       t`Looks at the values in each argument in order and returns the first non-null value for each row.`,
@@ -932,6 +989,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   },
   {
     name: "case",
+    category: "logical",
     structure: "case",
     description: () =>
       t`Alias for if(). Tests an expression against a list of cases and returns the corresponding value of the first matching case, with an optional default value if nothing else is met.`,
@@ -960,6 +1018,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "if",
     structure: "if",
+    category: "logical",
     description: () =>
       t`Alias for case(). Tests an expression against a list of cases and returns the corresponding value of the first matching case, with an optional default value if nothing else is met.`,
     args: [
@@ -986,6 +1045,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "in",
     structure: "in",
+    category: "logical",
     description: () =>
       t`Returns true if value1 equals value2 (or value3, etc. if specified).`,
     args: [
@@ -1009,6 +1069,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "not-in",
     structure: "notIn",
+    category: "logical",
     description: () =>
       t`Returns true if value1 doesn't equal value2 (and value3, etc. if specified).`,
     args: [
@@ -1032,6 +1093,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "get-year",
     structure: "year",
+    category: "date",
     description: () =>
       t`Takes a datetime and returns an integer with the number of the year.`,
     args: [
@@ -1045,6 +1107,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "get-quarter",
     structure: "quarter",
+    category: "date",
     description: () =>
       t`Takes a datetime and returns an integer (1-4) with the number of the quarter in the year.`,
     args: [
@@ -1058,6 +1121,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "get-month",
     structure: "month",
+    category: "date",
     description: () =>
       t`Takes a datetime and returns an integer (1-12) with the number of the month in the year.`,
     args: [
@@ -1071,6 +1135,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "get-week",
     structure: "week",
+    category: "date",
     description: () => t`Extracts the week of the year as an integer.`,
     args: [
       {
@@ -1094,6 +1159,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "get-day",
     structure: "day",
+    category: "date",
     description: () =>
       t`Takes a datetime and returns an integer (1-31) with the number of the day of the month.`,
     args: [
@@ -1107,6 +1173,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "get-day-of-week",
     structure: "weekday",
+    category: "date",
     description: () =>
       t`Takes a datetime and returns an integer (1-7) with the number of the day of the week. Which day is 1 is defined in your localization settings.`,
     args: [
@@ -1120,6 +1187,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "get-hour",
     structure: "hour",
+    category: "date",
     description: () =>
       t`Takes a datetime and returns an integer (0-23) with the number of the hour. No AM/PM.`,
     args: [
@@ -1133,6 +1201,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "get-minute",
     structure: "minute",
+    category: "date",
     description: () =>
       t`Takes a datetime and returns an integer (0-59) with the number of the minute in the hour.`,
     args: [
@@ -1146,6 +1215,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "get-second",
     structure: "second",
+    category: "date",
     description: () =>
       t`Takes a datetime and returns an integer (0-59) with the number of the seconds in the minute.`,
     args: [
@@ -1159,6 +1229,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "datetime-add",
     structure: "datetimeAdd",
+    category: "date",
     description: () => t`Adds some units of time to a date or timestamp value.`,
     args: [
       {
@@ -1182,6 +1253,7 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "datetime-subtract",
     structure: "datetimeSubtract",
+    category: "date",
     description: () =>
       t`Subtracts some units of time to a date or timestamp value.`,
     args: [
@@ -1206,11 +1278,13 @@ const HELPER_TEXT_STRINGS: HelpTextConfig[] = [
   {
     name: "now",
     structure: "now",
+    category: "date",
     description: getDescriptionForNow,
   },
   {
     name: "convert-timezone",
     structure: "convertTimezone",
+    category: "date",
     description: () => t`Convert timezone of a date or timestamp column.
 We support tz database time zone names.`,
     args: [
diff --git a/frontend/src/metabase-lib/v1/expressions/types.ts b/frontend/src/metabase-lib/v1/expressions/types.ts
index 8604f77df15e..d898c9d5a153 100644
--- a/frontend/src/metabase-lib/v1/expressions/types.ts
+++ b/frontend/src/metabase-lib/v1/expressions/types.ts
@@ -3,8 +3,17 @@ import type { DatabaseFeature, Expression } from "metabase-types/api";
 
 import type { OPERATOR, TOKEN } from "./tokenizer";
 
+export type MBQLClauseCategory =
+  | "logical"
+  | "math"
+  | "string"
+  | "date"
+  | "window"
+  | "aggregation";
+
 export interface HelpText {
   name: string;
+  category: MBQLClauseCategory;
   args?: HelpTextArg[]; // no args means that expression function doesn't accept any parameters, e.g. "CumulativeCount"
   description: string;
   example: Expression;
@@ -14,6 +23,7 @@ export interface HelpText {
 
 export interface HelpTextConfig {
   name: string;
+  category: MBQLClauseCategory;
   args?: HelpTextArg[]; // no args means that expression function doesn't accept any parameters, e.g. "CumulativeCount"
   description: (database: Database, reportTimezone?: string) => string;
   structure: string;
diff --git a/frontend/src/metabase/query_builder/components/expressions/CombineColumns/CombineColumns.tsx b/frontend/src/metabase/query_builder/components/expressions/CombineColumns/CombineColumns.tsx
index 56c335878af2..46e705a72a11 100644
--- a/frontend/src/metabase/query_builder/components/expressions/CombineColumns/CombineColumns.tsx
+++ b/frontend/src/metabase/query_builder/components/expressions/CombineColumns/CombineColumns.tsx
@@ -6,6 +6,8 @@ import { isNotNull } from "metabase/lib/types";
 import { Box, Button, Flex, Icon, Stack } from "metabase/ui";
 import * as Lib from "metabase-lib";
 
+import { ExpressionWidgetHeader } from "../ExpressionWidgetHeader";
+
 import { ColumnAndSeparatorRow } from "./ColumnAndSeparatorRow";
 import { Example } from "./Example";
 import type { ColumnAndSeparator } from "./util";
@@ -20,7 +22,9 @@ import {
 interface Props {
   query: Lib.Query;
   stageIndex: number;
+  onCancel?: () => void;
   onSubmit: (name: string, clause: Lib.ExpressionClause) => void;
+  withTitle?: boolean;
   width?: number;
 
   /**
@@ -38,9 +42,11 @@ type State = {
 export function CombineColumns({
   query,
   stageIndex,
+  onCancel,
   onSubmit,
   width,
   column,
+  withTitle,
 }: Props) {
   const expressionableColumns = Lib.expressionableColumns(query, stageIndex);
 
@@ -149,69 +155,83 @@ export function CombineColumns({
   );
 
   return (
-    <form onSubmit={handleSubmit}>
-      <Box maw="100vw" w={width} p="lg" pt={0}>
-        <Stack gap="lg" mt="lg">
-          <Stack gap="md">
-            <Box>
-              <Stack gap="md">
-                {columnsAndSeparators.map(
-                  (item, index) =>
-                    // Do not allow editing the first column when it is passed from
-                    // the props.
-                    (!column || index > 0) && (
-                      <ColumnAndSeparatorRow
-                        key={index}
-                        query={query}
-                        stageIndex={stageIndex}
-                        index={index}
-                        columns={expressionableColumns}
-                        column={item.column}
-                        separator={item.separator ?? ""}
-                        showSeparator={!isUsingDefaultSeparator && index !== 0}
-                        showRemove={columnsAndSeparators.length >= 3}
-                        onChange={handleRowChange}
-                        onRemove={handleRowRemove}
-                      />
-                    ),
-                )}
-              </Stack>
-            </Box>
-            <Flex
-              align="center"
-              gap="md"
-              justify={isUsingDefaultSeparator ? "space-between" : "end"}
-            >
-              {isUsingDefaultSeparator && (
-                <Box>
-                  <Button p={0} variant="subtle" onClick={handleEditSeparators}>
-                    {jt`Separated by ${formatSeparator(
-                      state.defaultSeparator,
-                    )}`}
-                  </Button>
-                </Box>
-              )}
-
-              <Button
-                leftSection={<Icon name="add" />}
-                p={0}
-                variant="subtle"
-                onClick={handleRowAdd}
+    <>
+      {onCancel && withTitle && (
+        <ExpressionWidgetHeader
+          title={t`Select columns to combine`}
+          onBack={onCancel}
+        />
+      )}
+      <form onSubmit={handleSubmit}>
+        <Box maw="100vw" w={width} p="lg" pt={0}>
+          <Stack gap="lg" mt="lg">
+            <Stack gap="md">
+              <Box>
+                <Stack gap="md">
+                  {columnsAndSeparators.map(
+                    (item, index) =>
+                      // Do not allow editing the first column when it is passed from
+                      // the props.
+                      (!column || index > 0) && (
+                        <ColumnAndSeparatorRow
+                          key={index}
+                          query={query}
+                          stageIndex={stageIndex}
+                          index={index}
+                          columns={expressionableColumns}
+                          column={item.column}
+                          separator={item.separator ?? ""}
+                          showSeparator={
+                            !isUsingDefaultSeparator && index !== 0
+                          }
+                          showRemove={columnsAndSeparators.length >= 3}
+                          onChange={handleRowChange}
+                          onRemove={handleRowRemove}
+                        />
+                      ),
+                  )}
+                </Stack>
+              </Box>
+              <Flex
+                align="center"
+                gap="md"
+                justify={isUsingDefaultSeparator ? "space-between" : "end"}
               >
-                {t`Add column`}
+                {isUsingDefaultSeparator && (
+                  <Box>
+                    <Button
+                      p={0}
+                      variant="subtle"
+                      onClick={handleEditSeparators}
+                    >
+                      {jt`Separated by ${formatSeparator(
+                        state.defaultSeparator,
+                      )}`}
+                    </Button>
+                  </Box>
+                )}
+
+                <Button
+                  leftSection={<Icon name="add" />}
+                  p={0}
+                  variant="subtle"
+                  onClick={handleRowAdd}
+                >
+                  {t`Add column`}
+                </Button>
+              </Flex>
+            </Stack>
+
+            <Example columnsAndSeparators={columnsAndSeparators} />
+
+            <Flex align="center" gap="md" justify="end">
+              <Button type="submit" variant="filled" disabled={!isValid}>
+                {t`Done`}
               </Button>
             </Flex>
           </Stack>
-
-          <Example columnsAndSeparators={columnsAndSeparators} />
-
-          <Flex align="center" gap="md" justify="end">
-            <Button type="submit" variant="filled" disabled={!isValid}>
-              {t`Done`}
-            </Button>
-          </Flex>
-        </Stack>
-      </Box>
-    </form>
+        </Box>
+      </form>
+    </>
   );
 }
diff --git a/frontend/src/metabase/query_builder/components/expressions/Editor/Editor.module.css b/frontend/src/metabase/query_builder/components/expressions/Editor/Editor.module.css
index 3f667f54c256..69fe336f414e 100644
--- a/frontend/src/metabase/query_builder/components/expressions/Editor/Editor.module.css
+++ b/frontend/src/metabase/query_builder/components/expressions/Editor/Editor.module.css
@@ -1,8 +1,8 @@
 .wrapper {
   position: relative;
-  height: 220px;
-  width: 100%;
   border-bottom: 1px solid var(--mb-color-border);
+  display: flex;
+  flex-direction: column;
 
   &.formatting {
     background-color: var(--mb-color-bg-medium);
@@ -17,18 +17,20 @@
 
 .toolbar {
   position: absolute;
-  bottom: 0;
+  top: 0;
   right: 0;
-  padding-top: 1px;
-  padding-bottom: 1px;
+}
+
+.toolbarButton {
+  aspect-ratio: 1;
 }
 
 .editor {
   overflow: auto;
-  width: 100%;
   height: 100%;
   box-sizing: border-box;
   color: var(--mb-color-text-dark);
+  margin-right: calc(28px + var(--mantine-spacing-sm));
 
   :global {
     .cm-editor {
@@ -132,5 +134,6 @@
   position: absolute;
   bottom: var(--mantine-spacing-sm);
   left: calc(1.6rem + var(--mantine-spacing-sm));
+  right: 0;
   transition: opacity 300ms;
 }
diff --git a/frontend/src/metabase/query_builder/components/expressions/Editor/Editor.tsx b/frontend/src/metabase/query_builder/components/expressions/Editor/Editor.tsx
index 376016fef456..2bf6e2189089 100644
--- a/frontend/src/metabase/query_builder/components/expressions/Editor/Editor.tsx
+++ b/frontend/src/metabase/query_builder/components/expressions/Editor/Editor.tsx
@@ -1,5 +1,9 @@
 import type { EditorState } from "@codemirror/state";
-import CodeMirror, { type ReactCodeMirrorRef } from "@uiw/react-codemirror";
+import { useDisclosure } from "@mantine/hooks";
+import CodeMirror, {
+  EditorSelection,
+  type ReactCodeMirrorRef,
+} from "@uiw/react-codemirror";
 import cx from "classnames";
 import { useCallback, useMemo, useRef, useState } from "react";
 import { useMount } from "react-use";
@@ -10,12 +14,14 @@ import { useSelector } from "metabase/lib/redux";
 import { getMetadata } from "metabase/selectors/metadata";
 import { Button, Tooltip as ButtonTooltip, Flex, Icon } from "metabase/ui";
 import * as Lib from "metabase-lib";
-import { format } from "metabase-lib/v1/expressions";
+import { MBQL_CLAUSES, format } from "metabase-lib/v1/expressions";
 import { tokenAtPos } from "metabase-lib/v1/expressions/complete/util";
 import { TOKEN } from "metabase-lib/v1/expressions/tokenizer";
 import type { ErrorWithMessage } from "metabase-lib/v1/expressions/types";
 import type Metadata from "metabase-lib/v1/metadata/Metadata";
 
+import { FunctionBrowser } from "../FunctionBrowser";
+import { LayoutMain, LayoutSidebar } from "../Layout";
 import type { ClauseType, StartRule } from "../types";
 
 import { CloseModal, useCloseModal } from "./CloseModal";
@@ -40,6 +46,7 @@ type EditorProps<S extends StartRule> = {
   reportTimezone?: string;
   readOnly?: boolean;
   error?: ErrorWithMessage | Error | null;
+  hasHeader?: boolean;
   onCloseEditor?: () => void;
 
   onChange: (
@@ -49,6 +56,10 @@ type EditorProps<S extends StartRule> = {
   shortcuts?: Shortcut[];
 };
 
+const EDITOR_WIDGET_HEIGHT = 220;
+const FB_HEIGHT = EDITOR_WIDGET_HEIGHT + 46;
+const FB_HEIGHT_WITH_HEADER = FB_HEIGHT + 48;
+
 export function Editor<S extends StartRule = "expression">(
   props: EditorProps<S>,
 ) {
@@ -63,11 +74,14 @@ export function Editor<S extends StartRule = "expression">(
     error,
     reportTimezone,
     shortcuts,
+    hasHeader,
     onCloseEditor,
   } = props;
 
   const ref = useRef<ReactCodeMirrorRef>(null);
   const metadata = useSelector(getMetadata);
+  const [isFunctionBrowserOpen, { toggle: toggleFunctionBrowser }] =
+    useDisclosure();
 
   const {
     source,
@@ -111,56 +125,105 @@ export function Editor<S extends StartRule = "expression">(
     extensions: [customTooltip],
   });
 
+  const handleFunctionBrowserClauseClick = useCallback((name: string) => {
+    const view = ref.current?.view;
+    if (!view) {
+      return;
+    }
+    const clause = MBQL_CLAUSES[name];
+    if (!clause) {
+      return;
+    }
+
+    const text =
+      clause.args.length > 0 ? `${clause.displayName}()` : clause.displayName;
+    const len =
+      clause.args.length > 0
+        ? clause.displayName.length + 1 // + 1 for the parenthesis
+        : clause.displayName.length;
+
+    view?.focus();
+    view?.dispatch(
+      view.state.changeByRange(range => ({
+        range: EditorSelection.cursor(range.from + len),
+        changes: [{ from: range.from, to: range.to, insert: text }],
+      })),
+    );
+  }, []);
+
   return (
-    <Flex
-      className={cx(S.wrapper, { [S.formatting]: isFormatting })}
-      direction="column"
-    >
-      <CodeMirror
-        id={id}
-        ref={ref}
-        data-testid="custom-expression-query-editor"
-        className={S.editor}
-        extensions={extensions}
-        readOnly={readOnly || isFormatting}
-        value={source}
-        onChange={onSourceChange}
-        onBlur={onBlur}
-        height="100%"
-        width="100%"
-        indentWithTab={false}
-        autoFocus
-      />
-      <Errors error={error} />
+    <>
+      <LayoutMain className={cx(S.wrapper, { [S.formatting]: isFormatting })}>
+        <CodeMirror
+          id={id}
+          ref={ref}
+          data-testid="custom-expression-query-editor"
+          className={S.editor}
+          extensions={extensions}
+          readOnly={readOnly || isFormatting}
+          value={source}
+          onChange={onSourceChange}
+          onBlur={onBlur}
+          height="100%"
+          width="100%"
+          indentWithTab={false}
+          autoFocus
+        />
+        <Errors error={error} />
 
-      {source.trim() === "" && !isFormatting && error == null && (
-        <Shortcuts shortcuts={shortcuts} className={S.shortcuts} />
-      )}
+        {source.trim() === "" && !isFormatting && error == null && (
+          <Shortcuts shortcuts={shortcuts} className={S.shortcuts} />
+        )}
 
-      <Flex className={S.toolbar} pr="md" gap="sm">
-        {source.trim() !== "" && error == null && isValidated && (
-          <ButtonTooltip label={t`Auto-format`}>
+        <Flex className={S.toolbar} gap="sm" pt="sm" pr="sm" direction="column">
+          <ButtonTooltip label={t`Function browser`}>
             <Button
-              aria-label={t`Auto-format`}
-              onClick={formatExpression}
-              variant="subtle"
+              aria-label={t`Function browser`}
+              onClick={toggleFunctionBrowser}
+              variant={isFunctionBrowserOpen ? "filled" : "subtle"}
+              className={S.toolbarButton}
               size="xs"
-              p="xs"
-              disabled={isFormatting || error != null}
-              leftSection={<Icon name="format_code" />}
+              p="x"
+              leftSection={<Icon name="function" />}
             />
           </ButtonTooltip>
-        )}
-      </Flex>
+          {source.trim() !== "" && error == null && isValidated && (
+            <ButtonTooltip label={t`Auto-format`}>
+              <Button
+                aria-label={t`Auto-format`}
+                onClick={formatExpression}
+                className={S.toolbarButton}
+                variant="subtle"
+                size="xs"
+                p="xs"
+                disabled={isFormatting || error != null}
+                leftSection={<Icon name="format_code" />}
+              />
+            </ButtonTooltip>
+          )}
+        </Flex>
+
+        {portal}
+      </LayoutMain>
+
+      {isFunctionBrowserOpen && (
+        <LayoutSidebar h={hasHeader ? FB_HEIGHT_WITH_HEADER : FB_HEIGHT}>
+          <FunctionBrowser
+            startRule={startRule}
+            reportTimezone={reportTimezone}
+            query={query}
+            onClauseClick={handleFunctionBrowserClauseClick}
+          />
+        </LayoutSidebar>
+      )}
 
-      {portal}
       {showModal && (
         <CloseModal
           onKeepEditing={closeModal}
           onDiscardChanges={onCloseEditor}
         />
       )}
-    </Flex>
+    </>
   );
 }
 
diff --git a/frontend/src/metabase/query_builder/components/expressions/Editor/Shortcuts/Shortcuts.tsx b/frontend/src/metabase/query_builder/components/expressions/Editor/Shortcuts/Shortcuts.tsx
index f739f5111cde..8c171c66a09c 100644
--- a/frontend/src/metabase/query_builder/components/expressions/Editor/Shortcuts/Shortcuts.tsx
+++ b/frontend/src/metabase/query_builder/components/expressions/Editor/Shortcuts/Shortcuts.tsx
@@ -16,7 +16,7 @@ export function Shortcuts({
   className?: string;
 }) {
   return (
-    <Flex gap="sm" className={className}>
+    <Flex gap="sm" className={className} wrap="wrap">
       {shortcuts.map((shortcut, index) => (
         <Button
           key={index}
diff --git a/frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.module.css b/frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.module.css
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.tsx b/frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.tsx
index fb331a8f56b1..95808030e280 100644
--- a/frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.tsx
+++ b/frontend/src/metabase/query_builder/components/expressions/ExpressionWidget.tsx
@@ -15,13 +15,12 @@ import {
 import { CombineColumns, hasCombinations } from "./CombineColumns";
 import { Editor } from "./Editor";
 import type { Shortcut } from "./Editor/Shortcuts";
-import { ExpressionWidgetHeader } from "./ExpressionWidgetHeader";
 import { ExtractColumn, hasExtractions } from "./ExtractColumn";
+import { Layout, LayoutFooter, LayoutHeader } from "./Layout";
 import { NameInput } from "./NameInput";
 import type { ClauseType, StartRule } from "./types";
 
 const WIDGET_WIDTH = 472;
-const EDITOR_WIDGET_WIDTH = 688;
 
 export type ExpressionWidgetProps<S extends StartRule = "expression"> = {
   startRule?: S;
@@ -152,14 +151,12 @@ export const ExpressionWidget = <S extends StartRule = "expression">(
   if (startRule === "expression" && isCombiningColumns) {
     return (
       <Box w={WIDGET_WIDTH} data-testid="expression-editor">
-        <ExpressionWidgetHeader
-          title={t`Select columns to combine`}
-          onBack={handleCancel}
-        />
         <CombineColumns
           query={query}
           stageIndex={stageIndex}
+          onCancel={handleCancel}
           onSubmit={handleCombineColumnsSubmit}
+          withTitle
         />
       </Box>
     );
@@ -179,13 +176,8 @@ export const ExpressionWidget = <S extends StartRule = "expression">(
   }
 
   return (
-    <Box
-      w="calc(100vw - 2 * var(--mantine-spacing-lg))"
-      maw={EDITOR_WIDGET_WIDTH}
-      data-testid="expression-editor"
-      data-ignore-editor-clicks="true"
-    >
-      {header}
+    <Layout data-testid="expression-editor" data-ignore-editor-clicks="true">
+      {header && <LayoutHeader>{header}</LayoutHeader>}
 
       <Editor
         id="expression-content"
@@ -199,37 +191,39 @@ export const ExpressionWidget = <S extends StartRule = "expression">(
         reportTimezone={reportTimezone}
         shortcuts={shortcuts}
         error={error}
+        hasHeader={Boolean(header)}
         onCloseEditor={onClose}
       />
 
-      <Flex gap="xs" align="center" justify="end" p="0" pr="sm">
-        {withName && (
-          <NameInput
-            value={name}
-            onChange={setName}
-            onSubmit={handleSubmit}
-            startRule={startRule}
-          />
-        )}
-
-        <Flex py="sm" pr="sm" gap="sm">
-          {onClose && (
+      <LayoutFooter>
+        <Flex gap="xs" align="center" justify="end" p="0" pr="sm">
+          {withName && (
+            <NameInput
+              value={name}
+              onChange={setName}
+              onSubmit={handleSubmit}
+              startRule={startRule}
+            />
+          )}
+          <Flex py="sm" pr="sm" gap="sm">
+            {onClose && (
+              <Button
+                onClick={onClose}
+                variant="subtle"
+                size="xs"
+              >{t`Cancel`}</Button>
+            )}
             <Button
-              onClick={onClose}
-              variant="subtle"
+              variant="filled"
+              disabled={!isValid}
+              onClick={handleSubmit}
               size="xs"
-            >{t`Cancel`}</Button>
-          )}
-          <Button
-            variant="filled"
-            disabled={!isValid}
-            onClick={handleSubmit}
-            size="xs"
-          >
-            {initialName || initialClause ? t`Update` : t`Done`}
-          </Button>
+            >
+              {initialName || initialClause ? t`Update` : t`Done`}
+            </Button>
+          </Flex>
         </Flex>
-      </Flex>
-    </Box>
+      </LayoutFooter>
+    </Layout>
   );
 };
diff --git a/frontend/src/metabase/query_builder/components/expressions/ExpressionWidgetHeader.module.css b/frontend/src/metabase/query_builder/components/expressions/ExpressionWidgetHeader.module.css
index f8862735a889..e50f8b9f3b7d 100644
--- a/frontend/src/metabase/query_builder/components/expressions/ExpressionWidgetHeader.module.css
+++ b/frontend/src/metabase/query_builder/components/expressions/ExpressionWidgetHeader.module.css
@@ -4,6 +4,7 @@
   border-bottom: 1px solid var(--mb-color-border);
   display: flex;
   align-items: center;
+  height: 3rem;
 
   .HeaderButton {
     color: var(--mb-color-text-light);
diff --git a/frontend/src/metabase/query_builder/components/expressions/FunctionBrowser/FunctionBrowser.module.css b/frontend/src/metabase/query_builder/components/expressions/FunctionBrowser/FunctionBrowser.module.css
new file mode 100644
index 000000000000..b9434268d4ad
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/expressions/FunctionBrowser/FunctionBrowser.module.css
@@ -0,0 +1,46 @@
+.wrapper {
+  background-color: var(--mb-color-bg-light);
+  border-left: 1px solid var(--mb-color-border);
+  height: 100%;
+}
+
+.results {
+  overflow: auto;
+  height: 100%;
+
+  /* specificity */
+  .name {
+    line-height: 1em;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+}
+
+.description {
+  margin: 0;
+  padding: 0;
+}
+
+.clause {
+  user-select: none;
+  cursor: pointer;
+
+  &:hover {
+    background-color: var(--mb-color-focus);
+  }
+}
+
+.structure {
+  background-color: var(--mb-color-bg-white);
+  border-bottom: 1px solid var(--mb-color-border);
+  font-size: 0.75rem;
+  color: var(--mb-color-text-secondary);
+  font-family: var(--mb-default-monospace-font-family);
+}
+
+.documentation {
+  height: 100%;
+  overflow: auto;
+  flex-shrink: 1;
+}
diff --git a/frontend/src/metabase/query_builder/components/expressions/FunctionBrowser/FunctionBrowser.tsx b/frontend/src/metabase/query_builder/components/expressions/FunctionBrowser/FunctionBrowser.tsx
new file mode 100644
index 000000000000..bef4328a2043
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/expressions/FunctionBrowser/FunctionBrowser.tsx
@@ -0,0 +1,139 @@
+import {
+  type ChangeEvent,
+  type MouseEvent,
+  useCallback,
+  useMemo,
+  useState,
+} from "react";
+import { t } from "ttag";
+
+import EmptyState from "metabase/components/EmptyState";
+import { useSelector } from "metabase/lib/redux";
+import { getMetadata } from "metabase/selectors/metadata";
+import { Box, Flex, Icon, Input, Text } from "metabase/ui";
+import type * as Lib from "metabase-lib";
+import type { HelpText } from "metabase-lib/v1/expressions";
+
+import type { StartRule } from "../types";
+
+import S from "./FunctionBrowser.module.css";
+import { getDatabase, getFilteredClauses, getSearchPlaceholder } from "./utils";
+
+export function FunctionBrowser({
+  startRule,
+  reportTimezone,
+  query,
+  onClauseClick,
+}: {
+  startRule: StartRule;
+  query: Lib.Query;
+  reportTimezone?: string;
+  onClauseClick?: (name: string) => void;
+}) {
+  const metadata = useSelector(getMetadata);
+  const database = getDatabase(query, metadata);
+
+  const [filter, setFilter] = useState("");
+  const handleFilterChange = useCallback(
+    (evt: ChangeEvent<HTMLInputElement>) => setFilter(evt.target.value),
+    [],
+  );
+
+  const filteredClauses = useMemo(
+    () => getFilteredClauses({ startRule, filter, database, reportTimezone }),
+    [filter, startRule, database, reportTimezone],
+  );
+
+  const isEmpty = filteredClauses.length === 0;
+
+  return (
+    <Flex
+      pt="sm"
+      direction="column"
+      className={S.wrapper}
+      data-testid="expression-editor-function-browser"
+    >
+      <Input
+        size="sm"
+        mb="sm"
+        mx="md"
+        placeholder={getSearchPlaceholder(startRule)}
+        value={filter}
+        onChange={handleFilterChange}
+        leftSection={<Icon name="search" />}
+      />
+      <Flex
+        component="dl"
+        my={0}
+        pt={0}
+        pb="md"
+        direction="column"
+        justify={isEmpty ? "center" : "flex-start"}
+        className={S.results}
+      >
+        {isEmpty && (
+          <EmptyState message={t`Didn't find any results`} icon="search" />
+        )}
+        {filteredClauses.map(group => (
+          <>
+            <Text
+              size="sm"
+              p="md"
+              pb="sm"
+              c="var(--mb-color-text-medium)"
+              fw="bold"
+            >
+              {group.displayName}
+            </Text>
+            {group.clauses.map(clause => (
+              <FunctionBrowserItem
+                key={clause.name}
+                onClauseClick={onClauseClick}
+                clause={clause}
+              />
+            ))}
+          </>
+        ))}
+      </Flex>
+    </Flex>
+  );
+}
+
+function FunctionBrowserItem({
+  onClauseClick,
+  clause,
+}: {
+  onClauseClick?: (name: string) => void;
+  clause: HelpText;
+}) {
+  const handleMouseDown = useCallback(
+    (evt: MouseEvent<HTMLDivElement>) => {
+      evt.preventDefault();
+      if (clause.name) {
+        onClauseClick?.(clause.name);
+      }
+    },
+    [onClauseClick, clause.name],
+  );
+  return (
+    <Box
+      role="button"
+      key={clause.name}
+      className={S.clause}
+      px="md"
+      py="sm"
+      onMouseDown={handleMouseDown}
+    >
+      <dt>
+        <Text size="md" pb="xs" fw="bold" className={S.name}>
+          {clause.structure}
+        </Text>
+      </dt>
+      <dd className={S.description}>
+        <Text size="sm" c="var(--mb-color-text-medium)">
+          {clause.description}
+        </Text>
+      </dd>
+    </Box>
+  );
+}
diff --git a/frontend/src/metabase/query_builder/components/expressions/FunctionBrowser/index.tsx b/frontend/src/metabase/query_builder/components/expressions/FunctionBrowser/index.tsx
new file mode 100644
index 000000000000..0c5db444a5bf
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/expressions/FunctionBrowser/index.tsx
@@ -0,0 +1 @@
+export * from "./FunctionBrowser";
diff --git a/frontend/src/metabase/query_builder/components/expressions/FunctionBrowser/utils.ts b/frontend/src/metabase/query_builder/components/expressions/FunctionBrowser/utils.ts
new file mode 100644
index 000000000000..3cfd5d6f023b
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/expressions/FunctionBrowser/utils.ts
@@ -0,0 +1,108 @@
+import { t } from "ttag";
+
+import { isNotNull } from "metabase/lib/types";
+import * as Lib from "metabase-lib";
+import {
+  AGGREGATION_FUNCTIONS,
+  EXPRESSION_FUNCTIONS,
+  type HelpText,
+  type MBQLClauseFunctionConfig,
+  MBQL_CLAUSES,
+} from "metabase-lib/v1/expressions";
+import { getHelpText } from "metabase-lib/v1/expressions/helper-text-strings";
+import type Database from "metabase-lib/v1/metadata/Database";
+import type Metadata from "metabase-lib/v1/metadata/Metadata";
+
+import type { StartRule } from "../types";
+
+const EXPRESSION_CLAUSES = Array.from(EXPRESSION_FUNCTIONS).map(
+  name => MBQL_CLAUSES[name],
+);
+const AGGREGATION_CLAUSES = Array.from(AGGREGATION_FUNCTIONS).map(
+  name => MBQL_CLAUSES[name],
+);
+
+export function getSearchPlaceholder(startRule: StartRule) {
+  if (startRule === "expression" || startRule === "boolean") {
+    return t`Search functions…`;
+  }
+  if (startRule === "aggregation") {
+    return t`Search aggregations…`;
+  }
+}
+
+function getClauses(startRule: StartRule): MBQLClauseFunctionConfig[] {
+  if (startRule === "expression" || startRule === "boolean") {
+    return EXPRESSION_CLAUSES;
+  }
+  if (startRule === "aggregation") {
+    return AGGREGATION_CLAUSES;
+  }
+  return [];
+}
+
+function getCategoryName(category: string) {
+  switch (category) {
+    case "logical":
+      return t`Logical functions`;
+    case "math":
+      return t`Math functions`;
+    case "string":
+      return t`String functions`;
+    case "date":
+      return t`Date functions`;
+    case "window":
+      return t`Window functions`;
+    case "aggregation":
+      return t`Aggregations`;
+  }
+}
+
+export function getFilteredClauses({
+  startRule,
+  filter,
+  database,
+  reportTimezone,
+}: {
+  startRule: StartRule;
+  filter: string;
+  database: Database | null;
+  reportTimezone?: string;
+}) {
+  const clauses = getClauses(startRule);
+  const filteredClauses = clauses
+    .filter(
+      clause =>
+        database?.hasFeature(clause.requiresFeature) &&
+        clause.displayName.toLowerCase().includes(filter.toLowerCase()),
+    )
+    .map(clause =>
+      clause.name && database
+        ? getHelpText(clause.name, database, reportTimezone)
+        : null,
+    )
+    .filter(isNotNull);
+
+  const filteredCategories = new Set(
+    filteredClauses.map(clause => clause.category),
+  );
+
+  return Array.from(filteredCategories)
+    .sort()
+    .map(category => ({
+      category,
+      displayName: getCategoryName(category),
+      clauses: filteredClauses
+        .filter(clause => clause.category === category)
+        .sort(byName),
+    }));
+}
+
+function byName(a: HelpText, b: HelpText) {
+  return a.structure.localeCompare(b.structure);
+}
+
+export function getDatabase(query: Lib.Query, metadata: Metadata) {
+  const databaseId = Lib.databaseID(query);
+  return metadata.database(databaseId);
+}
diff --git a/frontend/src/metabase/query_builder/components/expressions/FunctionBrowser/utils.unit.spec.ts b/frontend/src/metabase/query_builder/components/expressions/FunctionBrowser/utils.unit.spec.ts
new file mode 100644
index 000000000000..adab773363a9
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/expressions/FunctionBrowser/utils.unit.spec.ts
@@ -0,0 +1,159 @@
+import { createMockMetadata } from "__support__/metadata";
+import { createSampleDatabase } from "metabase-types/api/mocks/presets";
+
+import type { StartRule } from "../types";
+
+import { getFilteredClauses } from "./utils";
+
+function setup({
+  filter = "",
+  startRule = "expression",
+}: {
+  filter?: string;
+  startRule?: StartRule;
+} = {}) {
+  const sampleDatabase = createSampleDatabase();
+  const metadata = createMockMetadata({ databases: [sampleDatabase] });
+  const database = metadata.database(sampleDatabase.id);
+  if (!database) {
+    throw new Error("Error in test: sample database not found");
+  }
+  database.hasFeature = jest.fn().mockReturnValue(true);
+
+  return getFilteredClauses({
+    filter,
+    startRule,
+    database,
+  });
+}
+
+describe("getFilteredClauses", () => {
+  it("should return all clauses when no filter is passed", () => {
+    const results = setup();
+
+    // The array should be sorted
+    expect(results.map(group => group.category)).toEqual(
+      ["date", "logical", "math", "string"].sort(),
+    );
+
+    const dateFunctions = results[0];
+
+    // The array should be sorted
+    expect(dateFunctions.clauses.map(clause => clause.structure)).toEqual(
+      [
+        "convertTimezone",
+        "datetimeAdd",
+        "datetimeDiff",
+        "datetimeSubtract",
+        "day",
+        "dayName",
+        "hour",
+        "interval",
+        "intervalStartingFrom",
+        "minute",
+        "month",
+        "monthName",
+        "now",
+        "quarter",
+        "quarterName",
+        "relativeDateTime",
+        "second",
+        "timeSpan",
+        "week",
+        "weekday",
+        "year",
+      ].sort(),
+    );
+  });
+
+  it("should filter clauses", () => {
+    const results = setup({
+      filter: "no",
+    });
+
+    // The array should be sorted
+    expect(results.map(group => group.category)).toEqual(
+      ["date", "logical", "string"].sort(),
+    );
+
+    // The array should be sorted
+    expect(results[0].clauses.map(clause => clause.name)).toEqual(["now"]);
+    expect(results[1].clauses.map(clause => clause.name)).toEqual([
+      "not-in",
+      "not-null",
+    ]);
+    expect(results[2].clauses.map(clause => clause.name)).toEqual([
+      "does-not-contain",
+      "not-empty",
+    ]);
+  });
+
+  it("should filter clauses based on display name", () => {
+    const results = setup({
+      filter: "regexex",
+    });
+
+    expect(results.map(group => group.category)).toEqual(["string"].sort());
+    expect(results[0].clauses.map(clause => clause.name)).toEqual([
+      "regex-match-first",
+    ]);
+  });
+
+  it("should not filter clauses based on display name", () => {
+    const results = setup({
+      filter: "regex-match-first",
+    });
+
+    expect(results).toHaveLength(0);
+  });
+
+  it("should find case", () => {
+    const results = setup({
+      filter: "case",
+    });
+
+    // The array should be sorted
+    expect(results.map(group => group.category)).toEqual(["logical"].sort());
+
+    // The array should be sorted
+    expect(results[0].clauses[0]).toEqual({
+      args: [
+        {
+          description: "Something that should evaluate to true or false.",
+          example: [">", ["dimension", "Weight"], 200],
+          name: "condition",
+        },
+        {
+          description:
+            "The value that will be returned if the preceding condition is true.",
+          example: "Large",
+          name: "output",
+        },
+        {
+          description: "You can add more conditions to test.",
+          example: [
+            "args",
+            [[">", ["dimension", "Weight"], 150], "Medium", "Small"],
+          ],
+          name: "…",
+        },
+      ],
+      category: "logical",
+      description:
+        "Alias for if(). Tests an expression against a list of cases and returns the corresponding value of the first matching case, with an optional default value if nothing else is met.",
+      docsPage: "case",
+      example: [
+        "case",
+        [
+          [[">", ["dimension", "Weight"], 200], "Large"],
+          [[">", ["dimension", "Weight"], 150], "Medium"],
+        ],
+        {
+          default: "Small",
+        },
+      ],
+      name: "case",
+      structure: "case",
+    });
+  });
+});
diff --git a/frontend/src/metabase/query_builder/components/expressions/Layout/Layout.module.css b/frontend/src/metabase/query_builder/components/expressions/Layout/Layout.module.css
new file mode 100644
index 000000000000..b5357020046e
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/expressions/Layout/Layout.module.css
@@ -0,0 +1,72 @@
+.layout {
+  display: grid;
+
+  --header-height: 48px;
+  --main-width: 688px;
+  --main-height: 280px;
+  --footer-height: 46px;
+  --sidebar-width: 424px;
+
+  grid-template-rows: min-content min-content min-content;
+  grid-template-columns:
+    minmax(var(--main-width), auto)
+    auto;
+
+  @media screen and (--breakpoint-max-md) {
+    --main-width: 65vw;
+    --sidebar-width: 35vw;
+  }
+
+  @media screen and (--breakpoint-max-sm) {
+    --main-width: 460px;
+    --sidebar-width: 180px;
+
+    background: var(--mb-color-bg-white);
+    position: fixed;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    grid-template-rows: min-content 1fr var(--footer-height);
+    grid-template-columns:
+      2fr
+      min-content;
+  }
+}
+
+.header {
+  height: var(--header-height);
+  grid-row: 1;
+  grid-column: 1 / span 2;
+}
+
+.main {
+  grid-row: 2;
+  grid-column: 1;
+  height: var(--main-height);
+  width: var(--main-width);
+
+  @media (max-width: 640px) {
+    width: auto;
+    height: auto;
+  }
+}
+
+.sidebar {
+  grid-column: 2;
+  grid-row: 1 / span 3;
+  overflow: hidden;
+  max-width: var(--sidebar-width);
+  min-height: 100%;
+
+  @media screen and (--breakpoint-max-sm) {
+    width: 35vw;
+    height: 100vh;
+  }
+}
+
+.footer {
+  height: var(--footer-height);
+  grid-row: 3;
+  grid-column: 1;
+}
diff --git a/frontend/src/metabase/query_builder/components/expressions/Layout/Layout.tsx b/frontend/src/metabase/query_builder/components/expressions/Layout/Layout.tsx
new file mode 100644
index 000000000000..16b4c91b5ae7
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/expressions/Layout/Layout.tsx
@@ -0,0 +1,28 @@
+import cx from "classnames";
+import type { HTMLAttributes } from "react";
+
+import { Box, type BoxProps } from "metabase/ui";
+
+import S from "./Layout.module.css";
+
+type Props = BoxProps & HTMLAttributes<HTMLDivElement>;
+
+export function Layout(props: Props) {
+  return <Box {...props} className={cx(S.layout, props.className)} />;
+}
+
+export function LayoutHeader(props: Props) {
+  return <Box {...props} className={cx(S.header, props.className)} />;
+}
+
+export function LayoutMain(props: Props) {
+  return <Box {...props} className={cx(S.main, props.className)} />;
+}
+
+export function LayoutSidebar(props: Props) {
+  return <Box {...props} className={cx(S.sidebar, props.className)} />;
+}
+
+export function LayoutFooter(props: Props) {
+  return <Box {...props} className={cx(S.footer, props.className)} />;
+}
diff --git a/frontend/src/metabase/query_builder/components/expressions/Layout/index.tsx b/frontend/src/metabase/query_builder/components/expressions/Layout/index.tsx
new file mode 100644
index 000000000000..f7ad54817ada
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/expressions/Layout/index.tsx
@@ -0,0 +1 @@
+export * from "./Layout";


Reviews:
user: login: ranquild
body: Function order looks arbitrary. @mngr wdyt? 
<img width="520" alt="Screenshot 2025-03-17 at 11 07 19" src="https://github.com/user-attachments/assets/f092eb35-0493-4821-8a74-3903a8e9123a" />

state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T15:08:29Z
user: login: romeovs
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:07:26Z
user: login: romeovs
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:11:56Z
user: login: ranquild
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T15:54:49Z
user: login: mngr
state: APPROVED
author_association: NONE
submitted_at: 2025-03-18T17:54:55Z

Review comments:
diff_hunk: @@ -0,0 +1,76 @@
+.layout {
+  display: grid;
+
+  --header-height: 48px;
+  --main-width: 688px;
+  --main-height: 280px;
+  --footer-height: 46px;
+  --sidebar-width: 424px;
+
+  grid-template-rows: min-content min-content min-content;
+  grid-template-columns:
+    minmax(var(--main-width), auto)
+    auto;
+
+  @media (max-width: 920px) {
path: frontend/src/metabase/query_builder/components/expressions/Layout/Layout.module.css
user: login: ranquild
body: can we use standard breakouts? maybe with mantine?
created_at: 2025-03-17T15:04:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,33 @@
+import cx from "classnames";
+import type { HTMLAttributes } from "react";
+
+import { Box, type BoxProps } from "metabase/ui";
+
+import S from "./Layout.module.css";
+
+type Props = BoxProps & HTMLAttributes<HTMLDivElement>;
+
+export function Layout(props: Props) {
+  return <Box {...props} className={cx(S.layout, props.className)} />;
+}
+
+function LayoutHeader(props: Props) {
+  return <Box {...props} className={cx(S.header, props.className)} />;
+}
+
+function LayoutMain(props: Props) {
+  return <Box {...props} className={cx(S.main, props.className)} />;
+}
+
+function LayoutSidebar(props: Props) {
+  return <Box {...props} className={cx(S.sidebar, props.className)} />;
+}
+
+function LayoutFooter(props: Props) {
+  return <Box {...props} className={cx(S.footer, props.className)} />;
+}
+
+Layout.Header = LayoutHeader;
path: frontend/src/metabase/query_builder/components/expressions/Layout/Layout.tsx
user: login: ranquild
body: I think we don't normally do that and use components directly. This will make tree-shaking impossible
created_at: 2025-03-17T15:04:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -18,7 +18,7 @@ export const ExpressionWidgetHeader = ({
   onBack,
 }: Props): JSX.Element => {
   return (
-    <Flex className={ExpressionWidgetHeaderS.Header}>
+    <Flex className={ExpressionWidgetHeaderS.Header} maw={688}>
path: frontend/src/metabase/query_builder/components/expressions/ExpressionWidgetHeader.tsx
user: login: ranquild
body: magic constant?
created_at: 2025-03-17T15:04:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -18,7 +18,7 @@ export const ExpressionWidgetHeader = ({
   onBack,
 }: Props): JSX.Element => {
   return (
-    <Flex className={ExpressionWidgetHeaderS.Header}>
+    <Flex className={ExpressionWidgetHeaderS.Header} maw={688}>
path: frontend/src/metabase/query_builder/components/expressions/ExpressionWidgetHeader.tsx
user: login: romeovs
body: Not needed anymore so I removed it.
created_at: 2025-03-17T16:07:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,76 @@
+.layout {
+  display: grid;
+
+  --header-height: 48px;
+  --main-width: 688px;
+  --main-height: 280px;
+  --footer-height: 46px;
+  --sidebar-width: 424px;
+
+  grid-template-rows: min-content min-content min-content;
+  grid-template-columns:
+    minmax(var(--main-width), auto)
+    auto;
+
+  @media (max-width: 920px) {
path: frontend/src/metabase/query_builder/components/expressions/Layout/Layout.module.css
user: login: romeovs
body: There are not enough standard ones to fill the space between the `md` and `sm` breakpoints here. I've tried solving the issue with screen-relative units and I think it works well enough! (cc @AlessioLaiso)
created_at: 2025-03-17T16:11:55Z
author_association: CONTRIBUTOR

PR comments:
user: login: trunk-io[bot]
created_at: 2025-03-13T16:08:49Z
body: <!-- Trunk Test Analytics -->


[![Static Badge](https://img.shields.io/badge/27652_tests-589543d8-purple)](https://app.trunk.io/metabase/flaky-tests/pr/55102?repo=metabase/metabase&commitHash=589543d808fe035e5f6fe2dde0ee2af57122c3c2&utm_source=gh-comment) &ensp; [![Static Badge](https://img.shields.io/badge/0-failed-crimson)](https://app.trunk.io/metabase/flaky-tests/pr/55102?repo=metabase/metabase&commitHash=589543d808fe035e5f6fe2dde0ee2af57122c3c2&utm_source=gh-comment&runConclusion=FAILURE) &ensp; [![Static Badge](https://img.shields.io/badge/2-flaky-red)](https://app.trunk.io/metabase/flaky-tests/pr/55102?repo=metabase/metabase&commitHash=589543d808fe035e5f6fe2dde0ee2af57122c3c2&utm_source=gh-comment&runConclusion=FAILURE) &ensp; [![Static Badge](https://img.shields.io/badge/0-quarantined-yellow)](https://app.trunk.io/metabase/flaky-tests/pr/55102?repo=metabase/metabase&commitHash=589543d808fe035e5f6fe2dde0ee2af57122c3c2&utm_source=gh-comment&runConclusion=QUARANTINED)


| Flaky Test | Failure Summary | Logs |
| --- | --- | --- |
| <code>scenarios > visualizations > line/bar chart > tooltips should be enterable and scollable to view all rows in long tooltips (metabase\#53586) (metab...</code> | The test timed out while trying to find an element with data-testid "echarts-tooltip". | [Logs ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/55102?repo=metabase/metabase&commitHash=589543d808fe035e5f6fe2dde0ee2af57122c3c2&utm_source=gh-comment&runConclusion=FAILURE&runId=fc448f43-cad9-559d-8e17-adf8336fe137) |
| <code>scenarios > dashboard warn before leave should warn a user before leaving after adding, removed, moving, or duplicating a tab</code> | Unable to find an element with the text "You're editing this dashboard" in the expected time. | [Logs ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/55102?repo=metabase/metabase&commitHash=589543d808fe035e5f6fe2dde0ee2af57122c3c2&utm_source=gh-comment&runConclusion=FAILURE&runId=8eb9a593-334a-5907-92d6-81c86e54cf48) |
<sub>

[View Full Report ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/55102?repo=metabase/metabase&commitHash=589543d808fe035e5f6fe2dde0ee2af57122c3c2&utm_source=gh-comment) ⋅ [Docs](https://docs.trunk.io/flaky-tests?utm_source=gh-comment)

</sub>
author_association: NONE
user: login: mngr
created_at: 2025-03-14T15:48:59Z
body: I like the behavior! Inserting the function with click is 👌
For some reason case function is not searchable.
<img width="200" alt="Captura de ecrã 2025-03-14, às 15 44 11" src="https://github.com/user-attachments/assets/1bbfcb18-b39a-4a80-b6fc-377816b63d72" />
The buttons for functions and auto-format don't match
<img width="200" alt="Captura de ecrã 2025-03-14, às 15 44 43" src="https://github.com/user-attachments/assets/31836947-68b5-4e32-bdbf-3fd81af39611" />
And the text in the function browser is difficult to read, not sure why
<img width="200" alt="Captura de ecrã 2025-03-14, às 15 43 34" src="https://github.com/user-attachments/assets/9d60ab7a-9fe3-430f-87bd-522c5c78f632" />
@AlessioLaiso any ideas how to make it more legible?

author_association: NONE
user: login: AlessioLaiso
created_at: 2025-03-14T20:52:29Z
body: The function browser is much narrower than in the designs, where it's 424px. This should also help making the sentences more readable.

<img width="437" alt="Screenshot 2025-03-14 at 20 50 53" src="https://github.com/user-attachments/assets/046300ca-8830-44c7-a61e-ef55eab09d17" />

author_association: CONTRIBUTOR
user: login: romeovs
created_at: 2025-03-17T13:30:53Z
body: @mngr Please have another look.

> The buttons for functions and auto-format don't match

Is is as per design. The function browser button is a toggle so it stays blue when it's open.


> The function browser is much narrower than in the designs, where it's 424px.

Fixed

> For some reason case function is not searchable.

Fixed!
author_association: CONTRIBUTOR
user: login: mngr
created_at: 2025-03-17T15:45:03Z
body: <img width="1143" alt="Captura de ecrã 2025-03-17, às 15 41 07" src="https://github.com/user-attachments/assets/ec96aeb7-f375-4091-8dfc-952c39a43be1" />
@AlessioLaiso Functions browser button when active looks way too prominent, I wonder if we should make it with just a border without the blue background? or use some other style? Also it seems the vertical padding should be bigger between the buttons
author_association: NONE
user: login: AlessioLaiso
created_at: 2025-03-17T17:14:06Z
body: > Functions browser button when active looks way too prominent, I wonder if we should make it with just a border without the blue background? or use some other style? Also it seems the vertical padding should be bigger between the buttons

Agree regarding increasing the vertical padding: right now it looks like it's 0? Let's make it 8.

Regarding how prominent the toggle is: that's a component that's we've used with the same styles in other places, so we might want to keep it as it is for now, but let me double check with the designers and see if that's something we're open to changing.
author_association: CONTRIBUTOR
user: login: ranquild
created_at: 2025-03-18T15:55:46Z
body: @romeovs there is one small bug - you seem to compare the mbql function name and not display name: 
<img width="475" alt="Screenshot 2025-03-18 at 11 55 06" src="https://github.com/user-attachments/assets/00f54dd5-be35-430a-9282-b028b0487325" /> E.g. `interval` (display-name) vs `time-interval` (mbql name).

author_association: CONTRIBUTOR
user: login: romeovs
created_at: 2025-03-18T16:21:12Z
body: @ranquild The code compares the [`displayName`](https://github.com/metabase/metabase/pull/55102/files#diff-fd71d097a7ec609ed305b03ef1412cc7197fe53cf4294bd50e4268e0d43dfc14R77) of the clause to the filter. 

So it should match `regexextract` when typing `regexex`, even though the mbql name is `regex-match-first`:

<img width="467" alt="Screenshot 2025-03-18 at 17 15 53" src="https://github.com/user-attachments/assets/61cfe2a3-492e-4b8f-842b-ba3473954510" />


Just to make that extra clear, I've added a unit test to make sure we are matching on `displayName`.
author_association: CONTRIBUTOR
user: login: ranquild
created_at: 2025-03-18T16:22:08Z
body: @romeovs not matching, I mean sorting. Look at the screenshot at my comment - it should be obvious that sorting is wrong.
author_association: CONTRIBUTOR
user: login: romeovs
created_at: 2025-03-18T16:32:47Z
body: @ranquild oh of course! Fixed and updated the test.
author_association: CONTRIBUTOR
user: login: mngr
created_at: 2025-03-18T16:35:40Z
body: @romeovs thanks for adding the padding between the buttons! now it looks good
Could you please also check the styles of the function browser, I see a lot of differences compared to the figma 
<img width="665" alt="Captura de ecrã 2025-03-18, às 16 32 42" src="https://github.com/user-attachments/assets/13c5db6a-a99c-4648-9fd7-ab5764760dcb" />

- Borders, icon and text color and size for the search input
- Function names text size (and color?)
- Padding between the functions
author_association: NONE
user: login: romeovs
created_at: 2025-03-18T16:46:27Z
body: @mngr I've increased the font size and fixed the spacing issues. @AlessioLaiso and I talked about the searchbox and decided to use the Mantine one we currently have instead of implementing a new one.
author_association: CONTRIBUTOR
user: login: mngr
created_at: 2025-03-18T17:02:01Z
body: > @mngr I've increased the font size and fixed the spacing issues. @AlessioLaiso and I talked about the searchbox and decided to use the Mantine one we currently have instead of implementing a new one.
@romeovs did you commit the changes? cause I don't see them
author_association: NONE
user: login: romeovs
created_at: 2025-03-18T17:36:27Z
body: @mngr Please have another look

author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-19T08:49:47Z
body: 👋 Deploying a preview environment for commit 589543d808fe035e5f6fe2dde0ee2af57122c3c2.
✅ Preview:
    https://pr55102.coredev.metabase.com
🗒️ [login instructions](https://www.notion.so/metabase/Ephemeral-PR-Environments-c74a3710c87a460dbf4fc0007a001458)
<!-- Sticky Pull Request Comment -->
author_association: CONTRIBUTOR