fix(ui): use custom component for tooltips

This commit is contained in:
Łukasz Mierzwa
2020-05-15 20:32:40 +01:00
committed by Łukasz Mierzwa
parent a65f9dad8e
commit 40edb80314
17 changed files with 257 additions and 123 deletions

2
ui/package-lock.json generated
View File

@@ -19169,6 +19169,7 @@
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/react-popper-tooltip/-/react-popper-tooltip-2.11.1.tgz",
"integrity": "sha512-04A2f24GhyyMicKvg/koIOQ5BzlrRbKiAgP6L+Pdj1MVX3yJ1NeZ8+EidndQsbejFT55oW1b++wg2Z8KlAyhfQ==",
"dev": true,
"requires": {
"@babel/runtime": "^7.9.2",
"react-popper": "^1.3.7"
@@ -19178,6 +19179,7 @@
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.7.tgz",
"integrity": "sha512-nmqYTx7QVjCm3WUZLeuOomna138R1luC4EqkW3hxJUrAe+3eNz3oFCLYdnPwILfn0mX1Ew2c3wctrjlUMYYUww==",
"dev": true,
"requires": {
"@babel/runtime": "^7.1.2",
"create-react-context": "^0.3.0",

View File

@@ -51,7 +51,6 @@
"react-media": "1.10.0",
"react-moment": "0.9.7",
"react-popper": "2.2.3",
"react-popper-tooltip": "2.11.1",
"react-resize-detector": "4.2.3",
"react-reveal": "1.2.2",
"react-scripts": "3.4.1",

View File

@@ -1,4 +1,5 @@
import React from "react";
import { act } from "react-dom/test-utils";
import { mount } from "enzyme";
@@ -270,7 +271,7 @@ describe("<Alert />", () => {
expect(tree.find("time").text()).toBe("a few seconds ago");
advanceTo(new Date(Date.UTC(2018, 7, 14, 17, 37, 41)));
jest.advanceTimersByTime(61 * 1000);
act(() => jest.advanceTimersByTime(61 * 1000));
expect(tree.find("time").text()).toBe("a minute ago");
advanceTo(new Date(Date.UTC(2018, 7, 14, 18, 36, 41)));

View File

@@ -1,4 +1,5 @@
import React from "react";
import { act } from "react-dom/test-utils";
import { shallow, mount } from "enzyme";
@@ -371,7 +372,7 @@ describe("<Grid />", () => {
MountedGrid();
// skip a minute to trigger FontFaceObserver timeout handler
advanceBy(60 * 1000);
jest.runOnlyPendingTimers();
act(() => jest.runOnlyPendingTimers());
});
it("doesn't crash on unmount", () => {

View File

@@ -1,4 +1,5 @@
import React from "react";
import { act } from "react-dom/test-utils";
import { mount } from "enzyme";
@@ -77,12 +78,12 @@ describe("<MainModal />", () => {
const toggle = tree.find(".nav-link");
toggle.simulate("click");
jest.runOnlyPendingTimers();
act(() => jest.runOnlyPendingTimers());
tree.update();
expect(tree.find("MainModalContent")).toHaveLength(1);
toggle.simulate("click");
jest.runOnlyPendingTimers();
act(() => jest.runOnlyPendingTimers());
tree.update();
expect(tree.find("MainModalContent")).toHaveLength(0);
});
@@ -95,7 +96,7 @@ describe("<MainModal />", () => {
expect(tree.find("MainModalContent")).toHaveLength(1);
tree.find("button.close").simulate("click");
jest.runOnlyPendingTimers();
act(() => jest.runOnlyPendingTimers());
tree.update();
expect(tree.find("MainModalContent")).toHaveLength(0);
});

View File

@@ -198,4 +198,4 @@ const NavBar = observer(
);
NavBar.contextType = ThemeContext;
export { NavBar };
export { NavBar, MobileIdleTimeout, DesktopIdleTimeout };

View File

@@ -1,7 +1,7 @@
import React from "react";
import { act } from "react-dom/test-utils";
import { shallow, mount } from "enzyme";
import { mount } from "enzyme";
import moment from "moment";
@@ -10,13 +10,17 @@ import { AlertStore, NewUnappliedFilter } from "Stores/AlertStore";
import { Settings } from "Stores/Settings";
import { SilenceFormStore } from "Stores/SilenceFormStore";
import { ThemeContext } from "Components/Theme";
import { NavBar } from ".";
import { NavBar, MobileIdleTimeout, DesktopIdleTimeout } from ".";
let alertStore;
let settingsStore;
let silenceFormStore;
beforeAll(() => {
jest.useFakeTimers();
jest.spyOn(React, "useContext").mockImplementation(() => MockThemeContext);
});
beforeEach(() => {
alertStore = new AlertStore([]);
settingsStore = new Settings();
@@ -25,26 +29,19 @@ beforeEach(() => {
// fix startsAt & endsAt dates so they don't change between tests
silenceFormStore.data.startsAt = moment([2018, 1, 30, 10, 25, 50]).utc();
silenceFormStore.data.endsAt = moment([2018, 1, 30, 11, 25, 50]).utc();
jest.spyOn(React, "useContext").mockImplementation(() => MockThemeContext);
});
const RenderNavbar = () => {
return shallow(
<NavBar
alertStore={alertStore}
settingsStore={settingsStore}
silenceFormStore={silenceFormStore}
/>
).find(".container");
};
afterEach(() => {
act(() => jest.runAllTimers());
});
const MountedNavbar = () => {
const MountedNavbar = (fixedTop) => {
return mount(
<NavBar
alertStore={alertStore}
settingsStore={settingsStore}
silenceFormStore={silenceFormStore}
fixedTop={fixedTop}
/>,
{
wrappingComponent: ThemeContext.Provider,
@@ -57,7 +54,7 @@ const ValidateNavClass = (totalFilters, expectedClass) => {
for (let i = 0; i < totalFilters; i++) {
alertStore.filters.values.push(NewUnappliedFilter(`foo=${i}`));
}
const tree = RenderNavbar();
const tree = MountedNavbar();
const nav = tree.find(".navbar-nav");
expect(nav.props().className.split(" ")).toContain(expectedClass);
};
@@ -93,28 +90,14 @@ describe("<NavBar />", () => {
});
it("navbar includes 'fixed-top' class with fixedTop=true", () => {
const tree = mount(
<NavBar
alertStore={alertStore}
settingsStore={settingsStore}
silenceFormStore={silenceFormStore}
fixedTop={true}
/>
);
const tree = MountedNavbar(true);
const nav = tree.find(".navbar");
expect(nav.props().className.split(" ")).toContain("fixed-top");
expect(nav.props().className.split(" ")).not.toContain("w-100");
});
it("navbar doesn't 'fixed-top' class with fixedTop=false", () => {
const tree = mount(
<NavBar
alertStore={alertStore}
settingsStore={settingsStore}
silenceFormStore={silenceFormStore}
fixedTop={false}
/>
);
const tree = MountedNavbar(false);
const nav = tree.find(".navbar");
expect(nav.props().className.split(" ")).not.toContain("fixed-top");
expect(nav.props().className.split(" ")).toContain("w-100");
@@ -139,23 +122,19 @@ describe("<NavBar />", () => {
});
describe("<IdleTimer />", () => {
beforeAll(() => {
jest.useFakeTimers();
});
it("hides navbar after 12 seconds on mobile", () => {
it("hides navbar after MobileIdleTimeout on mobile", () => {
global.window.innerWidth = 500;
const tree = MountedNavbar();
jest.runTimersToTime(1000 * 13);
act(() => jest.runTimersToTime(MobileIdleTimeout + 1000));
tree.update();
expect(tree.find(".container").hasClass("visible")).toBe(false);
expect(tree.find(".container").hasClass("invisible")).toBe(true);
});
it("hides navbar after 3 minutes on desktop", () => {
it("hides navbar after DesktopIdleTimeout on desktop", () => {
global.window.innerWidth = 769;
const tree = MountedNavbar();
jest.runTimersToTime(1000 * 60 * 3 + 1000);
act(() => jest.runTimersToTime(DesktopIdleTimeout + 1000));
tree.update();
expect(tree.find(".container").hasClass("visible")).toBe(false);
expect(tree.find(".container").hasClass("invisible")).toBe(true);
@@ -166,7 +145,7 @@ describe("<IdleTimer />", () => {
const tree = MountedNavbar();
act(() => {
alertStore.filters.values.push(NewUnappliedFilter("cluster=dev"));
jest.runTimersToTime(1000 * 13);
jest.runTimersToTime(MobileIdleTimeout + 1000);
});
tree.update();
expect(tree.find(".container").hasClass("visible")).toBe(true);
@@ -178,7 +157,7 @@ describe("<IdleTimer />", () => {
const tree = MountedNavbar();
act(() => {
alertStore.filters.values.push(NewUnappliedFilter("cluster=dev"));
jest.runTimersToTime(1000 * 60 * 3 + 1000);
jest.runTimersToTime(DesktopIdleTimeout + 1000);
});
tree.update();
expect(tree.find(".container").hasClass("visible")).toBe(true);
@@ -190,14 +169,14 @@ describe("<IdleTimer />", () => {
const tree = MountedNavbar();
act(() => {
alertStore.filters.values.push(NewUnappliedFilter("cluster=dev"));
jest.runTimersToTime(1000 * 13);
jest.runTimersToTime(MobileIdleTimeout + 1000);
});
tree.update();
expect(tree.find(".container").hasClass("visible")).toBe(true);
expect(tree.find(".container").hasClass("invisible")).toBe(false);
alertStore.filters.applyAllFilters();
jest.runTimersToTime(1000 * 13);
act(() => jest.runTimersToTime(MobileIdleTimeout + 1000));
tree.update();
expect(tree.find(".container").hasClass("visible")).toBe(false);
expect(tree.find(".container").hasClass("invisible")).toBe(true);
@@ -208,14 +187,14 @@ describe("<IdleTimer />", () => {
const tree = MountedNavbar();
act(() => {
alertStore.filters.values.push(NewUnappliedFilter("cluster=dev"));
jest.runTimersToTime(1000 * 60 * 3 + 1000);
jest.runTimersToTime(DesktopIdleTimeout + 1000);
});
tree.update();
expect(tree.find(".container").hasClass("visible")).toBe(true);
expect(tree.find(".container").hasClass("invisible")).toBe(false);
alertStore.filters.applyAllFilters();
jest.runTimersToTime(1000 * 60 * 3 + 1000);
act(() => jest.runTimersToTime(DesktopIdleTimeout + 1000));
tree.update();
expect(tree.find(".container").hasClass("visible")).toBe(false);
expect(tree.find(".container").hasClass("invisible")).toBe(true);
@@ -226,13 +205,13 @@ describe("<IdleTimer />", () => {
const instance = tree.instance();
instance.onIdleTimerIdle();
jest.runOnlyPendingTimers();
act(() => jest.runOnlyPendingTimers());
tree.update();
expect(tree.find(".container").hasClass("visible")).toBe(false);
expect(tree.find(".container").hasClass("invisible")).toBe(true);
instance.onIdleTimerActive();
jest.runOnlyPendingTimers();
act(() => jest.runOnlyPendingTimers());
tree.update();
expect(tree.find(".container").hasClass("visible")).toBe(true);
expect(tree.find(".container").hasClass("invisible")).toBe(false);
@@ -243,7 +222,7 @@ describe("<IdleTimer />", () => {
const instance = tree.instance();
instance.onIdleTimerIdle();
jest.runOnlyPendingTimers();
act(() => jest.runOnlyPendingTimers());
tree.update();
expect(
window
@@ -255,7 +234,7 @@ describe("<IdleTimer />", () => {
it("doesn't hide when autohide is disabled in settingsStore", () => {
settingsStore.filterBarConfig.config.autohide = false;
const tree = MountedNavbar();
jest.runTimersToTime(1000 * 3600);
act(() => jest.runTimersToTime(1000 * 3600));
tree.update();
expect(tree.find(".container").hasClass("visible")).toBe(true);
expect(tree.find(".container").hasClass("invisible")).toBe(false);
@@ -265,7 +244,7 @@ describe("<IdleTimer />", () => {
settingsStore.filterBarConfig.config.autohide = true;
const tree = MountedNavbar();
alertStore.status.pause();
jest.runTimersToTime(1000 * 3600);
act(() => jest.runTimersToTime(1000 * 3600));
tree.update();
expect(tree.find(".container").hasClass("visible")).toBe(true);
expect(tree.find(".container").hasClass("invisible")).toBe(false);

View File

@@ -1,4 +1,5 @@
import React from "react";
import { act } from "react-dom/test-utils";
import { mount } from "enzyme";
@@ -47,12 +48,12 @@ describe("<OverviewModal />", () => {
const toggle = tree.find("div.navbar-brand");
toggle.simulate("click");
jest.runOnlyPendingTimers();
act(() => jest.runOnlyPendingTimers());
tree.update();
expect(tree.find(".modal-title").text()).toBe("Overview");
toggle.simulate("click");
jest.runOnlyPendingTimers();
act(() => jest.runOnlyPendingTimers());
tree.update();
expect(tree.find(".modal-title")).toHaveLength(0);
});
@@ -65,7 +66,7 @@ describe("<OverviewModal />", () => {
expect(tree.find(".modal-title").text()).toBe("Overview");
tree.find("button.close").simulate("click");
jest.runOnlyPendingTimers();
act(() => jest.runOnlyPendingTimers());
tree.update();
expect(tree.find("OverviewModalContent")).toHaveLength(0);
});

View File

@@ -357,8 +357,10 @@ describe("<Browser />", () => {
tree.unmount();
advanceTo(moment.utc([2000, 0, 1, 0, 30, 59]));
act(() => jest.runOnlyPendingTimers());
act(() => {
advanceTo(moment.utc([2000, 0, 1, 0, 30, 59]));
jest.runOnlyPendingTimers();
});
expect(useFetchGet.fetch.calls).toHaveLength(1);
});

View File

@@ -78,12 +78,12 @@ describe("<SilenceModal />", () => {
const toggle = tree.find(".nav-link");
toggle.simulate("click");
jest.runOnlyPendingTimers();
act(() => jest.runOnlyPendingTimers());
tree.update();
expect(tree.find("SilenceModalContent")).toHaveLength(1);
toggle.simulate("click");
jest.runOnlyPendingTimers();
act(() => jest.runOnlyPendingTimers());
tree.update();
expect(tree.find("SilenceModalContent")).toHaveLength(0);
});
@@ -93,12 +93,12 @@ describe("<SilenceModal />", () => {
const toggle = tree.find(".nav-link");
toggle.simulate("click");
jest.runOnlyPendingTimers();
act(() => jest.runOnlyPendingTimers());
tree.update();
expect(tree.find("SilenceModalContent")).toHaveLength(1);
silenceFormStore.toggle.hide();
jest.runOnlyPendingTimers();
act(() => jest.runOnlyPendingTimers());
tree.update();
expect(tree.find("SilenceModalContent")).toHaveLength(0);
});
@@ -114,7 +114,7 @@ describe("<SilenceModal />", () => {
// click to hide
toggle.simulate("click");
// wait for animation to finish
jest.runOnlyPendingTimers();
act(() => jest.runOnlyPendingTimers());
tree.update();
// form should be reset
expect(silenceFormStore.data.currentStage).toBe(SilenceFormStage.UserInput);
@@ -154,7 +154,7 @@ describe("<SilenceModal />", () => {
silenceFormStore.toggle.visible = true;
MountedSilenceModal();
expect(callbacks).toHaveLength(2);
expect(callbacks).toHaveLength(4);
act(() => {
callbacks.forEach((f) => f());
});

View File

@@ -1,51 +1,94 @@
import React from "react";
import React, { useState, useCallback, useEffect } from "react";
import { createPortal } from "react-dom";
import PropTypes from "prop-types";
import TooltipTrigger from "react-popper-tooltip";
import { usePopper } from "react-popper";
const Tooltip = ({ html, tooltip, children, className }) => (
<TooltipTrigger
trigger="hover"
placement="top"
delayShow={1000}
delayHide={100}
tooltip={({
arrowRef,
tooltipRef,
getArrowProps,
getTooltipProps,
placement,
}) => (
import { useSupportsTouch } from "Hooks/useSupportsTouch";
const TooltipWrapper = ({ title, children, className }) => {
const [referenceElement, setReferenceElement] = useState(null);
const [popperElement, setPopperElement] = useState(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "top",
modifiers: [
{
name: "preventOverflow",
options: {
boundariesElement: "viewport",
},
},
],
});
const supportsTouch = useSupportsTouch();
const [isHovering, setIsHovering] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [wasClicked, setWasClicked] = useState(false);
const showTooltip = useCallback(() => setIsHovering(true), []);
const hideTooltip = useCallback(() => setIsHovering(false), []);
const handleClick = useCallback(() => setWasClicked(true), []);
useEffect(() => {
let timerShow;
let timerHide;
if (!isHovering) {
if (isVisible) {
clearTimeout(timerShow);
timerHide = setTimeout(() => setIsVisible(false), 100);
}
setWasClicked(false);
} else if (wasClicked) {
clearTimeout(timerShow);
clearTimeout(timerHide);
setIsVisible(false);
} else if (!isVisible && isHovering) {
clearTimeout(timerHide);
timerShow = setTimeout(() => setIsVisible(true), 1000);
}
return () => {
clearTimeout(timerShow);
clearTimeout(timerHide);
};
}, [isHovering, isVisible, wasClicked]);
return (
<React.Fragment>
<div
{...getTooltipProps({
ref: tooltipRef,
className: "tooltip show tooltip-inner",
style: { transition: "opacity 0.2s" },
})}
>
{tooltip}
</div>
)}
>
{({ getTriggerProps, triggerRef }) => (
<div
{...getTriggerProps({
ref: triggerRef,
})}
onClick={handleClick}
onMouseOver={supportsTouch ? null : showTooltip}
onMouseLeave={supportsTouch ? null : hideTooltip}
onTouchStart={supportsTouch ? showTooltip : null}
onTouchCancel={supportsTouch ? hideTooltip : null}
onTouchEnd={supportsTouch ? hideTooltip : null}
ref={setReferenceElement}
style={{ display: "inline-block", maxWidth: "100%" }}
className={`${className ? className : ""} tooltip-trigger`}
>
{children}
</div>
)}
</TooltipTrigger>
);
const TooltipWrapper = ({ title, children, className }) => (
<Tooltip tooltip={title} className={className}>
{children}
</Tooltip>
);
{isVisible
? createPortal(
<div
className="tooltip show tooltip-inner"
ref={setPopperElement}
style={{
willChange: "opacity",
transition: "opacity 0.2s",
...styles.popper,
}}
{...attributes.popper}
>
{title}
</div>,
document.body
)
: null}
</React.Fragment>
);
};
TooltipWrapper.propTypes = {
title: PropTypes.node.isRequired,
children: PropTypes.node.isRequired,

View File

@@ -1,4 +1,5 @@
import React from "react";
import { act } from "react-dom/test-utils";
import { mount } from "enzyme";
@@ -7,17 +8,6 @@ import { TooltipWrapper } from ".";
describe("TooltipWrapper", () => {
beforeAll(() => {
jest.useFakeTimers();
// https://stackoverflow.com/a/60974039/1154047
const mutationObserverMock = jest.fn(function MutationObserver(callback) {
this.observe = jest.fn();
this.disconnect = jest.fn();
// Optionally add a trigger() method to manually trigger a change
this.trigger = (mockedMutationsList) => {
callback(mockedMutationsList, this);
};
});
global.MutationObserver = mutationObserverMock;
});
it("renders only children", () => {
@@ -30,21 +20,86 @@ describe("TooltipWrapper", () => {
expect(tree.find("div.tooltip")).toHaveLength(0);
});
it("renders tooltip on hover and hides on blur", () => {
it("uses passed className", () => {
const tree = mount(
<TooltipWrapper title="my title" className="foo">
<span>Hover me</span>
</TooltipWrapper>
);
expect(tree.find("div.foo")).toHaveLength(1);
expect(tree.find("div.foo").text()).toBe("Hover me");
});
it("on non-touch devices it renders tooltip on mouseOver and hides on mouseLeave", () => {
const tree = mount(
<TooltipWrapper title="my title">
<span>Hover me</span>
</TooltipWrapper>
);
tree.simulate("mouseEnter");
jest.runAllTimers();
tree.simulate("mouseOver");
act(() => jest.runAllTimers());
tree.update();
expect(tree.find("div.tooltip")).toHaveLength(1);
tree.simulate("mouseLeave");
jest.runAllTimers();
act(() => jest.runAllTimers());
tree.update();
expect(tree.find("div.tooltip")).toHaveLength(0);
});
it("on touch devices it renders tooltip on touchStart and hides on touchEnd", () => {
const tree = mount(
<TooltipWrapper title="my title">
<span>Hover me</span>
</TooltipWrapper>
);
act(() => {
const event = new Event("touchstart");
global.window.dispatchEvent(event);
});
tree.update();
tree.simulate("touchStart");
act(() => jest.runAllTimers());
tree.update();
expect(tree.find("div.tooltip")).toHaveLength(1);
tree.simulate("touchEnd");
act(() => jest.runAllTimers());
tree.update();
expect(tree.find("div.tooltip")).toHaveLength(0);
});
it("hides the tooltip after click and show again on mouseOver", () => {
const tree = mount(
<TooltipWrapper title="my title">
<span>Hover me</span>
</TooltipWrapper>
);
tree.simulate("mouseOver");
act(() => jest.runAllTimers());
tree.update();
expect(tree.find("div.tooltip")).toHaveLength(1);
tree.simulate("click");
act(() => jest.runAllTimers());
tree.update();
expect(tree.find("div.tooltip")).toHaveLength(0);
tree.simulate("mouseLeave");
act(() => jest.runAllTimers());
tree.update();
expect(tree.find("div.tooltip")).toHaveLength(0);
tree.simulate("mouseOver");
act(() => jest.runAllTimers());
tree.update();
expect(tree.find("div.tooltip")).toHaveLength(1);
tree.unmount();
act(() => jest.runAllTimers());
});
});

View File

@@ -0,0 +1,18 @@
import { useState, useEffect, useCallback } from "react";
function useSupportsTouch() {
const [supportsTouch, setSupportsTouch] = useState(false);
const handler = useCallback(() => setSupportsTouch(true), []);
useEffect(() => {
window.addEventListener("touchstart", handler);
return () => {
window.removeEventListener("touchstart", handler);
};
}, [handler]);
return supportsTouch;
}
export { useSupportsTouch };

View File

@@ -0,0 +1,24 @@
import { renderHook, act } from "@testing-library/react-hooks";
import { useSupportsTouch } from "./useSupportsTouch";
describe("useSupportsTouch", () => {
it("returns false by default", () => {
const { result } = renderHook(() => useSupportsTouch());
expect(result.current).toBe(false);
});
it("returns true after touchStart event", () => {
const { waitForNextUpdate, result } = renderHook(() => useSupportsTouch());
expect(result.current).toBe(false);
act(() => {
const event = new Event("touchstart");
global.window.dispatchEvent(event);
});
waitForNextUpdate();
expect(result.current).toBe(true);
});
});

View File

@@ -11,6 +11,8 @@ $btn-box-shadow: 0;
$btn-focus-box-shadow: 0;
$btn-active-box-shadow: 0;
$tooltip-max-width: 400px;
@import "Styles/Fonts";
@import "~bootswatch/dist/darkly/variables";

View File

@@ -14,6 +14,8 @@ $btn-box-shadow: 0;
$btn-focus-box-shadow: 0;
$btn-active-box-shadow: 0;
$tooltip-max-width: 400px;
@import "Styles/Fonts";
@import "~bootswatch/dist/flatly/variables";

View File

@@ -1,3 +1,4 @@
import React from "react";
import Enzyme from "enzyme";
import Adapter from "enzyme-adapter-react-16";
@@ -30,3 +31,6 @@ for (const level of ["error", "warn", "info", "log", "trace"]) {
FetchRetryConfig.minTimeout = 2;
FetchRetryConfig.maxTimeout = 10;
// usePopper uses useLayoutEffect but that fails in enzyme
React.useLayoutEffect = React.useEffect;