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