Skip to content
Snippets Groups Projects
Unverified Commit b41a5e84 authored by Uladzimir Havenchyk's avatar Uladzimir Havenchyk Committed by GitHub
Browse files

Make dashboard filter auto-wire less presumptuous - add toast animation and hint messages (#44378)

parent 0e9076c0
No related branches found
No related tags found
No related merge requests found
Showing
with 558 additions and 77 deletions
......@@ -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", () => {
......
......@@ -25,4 +25,5 @@ export * from "./snippets";
export * from "./store";
export * from "./table";
export * from "./timeline";
export * from "./undo";
export * from "./user";
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,
});
// 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[];
......@@ -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" />}
......
@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;
}
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`
......
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();
});
});
......@@ -97,6 +97,7 @@ export function showAutoWireToast(
originalDashcardAttributes,
columnName: formatMappingOption(mappingOption),
hasMultipleTabs: tabs.length > 1,
parameterId: parameter_id,
}),
);
};
......
export const AUTO_WIRE_TOAST_TIMEOUT = 12000;
......@@ -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));
}
}
......
......@@ -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`
......
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,
......
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({
......
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;
}
......
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 = () => {
......
......@@ -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")}`,
},
};
},
......
......@@ -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,
......
<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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment