diff --git a/ui/package-lock.json b/ui/package-lock.json
index 30e827ba6..9bfaa100f 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -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",
diff --git a/ui/package.json b/ui/package.json
index d82d10b79..a4bee8243 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -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",
diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.test.js
index 5b037d70d..0a25cf243 100644
--- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.test.js
+++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/index.test.js
@@ -1,4 +1,5 @@
import React from "react";
+import { act } from "react-dom/test-utils";
import { mount } from "enzyme";
@@ -270,7 +271,7 @@ describe("", () => {
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)));
diff --git a/ui/src/Components/Grid/AlertGrid/index.test.js b/ui/src/Components/Grid/AlertGrid/index.test.js
index 1320f5716..e6e532042 100644
--- a/ui/src/Components/Grid/AlertGrid/index.test.js
+++ b/ui/src/Components/Grid/AlertGrid/index.test.js
@@ -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("", () => {
MountedGrid();
// skip a minute to trigger FontFaceObserver timeout handler
advanceBy(60 * 1000);
- jest.runOnlyPendingTimers();
+ act(() => jest.runOnlyPendingTimers());
});
it("doesn't crash on unmount", () => {
diff --git a/ui/src/Components/MainModal/index.test.js b/ui/src/Components/MainModal/index.test.js
index d8af64b87..3f007c55e 100644
--- a/ui/src/Components/MainModal/index.test.js
+++ b/ui/src/Components/MainModal/index.test.js
@@ -1,4 +1,5 @@
import React from "react";
+import { act } from "react-dom/test-utils";
import { mount } from "enzyme";
@@ -77,12 +78,12 @@ describe("", () => {
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("", () => {
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);
});
diff --git a/ui/src/Components/NavBar/index.js b/ui/src/Components/NavBar/index.js
index f0fde2f6b..cac29fb64 100644
--- a/ui/src/Components/NavBar/index.js
+++ b/ui/src/Components/NavBar/index.js
@@ -198,4 +198,4 @@ const NavBar = observer(
);
NavBar.contextType = ThemeContext;
-export { NavBar };
+export { NavBar, MobileIdleTimeout, DesktopIdleTimeout };
diff --git a/ui/src/Components/NavBar/index.test.js b/ui/src/Components/NavBar/index.test.js
index 13b79c5e1..121626bc1 100644
--- a/ui/src/Components/NavBar/index.test.js
+++ b/ui/src/Components/NavBar/index.test.js
@@ -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(
-
- ).find(".container");
-};
+afterEach(() => {
+ act(() => jest.runAllTimers());
+});
-const MountedNavbar = () => {
+const MountedNavbar = (fixedTop) => {
return mount(
,
{
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("", () => {
});
it("navbar includes 'fixed-top' class with fixedTop=true", () => {
- const tree = mount(
-
- );
+ 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(
-
- );
+ 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("", () => {
});
describe("", () => {
- 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("", () => {
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("", () => {
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("", () => {
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("", () => {
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("", () => {
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("", () => {
const instance = tree.instance();
instance.onIdleTimerIdle();
- jest.runOnlyPendingTimers();
+ act(() => jest.runOnlyPendingTimers());
tree.update();
expect(
window
@@ -255,7 +234,7 @@ describe("", () => {
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("", () => {
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);
diff --git a/ui/src/Components/OverviewModal/index.test.js b/ui/src/Components/OverviewModal/index.test.js
index 7fc6b8294..18c250f8f 100644
--- a/ui/src/Components/OverviewModal/index.test.js
+++ b/ui/src/Components/OverviewModal/index.test.js
@@ -1,4 +1,5 @@
import React from "react";
+import { act } from "react-dom/test-utils";
import { mount } from "enzyme";
@@ -47,12 +48,12 @@ describe("", () => {
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("", () => {
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);
});
diff --git a/ui/src/Components/SilenceModal/Browser/index.test.js b/ui/src/Components/SilenceModal/Browser/index.test.js
index 2a39f88d9..f58523e8a 100644
--- a/ui/src/Components/SilenceModal/Browser/index.test.js
+++ b/ui/src/Components/SilenceModal/Browser/index.test.js
@@ -357,8 +357,10 @@ describe("", () => {
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);
});
diff --git a/ui/src/Components/SilenceModal/index.test.js b/ui/src/Components/SilenceModal/index.test.js
index d1a003d86..38153aeb6 100644
--- a/ui/src/Components/SilenceModal/index.test.js
+++ b/ui/src/Components/SilenceModal/index.test.js
@@ -78,12 +78,12 @@ describe("", () => {
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("", () => {
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("", () => {
// 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("", () => {
silenceFormStore.toggle.visible = true;
MountedSilenceModal();
- expect(callbacks).toHaveLength(2);
+ expect(callbacks).toHaveLength(4);
act(() => {
callbacks.forEach((f) => f());
});
diff --git a/ui/src/Components/TooltipWrapper/index.js b/ui/src/Components/TooltipWrapper/index.js
index c7c97e33c..bab8a53ac 100644
--- a/ui/src/Components/TooltipWrapper/index.js
+++ b/ui/src/Components/TooltipWrapper/index.js
@@ -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 }) => (
- (
+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 (
+
- {tooltip}
-
- )}
- >
- {({ getTriggerProps, triggerRef }) => (
-
{children}
- )}
-
-);
-
-const TooltipWrapper = ({ title, children, className }) => (
-
- {children}
-
-);
+ {isVisible
+ ? createPortal(
+
+ {title}
+
,
+ document.body
+ )
+ : null}
+
+ );
+};
TooltipWrapper.propTypes = {
title: PropTypes.node.isRequired,
children: PropTypes.node.isRequired,
diff --git a/ui/src/Components/TooltipWrapper/index.test.js b/ui/src/Components/TooltipWrapper/index.test.js
index c055b7cd2..ea3c9eef1 100644
--- a/ui/src/Components/TooltipWrapper/index.test.js
+++ b/ui/src/Components/TooltipWrapper/index.test.js
@@ -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(
+
+ Hover me
+
+ );
+ 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(
Hover me
);
- 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(
+
+ Hover me
+
+ );
+
+ 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(
+
+ Hover me
+
+ );
+
+ 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());
+ });
});
diff --git a/ui/src/Hooks/useSupportsTouch.js b/ui/src/Hooks/useSupportsTouch.js
new file mode 100644
index 000000000..93a0496b2
--- /dev/null
+++ b/ui/src/Hooks/useSupportsTouch.js
@@ -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 };
diff --git a/ui/src/Hooks/useSupportsTouch.test.js b/ui/src/Hooks/useSupportsTouch.test.js
new file mode 100644
index 000000000..282b482ff
--- /dev/null
+++ b/ui/src/Hooks/useSupportsTouch.test.js
@@ -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);
+ });
+});
diff --git a/ui/src/Styles/DarkTheme.scss b/ui/src/Styles/DarkTheme.scss
index 2a5f35541..c33e2351a 100644
--- a/ui/src/Styles/DarkTheme.scss
+++ b/ui/src/Styles/DarkTheme.scss
@@ -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";
diff --git a/ui/src/Styles/LightTheme.scss b/ui/src/Styles/LightTheme.scss
index ce2497408..9333f0342 100644
--- a/ui/src/Styles/LightTheme.scss
+++ b/ui/src/Styles/LightTheme.scss
@@ -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";
diff --git a/ui/src/setupTests.js b/ui/src/setupTests.js
index 5ac41c311..81e09b890 100644
--- a/ui/src/setupTests.js
+++ b/ui/src/setupTests.js
@@ -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;