Repository: metabase/metabase
Pull request #55627: Add hover effect for dirty query state
Author: login: ranquild
Labels: name: backport
description: Automatically create PR on current release branch on merge
name: .Team/Querying
name: PR-Env
description: See https://www.notion.so/metabase/Ephemeral-PR-Environments-c74a3710c87a460dbf4fc0007a001458
Created: 2025-03-24T13:22:23Z | Merged: 2025-03-24T16:54:54Z

Changes:
- Updates the run button shown in the overlay
- Adds a background color animation effect for the overlay
- The run button doesn't change its size on hover. This is done in https://github.com/metabase/metabase/pull/55629 so that you can compare.

How to verify:
- New -> Question -> Orders -> Visualize
- Filter -> ID -> Equal to 1 -> Add filter
- You should see the dirty query state with a play button in the middle
- The background color should change on hover

<img width="1728" alt="Screenshot 2025-03-24 at 10 12 47" src="https://github.com/user-attachments/assets/1a2e9dde-ea4c-40e4-bb52-b54607e9af07" />




Diff:
diff --git a/frontend/src/metabase/core/components/Button/Button.tsx b/frontend/src/metabase/core/components/Button/Button.tsx
index c1d5ce386e3d..d5675e4676ff 100644
--- a/frontend/src/metabase/core/components/Button/Button.tsx
+++ b/frontend/src/metabase/core/components/Button/Button.tsx
@@ -132,6 +132,7 @@ const BaseButton = forwardRef(function BaseButton(
       <ButtonContent iconVertical={iconVertical}>
         {icon && typeof icon === "string" ? (
           <Icon
+            className={classNames.icon}
             color={iconColor}
             name={icon as unknown as IconName}
             size={iconSize ? iconSize : 16}
diff --git a/frontend/src/metabase/css/query_builder.module.css b/frontend/src/metabase/css/query_builder.module.css
index 2d19f95bbeb4..18a090389cb6 100644
--- a/frontend/src/metabase/css/query_builder.module.css
+++ b/frontend/src/metabase/css/query_builder.module.css
@@ -120,24 +120,6 @@
   transition: background 0.3s linear;
 }
 
-.Loading {
-  background-color: color-mix(
-    in srgb,
-    var(--mb-color-bg-white) 70%,
-    transparent
-  );
-  transition: opacity 0.5s;
-}
-
-.Loading.LoadingHidden {
-  background-color: transparent;
-  pointer-events: none;
-}
-
-.Loading.LoadingHidden * {
-  pointer-events: none;
-}
-
 .QueryError {
   flex-direction: column;
   justify-content: center;
@@ -283,8 +265,13 @@
     opacity 0.25s;
 }
 
-.RunButton.RunButtonCompact {
-  padding: 16px 32px;
+.RunButton.RunButtonCircular {
+  padding: 0.25rem 1.5rem;
+  border: none;
+
+  .RunButtonIcon {
+    transform: translateX(2px);
+  }
 }
 
 .RunButton.RunButtonHidden {
@@ -294,7 +281,7 @@
 
 @media (prefers-reduced-motion) {
   .RunButton,
-  .RunButton.RunButtonCompact,
+  .RunButton.RunButtonCircular,
   .RunButton.RunButtonHidden {
     transition-duration: 10ms;
   }
@@ -335,3 +322,31 @@
 .QueryBuilder .ScalarValue {
   font-size: 5em;
 }
+
+.Overlay {
+  background-color: color-mix(
+    in srgb,
+    var(--mb-color-bg-white) 75%,
+    transparent
+  );
+  transition:
+    opacity 0.5s,
+    background-color 0.5s;
+
+  &.OverlayActive {
+    cursor: pointer;
+
+    &:hover {
+      background-color: color-mix(
+        in srgb,
+        var(--mb-color-brand) 3%,
+        color-mix(in srgb, var(--mb-color-bg-white) 75%, transparent)
+      );
+    }
+  }
+
+  &.OverlayHidden {
+    background-color: transparent;
+    pointer-events: none;
+  }
+}
diff --git a/frontend/src/metabase/query_builder/components/NativeQueryEditor/NativeQueryEditorSidebar/NativeQueryEditorSidebar.tsx b/frontend/src/metabase/query_builder/components/NativeQueryEditor/NativeQueryEditorSidebar/NativeQueryEditorSidebar.tsx
index 2554d3c04dcb..8a8475b3898c 100644
--- a/frontend/src/metabase/query_builder/components/NativeQueryEditor/NativeQueryEditorSidebar/NativeQueryEditorSidebar.tsx
+++ b/frontend/src/metabase/query_builder/components/NativeQueryEditor/NativeQueryEditorSidebar/NativeQueryEditorSidebar.tsx
@@ -127,7 +127,6 @@ export const NativeQueryEditorSidebar = (
           isDirty={isResultDirty}
           onRun={runQuery}
           onCancel={cancelQuery}
-          compact
           getTooltip={getTooltip}
         />
       )}
diff --git a/frontend/src/metabase/query_builder/components/QueryVisualization.jsx b/frontend/src/metabase/query_builder/components/QueryVisualization.jsx
index df0483fe5278..2a026834f249 100644
--- a/frontend/src/metabase/query_builder/components/QueryVisualization.jsx
+++ b/frontend/src/metabase/query_builder/components/QueryVisualization.jsx
@@ -11,7 +11,7 @@ import QueryBuilderS from "metabase/css/query_builder.module.css";
 import { isMac } from "metabase/lib/browser";
 import { useSelector } from "metabase/lib/redux";
 import { getWhiteLabeledLoadingMessageFactory } from "metabase/selectors/whitelabel";
-import { Box, Flex, Stack, Text } from "metabase/ui";
+import { Box, Flex, Stack, Text, Title } from "metabase/ui";
 import * as Lib from "metabase-lib";
 import { HARD_ROW_LIMIT } from "metabase-lib/v1/queries/utils";
 
@@ -26,6 +26,7 @@ export default function QueryVisualization(props) {
   const {
     className,
     question,
+    isRunnable,
     isRunning,
     isObjectDetail,
     isResultDirty,
@@ -49,6 +50,7 @@ export default function QueryVisualization(props) {
         hidden={
           !canRun ||
           !isResultDirty ||
+          !isRunnable ||
           isRunning ||
           isNativeEditorOpen ||
           result?.error
@@ -136,37 +138,34 @@ export function VisualizationRunningState({ className = "" }) {
   const message = getLoadingMessage(isSlow());
 
   return (
-    <div
-      className={cx(
-        className,
-        QueryBuilderS.Loading,
-        CS.flex,
-        CS.flexColumn,
-        CS.layoutCentered,
-        CS.textBrand,
-      )}
+    <Flex
+      className={cx(className, QueryBuilderS.Overlay)}
+      c="brand"
+      direction="column"
+      justify="center"
+      align="center"
     >
       <LoadingSpinner />
-      <h2 className={cx(CS.textBrand, CS.textUppercase, CS.my3)}>{message}</h2>
-    </div>
+      <Title className={CS.textUppercase} c="brand" order={2} mt="lg">
+        {message}
+      </Title>
+    </Flex>
   );
 }
 
 export const VisualizationDirtyState = ({
   className,
   result,
-  isRunnable,
   isRunning,
   isResultDirty,
   runQuestionQuery,
   cancelQuery,
   hidden,
 }) => {
-  const isEnabled = isRunnable && !hidden;
   const keyboardShortcut = getRunQueryShortcut();
 
   const handleClick = () => {
-    if (isEnabled) {
+    if (!hidden) {
       if (isRunning) {
         cancelQuery();
       } else {
@@ -176,32 +175,28 @@ export const VisualizationDirtyState = ({
   };
 
   return (
-    <div
-      className={cx(
-        className,
-        QueryBuilderS.Loading,
-        CS.flex,
-        CS.flexColumn,
-        CS.layoutCentered,
-        CS.cursorPointer,
-        { [QueryBuilderS.LoadingHidden]: hidden },
-      )}
+    <Flex
+      className={cx(className, QueryBuilderS.Overlay, {
+        [QueryBuilderS.OverlayActive]: !hidden,
+        [QueryBuilderS.OverlayHidden]: hidden,
+      })}
+      direction="column"
+      justify="center"
+      align="center"
+      gap="sm"
       data-testid="run-button-overlay"
       onClick={handleClick}
     >
-      <Stack gap="sm" align="center">
-        <RunButtonWithTooltip
-          className={CS.shadowed}
-          circular
-          compact
-          result={result}
-          hidden={!isEnabled}
-          isRunning={isRunning}
-          isDirty={isResultDirty}
-        />
-        {isEnabled && <Text c="text-medium">{keyboardShortcut}</Text>}
-      </Stack>
-    </div>
+      <RunButtonWithTooltip
+        className={CS.shadowed}
+        iconSize={32}
+        circular
+        hidden={hidden}
+        isRunning={isRunning}
+        isDirty={isResultDirty}
+      />
+      {!hidden && <Text c="text-medium">{keyboardShortcut}</Text>}
+    </Flex>
   );
 };
 
diff --git a/frontend/src/metabase/query_builder/components/RunButton.tsx b/frontend/src/metabase/query_builder/components/RunButton.tsx
index 0a2e0a9f31e1..9cba2822deb4 100644
--- a/frontend/src/metabase/query_builder/components/RunButton.tsx
+++ b/frontend/src/metabase/query_builder/components/RunButton.tsx
@@ -11,10 +11,11 @@ interface RunButtonProps {
   className?: string;
   isRunning: boolean;
   isDirty: boolean;
-  compact?: boolean;
   circular?: boolean;
-  borderless?: boolean;
+  medium?: boolean;
   hidden?: boolean;
+  onlyIcon?: boolean;
+  iconSize?: number;
   onRun?: () => void;
   onCancel?: () => void;
 }
@@ -26,7 +27,6 @@ const RunButton = forwardRef(function RunButton(
     onRun,
     onCancel,
     className,
-    compact,
     circular,
     hidden,
     ...props
@@ -35,26 +35,25 @@ const RunButton = forwardRef(function RunButton(
 ) {
   const icon = getButtonIcon(isRunning, isDirty);
   const ariaLabel = getButtonLabel(isRunning, isDirty);
-  const buttonLabel = compact || (!isRunning && !isDirty) ? null : ariaLabel;
 
   return (
     <Button
       {...props}
-      icon={icon}
-      primary={isDirty}
+      ref={ref}
       className={cx(className, QueryBuilderS.RunButton, {
         [QueryBuilderS.RunButtonHidden]: hidden,
-        [QueryBuilderS.RunButtonCompact]:
-          circular && !props.borderless && compact,
+        [QueryBuilderS.RunButtonCircular]: circular,
         [CS.circular]: circular,
       })}
+      classNames={{
+        icon: QueryBuilderS.RunButtonIcon,
+      }}
+      icon={icon}
+      primary={isDirty}
       data-testid="run-button"
-      onClick={isRunning ? onCancel : onRun}
-      ref={ref}
       aria-label={ariaLabel}
-    >
-      {buttonLabel}
-    </Button>
+      onClick={isRunning ? onCancel : onRun}
+    />
   );
 });
 
diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/ViewTitleHeaderRightSide/ViewTitleHeaderRightSide.tsx b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/ViewTitleHeaderRightSide/ViewTitleHeaderRightSide.tsx
index ffd3d32657fa..42d7180566c1 100644
--- a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/ViewTitleHeaderRightSide/ViewTitleHeaderRightSide.tsx
+++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/ViewTitleHeaderRightSide/ViewTitleHeaderRightSide.tsx
@@ -200,8 +200,6 @@ export function ViewTitleHeaderRightSide({
             iconSize={16}
             onlyIcon
             medium
-            compact
-            result={result}
             isRunning={isRunning}
             isDirty={isResultDirty}
             onRun={() => runQuestionQuery({ ignoreCache: true })}


Reviews:
user: login: ranquild
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T16:04:55Z
user: login: uladzimirdev
body: LGTM, works fine locally
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T16:29:29Z
user: login: romeovs
body: Looks good, just have some nits.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T16:39:17Z
user: login: mngr
state: APPROVED
author_association: NONE
submitted_at: 2025-03-24T16:53:54Z

Review comments:
diff_hunk: @@ -11,10 +11,11 @@ interface RunButtonProps {
   className?: string;
   isRunning: boolean;
   isDirty: boolean;
-  compact?: boolean;
path: frontend/src/metabase/query_builder/components/RunButton.tsx
user: login: ranquild
body: 1. `compact` is always `true`
2. `borderless` is not passed
3. `medium`, `onlyIcon`, `iconSize` are actually passed but not mentioned in the types
created_at: 2025-03-24T16:04:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -335,3 +322,31 @@
 .QueryBuilder .ScalarValue {
   font-size: 5em;
 }
+
+.Overlay {
+  background-color: color-mix(
+    in srgb,
+    var(--mb-color-bg-white) 75%,
+    transparent
+  );
+  transition:
+    opacity 0.5s,
+    background-color 0.5s;
+
+  &.OverlayActive {
+    cursor: pointer;
+
+    &:hover {
+      background-color: color-mix(
+        in srgb,
+        var(--mb-color-brand) 3%,
+        color-mix(in srgb, var(--mb-color-bg-white) 75%, transparent)
+      );
path: frontend/src/metabase/css/query_builder.module.css
user: login: uladzimirdev
body: isn't it too much mixing? can't it be some pre-defined color?
created_at: 2025-03-24T16:28:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -335,3 +322,31 @@
 .QueryBuilder .ScalarValue {
   font-size: 5em;
 }
+
+.Overlay {
+  background-color: color-mix(
+    in srgb,
+    var(--mb-color-bg-white) 75%,
+    transparent
+  );
+  transition:
+    opacity 0.5s,
+    background-color 0.5s;
+
+  &.OverlayActive {
+    cursor: pointer;
+
+    &:hover {
+      background-color: color-mix(
+        in srgb,
+        var(--mb-color-brand) 3%,
+        color-mix(in srgb, var(--mb-color-bg-white) 75%, transparent)
+      );
+    }
+  }
+
+  &.OverlayHidden {
+    background-color: transparent;
+    pointer-events: none;
path: frontend/src/metabase/css/query_builder.module.css
user: login: uladzimirdev
body: can't it be just display: none? 🤔 
created_at: 2025-03-24T16:29:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -335,3 +322,31 @@
 .QueryBuilder .ScalarValue {
   font-size: 5em;
 }
+
+.Overlay {
+  background-color: color-mix(
+    in srgb,
+    var(--mb-color-bg-white) 75%,
+    transparent
+  );
+  transition:
+    opacity 0.5s,
+    background-color 0.5s;
+
+  &.OverlayActive {
+    cursor: pointer;
+
+    &:hover {
+      background-color: color-mix(
+        in srgb,
+        var(--mb-color-brand) 3%,
+        color-mix(in srgb, var(--mb-color-bg-white) 75%, transparent)
+      );
+    }
+  }
+
path: frontend/src/metabase/css/query_builder.module.css
user: login: romeovs
body: Nit: since `OverlayActive` and `OverlayHidden` are mutually exclusive, and `OverlayHidden` has `pointer-events: none`, you could lift `cursor: pointer` to `Overlay` and remove a level of nesting.

```suggestion
  cursor: pointer;
  
  &.OverlayActive:hover {
    background-color: color-mix(
      in srgb,
      var(--mb-color-brand) 3%,
      color-mix(in srgb, var(--mb-color-bg-white) 75%, transparent)
    );
  }

```

Even better would be to remove one of the mutually exclusive classes.
created_at: 2025-03-24T16:37:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -136,37 +138,34 @@ export function VisualizationRunningState({ className = "" }) {
   const message = getLoadingMessage(isSlow());
 
   return (
-    <div
-      className={cx(
-        className,
-        QueryBuilderS.Loading,
-        CS.flex,
-        CS.flexColumn,
-        CS.layoutCentered,
-        CS.textBrand,
-      )}
+    <Flex
+      className={cx(className, QueryBuilderS.Overlay)}
+      c="brand"
+      direction="column"
+      justify="center"
+      align="center"
     >
       <LoadingSpinner />
-      <h2 className={cx(CS.textBrand, CS.textUppercase, CS.my3)}>{message}</h2>
-    </div>
+      <Title className={CS.textUppercase} c="brand" order={2} mt="lg">
+        {message}
+      </Title>
+    </Flex>
   );
 }
 
 export const VisualizationDirtyState = ({
   className,
   result,
-  isRunnable,
   isRunning,
   isResultDirty,
   runQuestionQuery,
   cancelQuery,
   hidden,
 }) => {
-  const isEnabled = isRunnable && !hidden;
   const keyboardShortcut = getRunQueryShortcut();
 
   const handleClick = () => {
-    if (isEnabled) {
+    if (!hidden) {
path: frontend/src/metabase/query_builder/components/QueryVisualization.jsx
user: login: romeovs
body: This could use a guard clause instead.
created_at: 2025-03-24T16:38:59Z
author_association: CONTRIBUTOR

PR comments:
user: login: trunk-io[bot]
created_at: 2025-03-24T14:46:34Z
body: <!-- Trunk Test Analytics -->


[![Static Badge](https://img.shields.io/badge/3_tests-6dc02d28-purple)](https://app.trunk.io/metabase/flaky-tests/pr/55627?repo=metabase/metabase&commitHash=6dc02d28864c1a141800f6f131c7596d31114601&utm_source=gh-comment) &ensp; [![Static Badge](https://img.shields.io/badge/0-failed-crimson)](https://app.trunk.io/metabase/flaky-tests/pr/55627?repo=metabase/metabase&commitHash=6dc02d28864c1a141800f6f131c7596d31114601&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/55627?repo=metabase/metabase&commitHash=6dc02d28864c1a141800f6f131c7596d31114601&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/55627?repo=metabase/metabase&commitHash=6dc02d28864c1a141800f6f131c7596d31114601&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/55627?repo=metabase/metabase&commitHash=6dc02d28864c1a141800f6f131c7596d31114601&utm_source=gh-comment&runConclusion=FAILURE&runId=1559f48a-7749-5410-839b-70c9d4bc1111) |
| <code>issue 39487 calendar has constant size when using date range picker filter (metabase\#39487)</code> | The modal dialog was not found within 4 seconds, causing a timeout. | [Logs ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/55627?repo=metabase/metabase&commitHash=6dc02d28864c1a141800f6f131c7596d31114601&utm_source=gh-comment&runConclusion=FAILURE&runId=be2715a8-6689-5d78-badb-efe041144499) |
<sub>

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

</sub>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-24T14:58:17Z
body: 
  ## e2e tests failed on `4afc9edceb99d0b95d449134e08c32e56488dac2-2`

  [e2e test run](https://github.com/metabase/metabase/actions/runs/14036391352?pr=55627)

  File | Test Name
  ---- | ---------
  `reproductions.cy.spec.ts` | (_flaky_) issue 39487 > calendar has constant size when using date range picker filter (metabase#39487)
`line-bar-tooltips.cy.spec.js` | (_flaky_) scenarios > visualizations > line/bar chart > tooltips > should be enterable and scollable to view all rows in long tooltips (metabase#53586) (metabase#48347)

author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-25T22:01:54Z
body: 👋 Deploying a preview environment for commit 0ceb0b551229294e1c278c7e5144f464c756255e.
✅ Preview:
    https://pr55627.coredev.metabase.com
🗒️ [login instructions](https://www.notion.so/metabase/Ephemeral-PR-Environments-c74a3710c87a460dbf4fc0007a001458)
<!-- Sticky Pull Request Comment -->
author_association: CONTRIBUTOR