From b41a5e840f77e9bfc536db36cf40fe73d6bee7ff Mon Sep 17 00:00:00 2001
From: Uladzimir Havenchyk <125459446+uladzimirdev@users.noreply.github.com>
Date: Wed, 19 Jun 2024 16:11:19 +0300
Subject: [PATCH] Make dashboard filter auto-wire less presumptuous - add toast
 animation and hint messages (#44378)

---
 .../dashboard-filters-auto-wiring.cy.spec.js  |  17 +-
 .../src/metabase-types/api/mocks/index.ts     |   1 +
 frontend/src/metabase-types/api/mocks/undo.ts |  18 ++
 frontend/src/metabase-types/store/undo.ts     |  23 ++-
 .../src/metabase/containers/UndoListing.jsx   |  49 ++++-
 .../containers/UndoListing.module.css         |  20 ++
 .../containers/UndoListing.styled.tsx         |  11 +-
 .../containers/UndoListing.unit.spec.tsx      |  43 ++++
 .../actions/auto-wire-parameters/actions.ts   |   1 +
 .../actions/auto-wire-parameters/constants.ts |   1 +
 .../actions/auto-wire-parameters/toasts.ts    |  27 ++-
 .../DashCardCardParameterMapper.styled.tsx    |   1 +
 .../DashCardCardParameterMapper.tsx           | 184 ++++++++++++------
 .../DashCardCardParameterMapper.unit.spec.jsx |  94 ++++++++-
 frontend/src/metabase/redux/undo.js           |  87 ++++++++-
 frontend/src/metabase/redux/undo.unit.spec.ts |  47 +++++
 .../feedback/Progress/Progress.styled.tsx     |   4 +-
 .../ui/components/icons/Icon/icons/index.ts   |   6 +
 .../components/icons/Icon/icons/sparkles.svg  |   1 +
 19 files changed, 558 insertions(+), 77 deletions(-)
 create mode 100644 frontend/src/metabase-types/api/mocks/undo.ts
 create mode 100644 frontend/src/metabase/containers/UndoListing.module.css
 create mode 100644 frontend/src/metabase/containers/UndoListing.unit.spec.tsx
 create mode 100644 frontend/src/metabase/dashboard/actions/auto-wire-parameters/constants.ts
 create mode 100644 frontend/src/metabase/ui/components/icons/Icon/icons/sparkles.svg

diff --git a/e2e/test/scenarios/dashboard-filters/dashboard-filters-auto-wiring.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-auto-wiring.cy.spec.js
index 010f0bb9197..17a8154ded5 100644
--- a/e2e/test/scenarios/dashboard-filters/dashboard-filters-auto-wiring.cy.spec.js
+++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-auto-wiring.cy.spec.js
@@ -44,7 +44,7 @@ const cards = [
     row: 0,
     col: 5,
     size_x: 5,
-    size_y: 4,
+    size_y: 5,
   },
 ];
 
@@ -85,6 +85,21 @@ describe("dashboard filters auto-wiring", () => {
         "contain",
         "The filter was auto-connected to all questions containing “User.Name”.",
       );
+
+      cy.log("verify auto-connect info is shown");
+
+      getDashboardCard(1).within(() => {
+        cy.findByText("Auto-connected").should("be.visible");
+        cy.icon("sparkles").should("be.visible");
+      });
+
+      // do not wait for timeout, but close the toast
+      undoToast().icon("close").click();
+
+      getDashboardCard(1).within(() => {
+        cy.findByText("Auto-connected").should("not.exist");
+        cy.icon("sparkles").should("not.exist");
+      });
     });
 
     it("should not wire parameters to cards that already have a parameter, despite matching fields", () => {
diff --git a/frontend/src/metabase-types/api/mocks/index.ts b/frontend/src/metabase-types/api/mocks/index.ts
index a1b8d599f98..2d466b2c256 100644
--- a/frontend/src/metabase-types/api/mocks/index.ts
+++ b/frontend/src/metabase-types/api/mocks/index.ts
@@ -25,4 +25,5 @@ export * from "./snippets";
 export * from "./store";
 export * from "./table";
 export * from "./timeline";
+export * from "./undo";
 export * from "./user";
diff --git a/frontend/src/metabase-types/api/mocks/undo.ts b/frontend/src/metabase-types/api/mocks/undo.ts
new file mode 100644
index 00000000000..cafe2d404c9
--- /dev/null
+++ b/frontend/src/metabase-types/api/mocks/undo.ts
@@ -0,0 +1,18 @@
+import type { Undo } from "metabase-types/store/undo";
+
+export const createMockUndo = (opts?: Partial<Undo>): Undo => ({
+  message: "The filter was auto-connected to all questions.",
+  actionLabel: "Undo",
+  showProgress: true,
+  timeout: 12000,
+  type: "filterAutoConnectDone",
+  extraInfo: {},
+  id: 12,
+  _domId: 12,
+  icon: "check",
+  canDismiss: true,
+  timeoutId: 636,
+  startedAt: 1718628033795,
+  count: 1,
+  ...opts,
+});
diff --git a/frontend/src/metabase-types/store/undo.ts b/frontend/src/metabase-types/store/undo.ts
index 67e139159cf..75f4450e268 100644
--- a/frontend/src/metabase-types/store/undo.ts
+++ b/frontend/src/metabase-types/store/undo.ts
@@ -1,13 +1,28 @@
-// TODO: convert redux/undo and UndoListing.jsx to TS and update type
-export type UndoState = {
+import type { DashCardId, DashboardTabId } from "metabase-types/api";
+
+export interface Undo {
   id: string | number;
   type?: string;
   action?: () => void;
+  message?: string;
+  timeout?: number;
   actions?: (() => void)[];
-  icon?: string;
+  showProgress?: boolean;
+  icon?: string | null;
   toastColor?: string;
   actionLabel?: string;
   canDismiss?: boolean;
+  startedAt?: number;
+  pausedAt?: number;
   dismissIconColor?: string;
+  extraInfo?: { dashcardIds?: DashCardId[]; tabId?: DashboardTabId } & Record<
+    string,
+    unknown
+  >;
   _domId?: string | number;
-}[];
+  timeoutId?: number;
+  count?: number;
+}
+
+// TODO: convert redux/undo and UndoListing.jsx to TS and update type
+export type UndoState = Undo[];
diff --git a/frontend/src/metabase/containers/UndoListing.jsx b/frontend/src/metabase/containers/UndoListing.jsx
index 72d4a704a6c..78acca33901 100644
--- a/frontend/src/metabase/containers/UndoListing.jsx
+++ b/frontend/src/metabase/containers/UndoListing.jsx
@@ -7,9 +7,15 @@ import BodyComponent from "metabase/components/BodyComponent";
 import { Ellipsified } from "metabase/core/components/Ellipsified";
 import { capitalize, inflect } from "metabase/lib/formatting";
 import { useSelector, useDispatch } from "metabase/lib/redux";
-import { dismissUndo, performUndo } from "metabase/redux/undo";
-import { Transition } from "metabase/ui";
+import {
+  dismissUndo,
+  pauseUndo,
+  performUndo,
+  resumeUndo,
+} from "metabase/redux/undo";
+import { Progress, Transition } from "metabase/ui";
 
+import CS from "./UndoListing.module.css";
 import {
   CardContent,
   CardContentSide,
@@ -59,18 +65,39 @@ const slideIn = {
   transitionProperty: "transform, opacity",
 };
 
+const TOAST_TRANSITION_DURATION = 300;
+
 function UndoToast({ undo, onUndo, onDismiss }) {
+  const dispatch = useDispatch();
   const [mounted, setMounted] = useState(false);
+  const [paused, setPaused] = useState(false);
 
   useMount(() => {
     setMounted(true);
   });
 
+  const handleMouseEnter = () => {
+    if (!undo.showProgress) {
+      return;
+    }
+    setPaused(true);
+    dispatch(pauseUndo(undo));
+  };
+
+  const handleMouseLeave = () => {
+    if (!undo.showProgress) {
+      return;
+    }
+
+    setPaused(false);
+    dispatch(resumeUndo(undo));
+  };
+
   return (
     <Transition
       mounted={mounted}
       transition={slideIn}
-      duration={300}
+      duration={TOAST_TRANSITION_DURATION}
       timingFunction="ease"
     >
       {styles => (
@@ -79,8 +106,24 @@ function UndoToast({ undo, onUndo, onDismiss }) {
           data-testid="toast-undo"
           color={undo.toastColor}
           role="status"
+          noBorder={undo.showProgress}
           style={styles}
+          className={CS.toast}
+          onMouseEnter={handleMouseEnter}
+          onMouseLeave={handleMouseLeave}
         >
+          {undo.showProgress && (
+            <Progress
+              size="sm"
+              color={paused ? "bg-dark" : "brand"}
+              /* we intentionally break a11y - css animation is smoother */
+              value={100}
+              pos="absolute"
+              top={0}
+              left={0}
+              className={CS.progress}
+            />
+          )}
           <CardContent>
             <CardContentSide maw="75ch">
               {undo.icon && <CardIcon name={undo.icon} color="text-white" />}
diff --git a/frontend/src/metabase/containers/UndoListing.module.css b/frontend/src/metabase/containers/UndoListing.module.css
new file mode 100644
index 00000000000..1d6e9a827c4
--- /dev/null
+++ b/frontend/src/metabase/containers/UndoListing.module.css
@@ -0,0 +1,20 @@
+@keyframes animated-progress {
+  0% {
+    transform: scaleX(1);
+  }
+
+  100% {
+    transform: scaleX(0);
+  }
+}
+
+.progress {
+  /* it must be in sync with AUTO_WIRE_TOAST_TIMEOUT */
+  animation: animated-progress 12s linear;
+  transform-origin: left;
+  width: 100%;
+}
+
+.toast:hover .progress {
+  animation-play-state: paused;
+}
diff --git a/frontend/src/metabase/containers/UndoListing.styled.tsx b/frontend/src/metabase/containers/UndoListing.styled.tsx
index 19ad30d9e20..7c6cb953069 100644
--- a/frontend/src/metabase/containers/UndoListing.styled.tsx
+++ b/frontend/src/metabase/containers/UndoListing.styled.tsx
@@ -1,3 +1,4 @@
+import { css } from "@emotion/react";
 import styled from "@emotion/styled";
 
 import Card from "metabase/components/Card";
@@ -23,13 +24,21 @@ export const UndoList = styled.ul`
 export const ToastCard = styled(Card)<{
   translateY: number;
   color?: string;
+  noBorder?: boolean;
 }>`
   padding: 10px ${space(2)};
   margin-top: ${space(1)};
   min-width: 310px;
   max-width: calc(100vw - 2 * ${LIST_H_MARGINS});
+  position: relative;
   transform: ${props => `translateY(${props.translateY}px)`};
-  ${props => (props.color ? `background-color: ${color(props.color)}` : "")}
+  ${props => (props.color ? `background-color: ${color(props.color)}` : "")};
+  ${({ noBorder }) =>
+    noBorder &&
+    css`
+      border: none;
+      overflow-x: hidden;
+    `};
 `;
 
 export const CardContent = styled.div`
diff --git a/frontend/src/metabase/containers/UndoListing.unit.spec.tsx b/frontend/src/metabase/containers/UndoListing.unit.spec.tsx
new file mode 100644
index 00000000000..08ab7535b4f
--- /dev/null
+++ b/frontend/src/metabase/containers/UndoListing.unit.spec.tsx
@@ -0,0 +1,43 @@
+import { renderWithProviders, screen } from "__support__/ui";
+import type { UndoState } from "metabase-types/store/undo";
+
+import { UndoListing } from "./UndoListing";
+
+const AUTO_CONNECT_UNDO: UndoState[number] = {
+  icon: null,
+  message:
+    "Auto-connect this filter to all questions containing “Product.Title”, in the current tab?",
+  actionLabel: "Auto-connect",
+  timeout: 12000,
+  id: 0,
+  _domId: 1,
+  canDismiss: true,
+};
+
+describe("UndoListing", () => {
+  it("renders list of Undo toasts", () => {
+    renderWithProviders(<UndoListing />, {
+      storeInitialState: {
+        undo: [AUTO_CONNECT_UNDO],
+      },
+    });
+
+    expect(screen.getByTestId("undo-list")).toBeInTheDocument();
+    expect(screen.getByTestId("toast-undo")).toBeInTheDocument();
+  });
+
+  it("should render progress bar", () => {
+    renderWithProviders(<UndoListing />, {
+      storeInitialState: {
+        undo: [
+          {
+            ...AUTO_CONNECT_UNDO,
+            showProgress: true,
+          },
+        ],
+      },
+    });
+
+    expect(screen.getByRole("progressbar")).toBeInTheDocument();
+  });
+});
diff --git a/frontend/src/metabase/dashboard/actions/auto-wire-parameters/actions.ts b/frontend/src/metabase/dashboard/actions/auto-wire-parameters/actions.ts
index 7132347fde8..147f8aadbb0 100644
--- a/frontend/src/metabase/dashboard/actions/auto-wire-parameters/actions.ts
+++ b/frontend/src/metabase/dashboard/actions/auto-wire-parameters/actions.ts
@@ -97,6 +97,7 @@ export function showAutoWireToast(
         originalDashcardAttributes,
         columnName: formatMappingOption(mappingOption),
         hasMultipleTabs: tabs.length > 1,
+        parameterId: parameter_id,
       }),
     );
   };
diff --git a/frontend/src/metabase/dashboard/actions/auto-wire-parameters/constants.ts b/frontend/src/metabase/dashboard/actions/auto-wire-parameters/constants.ts
new file mode 100644
index 00000000000..176927ff827
--- /dev/null
+++ b/frontend/src/metabase/dashboard/actions/auto-wire-parameters/constants.ts
@@ -0,0 +1 @@
+export const AUTO_WIRE_TOAST_TIMEOUT = 12000;
diff --git a/frontend/src/metabase/dashboard/actions/auto-wire-parameters/toasts.ts b/frontend/src/metabase/dashboard/actions/auto-wire-parameters/toasts.ts
index bbadcf1ae5f..1a50e362146 100644
--- a/frontend/src/metabase/dashboard/actions/auto-wire-parameters/toasts.ts
+++ b/frontend/src/metabase/dashboard/actions/auto-wire-parameters/toasts.ts
@@ -12,11 +12,13 @@ import type {
   DashCardId,
   DashboardParameterMapping,
   Parameter,
+  ParameterId,
 } from "metabase-types/api";
 import type { Dispatch, GetState } from "metabase-types/store";
 
+import { AUTO_WIRE_TOAST_TIMEOUT } from "./constants";
+
 export const AUTO_WIRE_TOAST_ID = _.uniqueId();
-const AUTO_WIRE_UNDO_TOAST_ID = _.uniqueId();
 
 export const showAutoWireParametersToast =
   ({
@@ -24,11 +26,13 @@ export const showAutoWireParametersToast =
     originalDashcardAttributes,
     columnName,
     hasMultipleTabs,
+    parameterId,
   }: {
     dashcardAttributes: SetMultipleDashCardAttributesOpts;
     originalDashcardAttributes: SetMultipleDashCardAttributesOpts;
     columnName: string;
     hasMultipleTabs: boolean;
+    parameterId: ParameterId;
   }) =>
   (dispatch: Dispatch) => {
     const message = hasMultipleTabs
@@ -37,11 +41,11 @@ export const showAutoWireParametersToast =
 
     dispatch(
       addUndo({
-        id: AUTO_WIRE_TOAST_ID,
         icon: null,
         message,
         actionLabel: t`Auto-connect`,
-        timeout: 12000,
+        showProgress: true,
+        timeout: AUTO_WIRE_TOAST_TIMEOUT,
         action: () => {
           connectAll();
           showUndoToast();
@@ -68,11 +72,15 @@ export const showAutoWireParametersToast =
     function showUndoToast() {
       dispatch(
         addUndo({
-          id: AUTO_WIRE_UNDO_TOAST_ID,
           message: t`The filter was auto-connected to all questions containing “${columnName}”.`,
           actionLabel: t`Undo`,
+          showProgress: true,
           timeout: 12000,
-          type: "filterAutoConnect",
+          type: "filterAutoConnectDone",
+          extraInfo: {
+            dashcardIds: dashcardAttributes.map(({ id }) => id),
+            parameterId,
+          },
           action: revertConnectAll,
         }),
       );
@@ -106,7 +114,8 @@ export const showAddedCardAutoWireParametersToast =
         type: "filterAutoConnect",
         message,
         actionLabel: t`Auto-connect`,
-        timeout: 12000,
+        showProgress: true,
+        timeout: AUTO_WIRE_TOAST_TIMEOUT,
         action: () => {
           closeAutoWireParameterToast(toastId);
           autoWireParametersToNewCard();
@@ -145,7 +154,8 @@ export const showAddedCardAutoWireParametersToast =
       dispatch(
         addUndo({
           message,
-          timeout: 12000,
+          showProgress: true,
+          timeout: AUTO_WIRE_TOAST_TIMEOUT,
           type: "filterAutoConnect",
           action: revertAutoWireParametersToNewCard,
         }),
@@ -159,12 +169,13 @@ export const closeAutoWireParameterToast =
     dispatch(dismissUndo(toastId, false));
   };
 
+const autoWireToastTypes = ["filterAutoConnect", "filterAutoConnectDone"];
 export const closeAddCardAutoWireToasts =
   () => (dispatch: Dispatch, getState: GetState) => {
     const undos = getState().undo;
 
     for (const undo of undos) {
-      if (undo.type === "filterAutoConnect") {
+      if (undo.type && autoWireToastTypes.includes(undo.type)) {
         dispatch(dismissUndo(undo.id, false));
       }
     }
diff --git a/frontend/src/metabase/dashboard/components/DashCard/DashCardParameterMapper/DashCardCardParameterMapper.styled.tsx b/frontend/src/metabase/dashboard/components/DashCard/DashCardParameterMapper/DashCardCardParameterMapper.styled.tsx
index 6b4a00fe23e..6fca7276eca 100644
--- a/frontend/src/metabase/dashboard/components/DashCard/DashCardParameterMapper/DashCardCardParameterMapper.styled.tsx
+++ b/frontend/src/metabase/dashboard/components/DashCard/DashCardParameterMapper/DashCardCardParameterMapper.styled.tsx
@@ -13,6 +13,7 @@ export const Container = styled.div<{ isSmall: boolean }>`
   align-items: center;
   width: 100%;
   padding: 0.25rem;
+  position: relative;
 `;
 
 export const TextCardDefault = styled.div`
diff --git a/frontend/src/metabase/dashboard/components/DashCard/DashCardParameterMapper/DashCardCardParameterMapper.tsx b/frontend/src/metabase/dashboard/components/DashCard/DashCardParameterMapper/DashCardCardParameterMapper.tsx
index ad1de31ec43..2d289a6cebf 100644
--- a/frontend/src/metabase/dashboard/components/DashCard/DashCardParameterMapper/DashCardCardParameterMapper.tsx
+++ b/frontend/src/metabase/dashboard/components/DashCard/DashCardParameterMapper/DashCardCardParameterMapper.tsx
@@ -1,13 +1,13 @@
 import { useState, useMemo, useCallback, useEffect } from "react";
 import { connect } from "react-redux";
-import { usePrevious } from "react-use";
+import { useMount, usePrevious } from "react-use";
 import { t } from "ttag";
 import _ from "underscore";
 
 import { isActionDashCard } from "metabase/actions/utils";
 import TippyPopover from "metabase/components/Popover/TippyPopover";
 import { Ellipsified } from "metabase/core/components/Ellipsified";
-import Tooltip from "metabase/core/components/Tooltip";
+import DeprecatedTooltip from "metabase/core/components/Tooltip";
 import CS from "metabase/css/core/index.css";
 import {
   isNativeDashCard,
@@ -19,8 +19,9 @@ import {
 import { useDispatch } from "metabase/lib/redux";
 import ParameterTargetList from "metabase/parameters/components/ParameterTargetList";
 import type { ParameterMappingOption } from "metabase/parameters/utils/mapping-options";
+import { getIsRecentlyAutoConnectedDashcard } from "metabase/redux/undo";
 import { getMetadata } from "metabase/selectors/metadata";
-import { Icon } from "metabase/ui";
+import { Flex, Icon, Text, Transition, Tooltip, Box } from "metabase/ui";
 import {
   MOBILE_HEIGHT_BY_DISPLAY_TYPE,
   MOBILE_DEFAULT_CARD_HEIGHT,
@@ -83,13 +84,23 @@ function formatSelected({
 const mapStateToProps = (
   state: State,
   props: DashcardCardParameterMapperProps,
-) => ({
-  editingParameter: getEditingParameter(state),
-  target: getParameterTarget(state, props),
-  metadata: getMetadata(state),
-  question: getQuestionByCard(state, props),
-  mappingOptions: getDashcardParameterMappingOptions(state, props),
-});
+) => {
+  const editingParameter = getEditingParameter(state);
+
+  return {
+    editingParameter,
+    target: getParameterTarget(state, props),
+    metadata: getMetadata(state),
+    question: getQuestionByCard(state, props),
+    mappingOptions: getDashcardParameterMappingOptions(state, props),
+    isRecentlyAutoConnected: getIsRecentlyAutoConnectedDashcard(
+      state,
+      // @ts-expect-error redux/undo is not in TS
+      props,
+      editingParameter?.id,
+    ),
+  };
+};
 
 const mapDispatchToProps = {
   setParameterMapping,
@@ -110,6 +121,7 @@ interface DashcardCardParameterMapperProps {
   // virtual cards will not have question
   question?: Question;
   mappingOptions: ParameterMappingOption[];
+  isRecentlyAutoConnected: boolean;
 }
 
 export function DashCardCardParameterMapper({
@@ -121,6 +133,7 @@ export function DashCardCardParameterMapper({
   isMobile,
   question,
   mappingOptions,
+  isRecentlyAutoConnected,
 }: DashcardCardParameterMapperProps) {
   const [isDropdownVisible, setIsDropdownVisible] = useState(false);
   const prevParameter = usePrevious(editingParameter);
@@ -259,12 +272,12 @@ export function DashCardCardParameterMapper({
       isVirtual,
     ]);
 
-  const headerContent = useMemo(() => {
-    const layoutHeight = isMobile
-      ? MOBILE_HEIGHT_BY_DISPLAY_TYPE[dashcard.card.display] ||
-        MOBILE_DEFAULT_CARD_HEIGHT
-      : dashcard.size_y;
+  const layoutHeight = isMobile
+    ? MOBILE_HEIGHT_BY_DISPLAY_TYPE[dashcard.card.display] ||
+      MOBILE_DEFAULT_CARD_HEIGHT
+    : dashcard.size_y;
 
+  const headerContent = useMemo(() => {
     if (layoutHeight > 2) {
       if (isTemporalUnit) {
         return t`Connect to`;
@@ -275,7 +288,7 @@ export function DashCardCardParameterMapper({
       return t`Variable to map to`;
     }
     return null;
-  }, [dashcard, isVirtual, isNative, isDisabled, isMobile, isTemporalUnit]);
+  }, [layoutHeight, isTemporalUnit, isVirtual, isNative, isDisabled]);
 
   const mappingInfoText =
     (virtualCardType &&
@@ -288,6 +301,9 @@ export function DashCardCardParameterMapper({
       }[virtualCardType]) ??
     "";
 
+  const shouldShowAutoConnectHint =
+    isRecentlyAutoConnected && !!selectedMappingOption;
+
   return (
     <Container isSmall={!isMobile && dashcard.size_y < 2}>
       {hasSeries && <CardLabel>{card.name}</CardLabel>}
@@ -316,48 +332,84 @@ export function DashCardCardParameterMapper({
               <Ellipsified>{headerContent}</Ellipsified>
             </Header>
           )}
-          <Tooltip tooltip={buttonTooltip}>
-            <TippyPopover
-              visible={isDropdownVisible && !isDisabled && hasPermissionsToMap}
-              onClickOutside={() => setIsDropdownVisible(false)}
-              placement="bottom-start"
-              content={
-                <ParameterTargetList
-                  onChange={(target: ParameterTarget) => {
-                    handleChangeTarget(target);
-                    setIsDropdownVisible(false);
-                  }}
-                  target={target}
-                  mappingOptions={mappingOptions}
-                />
-              }
-            >
-              <TargetButton
-                variant={buttonVariant}
-                aria-label={buttonTooltip ?? undefined}
-                aria-haspopup="listbox"
-                aria-expanded={isDropdownVisible}
-                aria-disabled={isDisabled || !hasPermissionsToMap}
-                onClick={() => {
-                  setIsDropdownVisible(true);
-                }}
-                onKeyDown={e => {
-                  if (e.key === "Enter") {
-                    setIsDropdownVisible(true);
-                  }
-                }}
+          <Flex align="center" justify="center" gap="xs" pos="relative">
+            <DeprecatedTooltip tooltip={buttonTooltip}>
+              <TippyPopover
+                visible={
+                  isDropdownVisible && !isDisabled && hasPermissionsToMap
+                }
+                onClickOutside={() => setIsDropdownVisible(false)}
+                placement="bottom-start"
+                content={
+                  <ParameterTargetList
+                    onChange={(target: ParameterTarget) => {
+                      handleChangeTarget(target);
+                      setIsDropdownVisible(false);
+                    }}
+                    target={target}
+                    mappingOptions={mappingOptions}
+                  />
+                }
               >
-                {buttonText && (
-                  <TargetButtonText>
-                    <Ellipsified>{buttonText}</Ellipsified>
-                  </TargetButtonText>
-                )}
-                {buttonIcon}
-              </TargetButton>
-            </TippyPopover>
-          </Tooltip>
+                <TargetButton
+                  variant={buttonVariant}
+                  aria-label={buttonTooltip ?? undefined}
+                  aria-haspopup="listbox"
+                  aria-expanded={isDropdownVisible}
+                  aria-disabled={isDisabled || !hasPermissionsToMap}
+                  onClick={() => {
+                    setIsDropdownVisible(true);
+                  }}
+                  onKeyDown={e => {
+                    if (e.key === "Enter") {
+                      setIsDropdownVisible(true);
+                    }
+                  }}
+                >
+                  {buttonText && (
+                    <TargetButtonText>
+                      <Ellipsified>{buttonText}</Ellipsified>
+                    </TargetButtonText>
+                  )}
+                  {buttonIcon}
+                </TargetButton>
+              </TippyPopover>
+            </DeprecatedTooltip>
+            {shouldShowAutoConnectHint &&
+              layoutHeight <= 3 &&
+              dashcard.size_x > 4 && <AutoConnectedAnimatedIcon />}
+          </Flex>
         </>
       )}
+      <Transition
+        mounted={shouldShowAutoConnectHint && layoutHeight > 3}
+        transition="fade"
+        duration={400}
+        exitDuration={0}
+      >
+        {styles => {
+          /* bottom prop is negative as we wanted to keep layout not shifted on hint */
+          return (
+            <Flex
+              mt="sm"
+              align="center"
+              pos="absolute"
+              bottom={-20}
+              style={styles}
+            >
+              <Icon name="sparkles" size="16" />
+              <Text
+                component="span"
+                ml="xs"
+                weight="bold"
+                fz="sm"
+                lh={1}
+                color="text-light"
+              >{t`Auto-connected`}</Text>
+            </Flex>
+          );
+        }}
+      </Transition>
       {target && isParameterVariableTarget(target) && (
         <Warning>
           {editingParameter && isDateParameter(editingParameter) // Date parameters types that can be wired to variables can only take a single value anyway, so don't explain it in the warning.
@@ -369,6 +421,28 @@ export function DashCardCardParameterMapper({
   );
 }
 
+function AutoConnectedAnimatedIcon() {
+  const [mounted, setMounted] = useState(false);
+
+  useMount(() => {
+    setMounted(true);
+  });
+
+  return (
+    <Transition transition="fade" mounted={mounted} exitDuration={0}>
+      {styles => {
+        return (
+          <Box component="span" style={styles} pos="absolute" right={-20}>
+            <Tooltip label={t`Auto-connected`}>
+              <Icon name="sparkles" />
+            </Tooltip>
+          </Box>
+        );
+      }}
+    </Transition>
+  );
+}
+
 export const DashCardCardParameterMapperConnected = connect(
   mapStateToProps,
   mapDispatchToProps,
diff --git a/frontend/src/metabase/dashboard/components/DashCard/DashCardParameterMapper/DashCardCardParameterMapper.unit.spec.jsx b/frontend/src/metabase/dashboard/components/DashCard/DashCardParameterMapper/DashCardCardParameterMapper.unit.spec.jsx
index 942245f7863..2aafb02b570 100644
--- a/frontend/src/metabase/dashboard/components/DashCard/DashCardParameterMapper/DashCardCardParameterMapper.unit.spec.jsx
+++ b/frontend/src/metabase/dashboard/components/DashCard/DashCardParameterMapper/DashCardCardParameterMapper.unit.spec.jsx
@@ -1,5 +1,10 @@
 import { createMockEntitiesState } from "__support__/store";
-import { getIcon, renderWithProviders, screen } from "__support__/ui";
+import {
+  getIcon,
+  queryIcon,
+  renderWithProviders,
+  screen,
+} from "__support__/ui";
 import { getMetadata } from "metabase/selectors/metadata";
 import Question from "metabase-lib/v1/Question";
 import {
@@ -42,6 +47,7 @@ const setup = options => {
       dashcard={createMockDashboardCard({ card })}
       question={new Question(card, metadata)}
       editingParameter={createMockParameter()}
+      isRecentlyAutoConnected={options.isRecentlyAutoConnected ?? false}
       mappingOptions={[]}
       metadata={metadata}
       setParameterMapping={jest.fn()}
@@ -51,7 +57,7 @@ const setup = options => {
   );
 };
 
-describe("DashCardParameterMapper", () => {
+describe("DashCardCardParameterMapper", () => {
   it("should render an unauthorized state for a card with no dataset query", () => {
     const card = createMockCard({
       dataset_query: createMockStructuredDatasetQuery({ query: {} }),
@@ -161,6 +167,90 @@ describe("DashCardParameterMapper", () => {
     expect(screen.getByText("Section.Name")).toBeInTheDocument();
   });
 
+  describe("Auto-connected hint", () => {
+    it("should render 'Auto-connected' message on auto-wire", () => {
+      const card = createMockCard();
+      const dashcard = createMockDashboardCard({
+        card,
+        size_y: 4,
+      });
+
+      setup({
+        dashcard,
+        card,
+        mappingOptions: [
+          {
+            target: ["dimension", ["field", 1]],
+            sectionName: "Section",
+            name: "Name",
+          },
+        ],
+        target: ["dimension", ["field", 1]],
+        isRecentlyAutoConnected: true,
+      });
+
+      expect(screen.getByText("Auto-connected")).toBeInTheDocument();
+      expect(getIcon("sparkles")).toBeInTheDocument();
+    });
+
+    it("should not render 'Auto-connected' message on auto-wire when no dashcards mapped", () => {
+      const card = createMockCard();
+      const dashcard = createMockDashboardCard({ card });
+
+      setup({
+        dashcard,
+        card,
+        isRecentlyAutoConnected: true,
+      });
+
+      expect(screen.queryByText("Auto-connected")).not.toBeInTheDocument();
+      expect(queryIcon("sparkles")).not.toBeInTheDocument();
+    });
+
+    it("should render only an icon when a dashcard is short", () => {
+      const card = createMockCard();
+      const dashcard = createMockDashboardCard({ card, size_y: 3, size_x: 5 });
+
+      setup({
+        dashcard,
+        card,
+        mappingOptions: [
+          {
+            target: ["dimension", ["field", 1]],
+            sectionName: "Section",
+            name: "Name",
+          },
+        ],
+        target: ["dimension", ["field", 1]],
+        isRecentlyAutoConnected: true,
+      });
+
+      expect(screen.queryByText("Auto-connected")).not.toBeInTheDocument();
+      expect(getIcon("sparkles")).toBeInTheDocument();
+    });
+    it("should not render an icon when a dashcard is narrow", () => {
+      const card = createMockCard();
+      const dashcard = createMockDashboardCard({ card, size_y: 3, size_x: 3 });
+
+      setup({
+        dashcard,
+        card,
+        mappingOptions: [
+          {
+            target: ["dimension", ["field", 1]],
+            sectionName: "Section",
+            name: "Name",
+          },
+        ],
+        target: ["dimension", ["field", 1]],
+        isRecentlyAutoConnected: true,
+      });
+
+      expect(screen.queryByText("Auto-connected")).not.toBeInTheDocument();
+      expect(queryIcon("sparkles")).not.toBeInTheDocument();
+    });
+  });
+
   it("should render an error state when a field is not present in the list of options", () => {
     const card = createMockCard({
       dataset_query: createMockStructuredDatasetQuery({
diff --git a/frontend/src/metabase/redux/undo.js b/frontend/src/metabase/redux/undo.js
index 98d36cfc425..6a4f5277feb 100644
--- a/frontend/src/metabase/redux/undo.js
+++ b/frontend/src/metabase/redux/undo.js
@@ -1,3 +1,4 @@
+import { createSelector } from "@reduxjs/toolkit";
 import _ from "underscore";
 
 import * as MetabaseAnalytics from "metabase/lib/analytics";
@@ -22,9 +23,41 @@ export const addUndo = createThunkAction(ADD_UNDO, undo => {
     if (timeout) {
       timeoutId = setTimeout(() => dispatch(dismissUndo(id, false)), timeout);
     }
-    return { ...undo, id, _domId: id, icon, canDismiss, timeoutId };
+    return {
+      ...undo,
+      id,
+      _domId: id,
+      icon,
+      canDismiss,
+      timeoutId,
+      startedAt: Date.now(),
+    };
+  };
+});
+
+const PAUSE_UNDO = "metabase/questions/PAUSE_UNDO";
+export const pauseUndo = createAction(PAUSE_UNDO, undo => {
+  clearTimeout(undo.timeoutId);
+
+  return { ...undo, pausedAt: Date.now(), timeoutId: null };
+});
+
+const RESUME_UNDO = "metabase/questions/RESUME_UNDO";
+export const resumeUndo = createThunkAction(RESUME_UNDO, undo => {
+  const restTime = undo.timeout - (undo.pausedAt - undo.startedAt);
+
+  return dispatch => {
+    return {
+      ...undo,
+      timeoutId: setTimeout(
+        () => dispatch(dismissUndo(undo.id, false)),
+        restTime,
+      ),
+      timeout: restTime,
+    };
   };
 });
+
 /**
  *
  * @param {import("metabase-types/store").State} state
@@ -35,6 +68,31 @@ function getUndo(state, undoId) {
   return _.findWhere(state.undo, { id: undoId });
 }
 
+const getAutoConnectedUndos = createSelector([state => state.undo], undos => {
+  return undos.filter(undo => undo.type === "filterAutoConnectDone");
+});
+
+export const getIsRecentlyAutoConnectedDashcard = createSelector(
+  [
+    getAutoConnectedUndos,
+    (_state, props) => props.dashcard.id,
+    (_state, _props, parameterId) => parameterId,
+  ],
+  (undos, dashcardId, parameterId) => {
+    const isRecentlyAutoConnected = undos.some(undo => {
+      const isDashcardAutoConnected =
+        undo.extraInfo?.dashcardIds?.includes(dashcardId);
+      const isSameParameterSelected = undo.extraInfo?.parameterId
+        ? undo.extraInfo.parameterId === parameterId
+        : true;
+
+      return isDashcardAutoConnected && isSameParameterSelected;
+    });
+
+    return isRecentlyAutoConnected;
+  },
+);
+
 export const dismissUndo = createThunkAction(
   DISMISS_UNDO,
   (undoId, track = true) => {
@@ -112,7 +170,34 @@ export default function (state = [], { type, payload, error }) {
       clearTimeoutForUndo(undo);
     }
     return [];
+  } else if (type === PAUSE_UNDO) {
+    return state.map(undo => {
+      if (undo.id === payload.id) {
+        return {
+          ...undo,
+          pausedAt: Date.now(),
+          timeoutId: null,
+        };
+      }
+
+      return undo;
+    });
+  } else if (type === RESUME_UNDO) {
+    return state.map(undo => {
+      if (undo.id === payload.id) {
+        return {
+          ...undo,
+          timeoutId: payload.timeoutId,
+          pausedAt: null,
+          startedAt: Date.now(),
+          timeout: payload.timeout,
+        };
+      }
+
+      return undo;
+    });
   }
+
   return state;
 }
 
diff --git a/frontend/src/metabase/redux/undo.unit.spec.ts b/frontend/src/metabase/redux/undo.unit.spec.ts
index bdde5299de6..c5f41f30521 100644
--- a/frontend/src/metabase/redux/undo.unit.spec.ts
+++ b/frontend/src/metabase/redux/undo.unit.spec.ts
@@ -1,4 +1,5 @@
 import { configureStore } from "@reduxjs/toolkit";
+import { act } from "@testing-library/react";
 
 import type { Dispatch } from "metabase-types/store";
 
@@ -6,7 +7,9 @@ import undoReducer, {
   addUndo,
   dismissAllUndo,
   dismissUndo,
+  pauseUndo,
   performUndo,
+  resumeUndo,
 } from "./undo";
 
 const MOCK_ID = "123";
@@ -76,6 +79,50 @@ describe("metabase/redux/undo", () => {
       expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
     });
   });
+
+  it("should handle pause and resume", async () => {
+    const store = createMockStore();
+    const timeout = 1000;
+    const timeShiftBeforePause = timeout - 150;
+    const timeShiftDuringPause = timeout + 100;
+    const timeShiftResumed1 = 100;
+    const timeShiftResumed2 = 100;
+
+    store.dispatch(addUndo({ id: MOCK_ID, timeout }));
+
+    // await act is required to simulate store update on the next tick
+    await act(async () => {
+      jest.advanceTimersByTime(timeShiftBeforePause);
+    });
+
+    // pause undo (e.g. when mouse is over toast)
+    // @ts-expect-error undo is still not converted to TS
+    store.dispatch(pauseUndo(store.getState().undo[0]));
+
+    await act(async () => {
+      jest.advanceTimersByTime(timeShiftDuringPause);
+    });
+
+    // undo is there
+    expect(store.getState().undo.length).toBe(1);
+
+    // resume undo (e.g. when mouse left toast)
+    store.dispatch(resumeUndo(store.getState().undo[0]));
+
+    await act(async () => {
+      jest.advanceTimersByTime(timeShiftResumed1);
+    });
+
+    // undo is still there, timeout didn't pass
+    expect(store.getState().undo.length).toBe(1);
+
+    await act(async () => {
+      jest.advanceTimersByTime(timeShiftResumed2);
+    });
+
+    // undo is dismissed, timeout passed
+    expect(store.getState().undo.length).toBe(0);
+  });
 });
 
 const createMockStore = () => {
diff --git a/frontend/src/metabase/ui/components/feedback/Progress/Progress.styled.tsx b/frontend/src/metabase/ui/components/feedback/Progress/Progress.styled.tsx
index 50da614065c..78aad066518 100644
--- a/frontend/src/metabase/ui/components/feedback/Progress/Progress.styled.tsx
+++ b/frontend/src/metabase/ui/components/feedback/Progress/Progress.styled.tsx
@@ -2,10 +2,10 @@ import type { MantineThemeOverride } from "@mantine/core";
 
 export const getProgressOverrides = (): MantineThemeOverride["components"] => ({
   Progress: {
-    styles: theme => {
+    styles: (theme, params) => {
       return {
         root: {
-          border: `1px solid ${theme.fn.themeColor("brand")}`,
+          border: `1px solid ${params.color ?? theme.fn.themeColor("brand")}`,
         },
       };
     },
diff --git a/frontend/src/metabase/ui/components/icons/Icon/icons/index.ts b/frontend/src/metabase/ui/components/icons/Icon/icons/index.ts
index 497205ad4ca..3f5331b34b4 100644
--- a/frontend/src/metabase/ui/components/icons/Icon/icons/index.ts
+++ b/frontend/src/metabase/ui/components/icons/Icon/icons/index.ts
@@ -323,6 +323,8 @@ import sort_component from "./sort.svg?component";
 import sort_source from "./sort.svg?source";
 import sort_arrows_component from "./sort_arrows.svg?component";
 import sort_arrows_source from "./sort_arrows.svg?source";
+import sparkles_component from "./sparkles.svg?component";
+import sparkles_source from "./sparkles.svg?source";
 import split_component from "./split.svg?component";
 import split_source from "./split.svg?source";
 import sql_component from "./sql.svg?component";
@@ -1057,6 +1059,10 @@ export const Icons = {
     component: snippet_component,
     source: snippet_source,
   },
+  sparkles: {
+    component: sparkles_component,
+    source: sparkles_source,
+  },
   star_filled: {
     component: star_filled_component,
     source: star_filled_source,
diff --git a/frontend/src/metabase/ui/components/icons/Icon/icons/sparkles.svg b/frontend/src/metabase/ui/components/icons/Icon/icons/sparkles.svg
new file mode 100644
index 00000000000..7e97b105ad0
--- /dev/null
+++ b/frontend/src/metabase/ui/components/icons/Icon/icons/sparkles.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none"><path fill="#949AAB" d="M4.398 9.807a1.04 1.04 0 0 0 1.204-.003c.178-.13.313-.31.387-.518l.447-1.373C6.551 7.57 6.744 7.256 7 7c.257-.256.57-.45.913-.565l1.391-.45a1.05 1.05 0 0 0 .69-1.077 1.04 1.04 0 0 0-.734-.904l-1.375-.447a2.34 2.34 0 0 1-1.48-1.477L5.953.691A1.043 1.043 0 0 0 3.98.708l-.457 1.4a2.32 2.32 0 0 1-1.44 1.45l-1.391.447a1.06 1.06 0 0 0-.644.67A1.05 1.05 0 0 0 .709 5.98l1.374.445a2.33 2.33 0 0 1 1.481 1.488l.452 1.391c.072.204.206.38.382.504Zm6.137 4.042a.8.8 0 0 0 .926.002.8.8 0 0 0 .3-.4l.248-.762a1.07 1.07 0 0 1 .68-.68l.772-.252a.79.79 0 0 0 .531-.64.796.796 0 0 0-.554-.88l-.764-.25a1.08 1.08 0 0 1-.68-.678l-.252-.773a.8.8 0 0 0-.293-.39.796.796 0 0 0-1.03.085.801.801 0 0 0-.195.315l-.247.762a1.07 1.07 0 0 1-.665.68l-.773.251a.8.8 0 0 0 .008 1.518l.763.247c.159.054.304.143.422.261.119.12.207.263.258.422l.253.774a.8.8 0 0 0 .292.388Z"/></svg>
\ No newline at end of file
-- 
GitLab