/*
* Copyright 2021 Palantir Technologies, Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { waitFor } from "@testing-library/dom";
import classNames from "classnames";
import { mount, type ReactWrapper } from "enzyme";
import { createRef, useCallback } from "react";
import { afterAll, afterEach, assert, beforeEach, describe, expect, it, vi } from "@blueprintjs/test-commons/vitest";
import { Classes, Utils } from "../../common";
import { Drawer } from "../drawer/drawer";
import { Menu } from "../menu/menu";
import { MenuItem } from "../menu/menuItem";
import { PopoverNext } from "../popover-next/popoverNext";
import { Tooltip, type TooltipProps } from "../tooltip/tooltip";
import { ContextMenu, type ContextMenuContentProps, type ContextMenuProps } from "./contextMenu";
// use a unique ID to avoid collisons with other tests
const MENU_CLASSNAME = Utils.uniqueId("test-menu");
const MENU = (
);
const TARGET_CLASSNAME = "test-target";
const TOOLTIP_SELECTOR = `.${Classes.TOOLTIP}`;
const COMMON_TOOLTIP_PROPS: Partial = {
hoverCloseDelay: 0,
hoverOpenDelay: 0,
transitionDuration: 0,
usePortal: false,
};
function cleanupDOM() {
// Aggressively clean up any remaining portals, overlays, and context menus
document.querySelectorAll(`.${Classes.PORTAL}`).forEach(el => el.remove());
document.querySelectorAll(`.${Classes.OVERLAY}`).forEach(el => el.remove());
document.querySelectorAll(`.${Classes.CONTEXT_MENU}`).forEach(el => el.remove());
document.querySelectorAll(`.${Classes.CONTEXT_MENU_POPOVER}`).forEach(el => el.remove());
document.body.classList.remove(Classes.OVERLAY_OPEN);
}
describe("ContextMenu", () => {
let containerElement: HTMLElement;
const mountedWrappers: ReactWrapper[] = [];
beforeEach(() => {
containerElement = document.createElement("div");
document.body.appendChild(containerElement);
});
afterEach(() => {
// Unmount all Enzyme wrappers before removing the container
mountedWrappers.forEach(wrapper => {
if (wrapper.exists()) {
wrapper.unmount();
}
});
mountedWrappers.length = 0;
containerElement.remove();
cleanupDOM();
});
afterAll(() => {
// Final cleanup after all tests in this suite complete
// This ensures nothing leaks to other test files
cleanupDOM();
});
describe("basic usage", () => {
it("renders children and Popover", () => {
const ctxMenu = mountTestMenu();
expect(ctxMenu.find(`.${TARGET_CLASSNAME}`).exists()).toBe(true);
expect(ctxMenu.find(PopoverNext).exists()).toBe(true);
});
it("opens popover on right click", () => {
const ctxMenu = mountTestMenu();
openCtxMenu(ctxMenu);
expect(ctxMenu.find(PopoverNext).prop("isOpen")).toBe(true);
});
it("renders custom HTML tag if specified", () => {
const ctxMenu = mountTestMenu({ tagName: "span" });
expect(ctxMenu.find(`span.${Classes.CONTEXT_MENU}`).exists()).toBe(true);
});
it("supports custom refs", () => {
const ref = createRef();
mountTestMenu({ className: "test-container", ref });
expect(ref.current).toBeDefined();
expect(ref.current?.classList.contains("test-container")).toBe(true);
});
it("closes popover on ESC key press", () => {
const ctxMenu = mountTestMenu();
openCtxMenu(ctxMenu);
ctxMenu
.find(`.${Classes.OVERLAY_OPEN}`)
.hostNodes()
.simulate("keydown", {
key: "Escape",
nativeEvent: new KeyboardEvent("keydown"),
});
expect(ctxMenu.find(PopoverNext).prop("isOpen")).toBe(false);
});
it("clicks inside popover don't propagate to context menu wrapper", () => {
const itemClickSpy = vi.fn();
const wrapperClickSpy = vi.fn();
const ctxMenu = mountTestMenu({
content: (
),
onClick: wrapperClickSpy,
});
openCtxMenu(ctxMenu);
ctxMenu.find("[data-testid='item']").hostNodes().simulate("click");
expect(itemClickSpy).toHaveBeenCalledOnce();
expect(wrapperClickSpy).not.toHaveBeenCalled();
});
it("allows overrding some Popover props", () => {
const placement = "top";
const popoverClassName = "test-popover-class";
const ctxMenu = mountTestMenu({ popoverProps: { placement, popoverClassName } });
openCtxMenu(ctxMenu);
const popoverWithTopPlacement = document.querySelector(
`.${popoverClassName}.${Classes.POPOVER_CONTENT_PLACEMENT}-${placement}`,
);
expect(popoverWithTopPlacement).toBeDefined();
});
function mountTestMenu(props: Partial = {}) {
const wrapper = mount(
,
{ attachTo: containerElement },
);
mountedWrappers.push(wrapper);
return wrapper;
}
});
describe("advanced usage (child render function API)", () => {
it("renders children and Popover", () => {
const ctxMenu = mountTestMenu();
expect(ctxMenu.find(`.${TARGET_CLASSNAME}`).exists()).toBe(true);
expect(ctxMenu.find(PopoverNext).exists()).toBe(true);
});
it("opens popover on right click", () => {
const ctxMenu = mountTestMenu();
openCtxMenu(ctxMenu);
expect(ctxMenu.find(PopoverNext).prop("isOpen")).toBe(true);
});
it("handles context menu event, even if content is undefined", () => {
const ctxMenu = mountTestMenu({ content: undefined });
let clickedInfo = ctxMenu.find("[data-testid='content-clicked-info']");
expect(clickedInfo.text().trim()).toBe(renderClickedInfo(undefined));
openCtxMenu(ctxMenu);
clickedInfo = ctxMenu.find("[data-testid='content-clicked-info']");
expect(clickedInfo.text().trim()).toBe(renderClickedInfo({ left: 10, top: 10 }));
});
it("does not handle context menu event when disabled={true}", () => {
const ctxMenu = mountTestMenu({ disabled: true });
let clickedInfo = ctxMenu.find("[data-testid='content-clicked-info']");
expect(clickedInfo.text().trim()).toBe(renderClickedInfo(undefined));
openCtxMenu(ctxMenu);
clickedInfo = ctxMenu.find("[data-testid='content-clicked-info']");
expect(clickedInfo.text().trim()).toBe(renderClickedInfo(undefined));
});
function mountTestMenu(props?: Partial) {
const wrapper = mount(
{ctxMenuProps => (
{ctxMenuProps.popover}
{renderClickedInfo(ctxMenuProps.contentProps.targetOffset)}
)}
,
{ attachTo: containerElement },
);
mountedWrappers.push(wrapper);
return wrapper;
}
});
describe("advanced usage (content render function API)", () => {
const ALT_CONTENT_WRAPPER = "alternative-content-wrapper";
it("renders children and menu content, prevents default context menu handler", () =>
new Promise(done => {
const onContextMenu = (e: React.MouseEvent) => {
expect(e.defaultPrevented).toBe(true);
done();
};
const wrapper = mountTestMenu({ onContextMenu });
expect(wrapper.find(`.${TARGET_CLASSNAME}`).exists()).toBe(true);
openCtxMenu(wrapper);
expect(wrapper.find(`.${MENU_CLASSNAME}`).exists()).toBe(true);
closeCtxMenu(wrapper);
}));
it("triggers native context menu if content function returns undefined", () =>
new Promise(done => {
const onContextMenu = (e: React.MouseEvent) => {
expect(e.defaultPrevented).toBe(false);
done();
};
const wrapper = mountTestMenu({
content: () => undefined,
onContextMenu,
});
openCtxMenu(wrapper);
closeCtxMenu(wrapper);
}));
it("updates menu if content prop value changes", () => {
const ctxMenu = mountTestMenu();
openCtxMenu(ctxMenu);
expect(ctxMenu.find(`.${MENU_CLASSNAME}`).exists()).toBe(true);
expect(ctxMenu.find(`.${ALT_CONTENT_WRAPPER}`).exists()).toBe(false);
ctxMenu.setProps({ content: renderAlternativeContent });
expect(ctxMenu.find(`.${ALT_CONTENT_WRAPPER}`).exists()).toBe(true);
});
it("updates menu if content render function return value changes", () => {
const testMenu = mount(, {
attachTo: containerElement,
});
mountedWrappers.push(testMenu);
openCtxMenu(testMenu);
expect(testMenu.find(`.${MENU_CLASSNAME}`).exists()).toBe(true);
expect(testMenu.find(`.${ALT_CONTENT_WRAPPER}`).exists()).toBe(false);
testMenu.setProps({ useAltContent: true });
expect(testMenu.find(`.${ALT_CONTENT_WRAPPER}`).exists()).toBe(true);
});
function renderContent({ mouseEvent, targetOffset }: ContextMenuContentProps) {
if (mouseEvent === undefined || targetOffset === undefined) {
return undefined;
}
return MENU;
}
function renderAlternativeContent() {
return {MENU}
;
}
function mountTestMenu(props?: Partial) {
const wrapper = mount(
,
{ attachTo: containerElement },
);
mountedWrappers.push(wrapper);
return wrapper;
}
function TestMenuWithChangingContent({ useAltContent } = { useAltContent: false }) {
const content = useCallback(
(contentProps: ContextMenuContentProps) =>
useAltContent ? renderAlternativeContent() : renderContent(contentProps),
[useAltContent],
);
return (
);
}
});
describe("theming", () => {
it("detects dark theme", () => {
const wrapper = mount(
,
);
mountedWrappers.push(wrapper);
openCtxMenu(wrapper);
const ctxMenuPopover = wrapper.find(`.${Classes.CONTEXT_MENU_POPOVER}`).hostNodes();
expect(ctxMenuPopover.hasClass(Classes.DARK)).toBe(true);
closeCtxMenu(wrapper);
});
it("detects theme change (dark -> light)", () => {
const wrapper = mount(
,
);
mountedWrappers.push(wrapper);
wrapper.setProps({ className: undefined });
openCtxMenu(wrapper);
const ctxMenuPopover = wrapper.find(`.${Classes.CONTEXT_MENU_POPOVER}`).hostNodes();
expect(ctxMenuPopover.hasClass(Classes.DARK)).toBe(false);
closeCtxMenu(wrapper);
});
});
describe("interacting with other components", () => {
describe("with one level of nesting", () => {
it("closes parent Tooltip", async () => {
const wrapper = mount(
,
);
mountedWrappers.push(wrapper);
openTooltip(wrapper);
openCtxMenu(wrapper);
expect(
wrapper.find(ContextMenu).find(PopoverNext).prop("isOpen"),
"ContextMenu popover should be open",
).toBe(true);
await assertTooltipClosed();
closeCtxMenu(wrapper);
});
it("closes child Tooltip", () => {
const wrapper = mount(
,
);
mountedWrappers.push(wrapper);
openTooltip(wrapper);
openCtxMenu(wrapper);
expect(
wrapper.find(ContextMenu).find(PopoverNext).first().prop("isOpen"),
"ContextMenu popover should be open",
).toBe(true);
// this assertion is difficult to unit test, but we know that the tooltip closes in manual testing,
// see https://github.com/palantir/blueprint/pull/4744
// assertTooltipClosed(wrapper);
closeCtxMenu(wrapper);
});
async function assertTooltipClosed() {
// Tooltip close is driven by a `useEffect` inside PopoverNext that syncs internal
// state when `disabled` flips true, so the DOM unmount lands in a later tick.
// Query the document directly because the Enzyme wrapper can hold stale references.
await waitFor(() => {
expect(document.querySelectorAll(`.${Classes.TOOLTIP}`), "Tooltip should be closed").toHaveLength(
0,
);
});
}
});
describe("with multiple layers of Tooltip nesting", () => {
const OUTER_TARGET_CLASSNAME = "outer-target";
describe("ContextMenu > Tooltip > ContextMenu", () => {
it("closes tooltip when inner menu opens", async () => {
const wrapper = mountTestCase();
openTooltip(wrapper);
expect(wrapper.find(TOOLTIP_SELECTOR), "tooltip should be open").toHaveLength(1);
openCtxMenu(wrapper);
await assertTooltipClosed();
const ctxMenuPopover = wrapper.find(`.${Classes.CONTEXT_MENU_POPOVER}`).hostNodes();
expect(ctxMenuPopover.exists(), "ContextMenu popover should be open").toBe(true);
expect(ctxMenuPopover.text().includes("first"), "inner ContextMenu should be open").toBe(true);
closeCtxMenu(wrapper);
});
it("closes tooltip when outer menu opens", () => {
const wrapper = mountTestCase();
openTooltip(wrapper, OUTER_TARGET_CLASSNAME);
expect(wrapper.find(TOOLTIP_SELECTOR), "tooltip should be open").toHaveLength(1);
openCtxMenu(wrapper, OUTER_TARGET_CLASSNAME);
// this assertion is difficult to test, but we know that the tooltip eventually does close in manual testing
// assertTooltipClosed(wrapper);
const ctxMenuPopover = wrapper.find(`.${Classes.CONTEXT_MENU_POPOVER}`).hostNodes();
expect(ctxMenuPopover.exists(), "ContextMenu popover should be open").toBe(true);
expect(ctxMenuPopover.text().includes("Align"), "outer ContextMenu should be open").toBe(true);
closeCtxMenu(wrapper);
});
function mountTestCase() {
/**
* Renders a component tree that looks like this:
*
* ––––––––––––––––––––––––––––––––––––––
* | outer ctx menu |
* | –––––––––––––––––––––––––––––––– |
* | | tooltip target | |
* | | –––––––––––––––––––––––––– | |
* | | | inner ctx menu w/ target | | |
* | | | | | |
* | | –––––––––––––––––––––––––– | |
* | –––––––––––––––––––––––––––––––– |
* ––––––––––––––––––––––––––––––––––––––
*
* It is possible to click on just the outer ctx menu, hover on just the tooltip target
* (and not the inner target), and to click on the inner target.
*/
const wrapper = mount(
}
popoverProps={{ transitionDuration: 0 }}
>
,
);
mountedWrappers.push(wrapper);
return wrapper;
}
async function assertTooltipClosed() {
// Tooltip close is driven by a `useEffect` inside PopoverNext that syncs internal
// state when `disabled` flips true, so the DOM unmount lands in a later tick.
// Query the document directly because the Enzyme wrapper can hold stale references.
await waitFor(() => {
expect(
document.querySelectorAll(`.${Classes.TOOLTIP}`),
"Tooltip should be closed",
).toHaveLength(0);
});
}
});
describe("Tooltip > ContextMenu > Tooltip", () => {
const OUTER_TOOLTIP_CONTENT = "hello";
const INNER_TOOLTIP_CONTENT = "goodbye";
const CTX_MENU_CLASSNAME = "test-ctx-menu";
it("closes inner tooltip when menu opens (after hovering inner target)", async () => {
const wrapper = mountTestCase();
wrapper.find(`.${OUTER_TARGET_CLASSNAME}`).simulate("mouseenter");
openTooltip(wrapper);
expect(wrapper.find(TOOLTIP_SELECTOR), "tooltip should be open").toHaveLength(1);
openCtxMenu(wrapper);
// Tooltip close is driven by a `useEffect` inside PopoverNext that syncs internal
// state when `disabled` flips true, so the DOM unmount lands in a later tick.
// Query the document directly because the Enzyme wrapper can hold stale references.
await waitFor(() => {
expect(
document.querySelectorAll(`.${Classes.TOOLTIP}`),
"Tooltip should be closed",
).toHaveLength(0);
});
const ctxMenuPopover = wrapper.find(`.${Classes.CONTEXT_MENU_POPOVER}`).hostNodes();
expect(ctxMenuPopover.exists(), "ContextMenu popover should be open").toBe(true);
closeCtxMenu(wrapper);
wrapper.find(`.${OUTER_TARGET_CLASSNAME}`).simulate("mouseleave");
});
it("closes outer tooltip when menu opens (after hovering ctx menu target)", () => {
const wrapper = mountTestCase();
openTooltip(wrapper, CTX_MENU_CLASSNAME);
expect(wrapper.find(`.${Classes.TOOLTIP}`), "tooltip should be open").toHaveLength(1);
openCtxMenu(wrapper, CTX_MENU_CLASSNAME);
// this assertion is difficult to test, but we know that the tooltip eventually does close in manual testing
// assert.isFalse(
// wrapper
// .find(PopoverNext)
// .find({ interactionKind: PopoverInteractionKind.HOVER_TARGET_ONLY })
// .last()
// .state("isOpen"),
// "Tooltip should be closed",
// );
const ctxMenuPopover = wrapper.find(`.${Classes.CONTEXT_MENU_POPOVER}`).hostNodes();
expect(ctxMenuPopover.exists(), "ContextMenu popover should be open").toBe(true);
expect(ctxMenuPopover.text().includes("Align"), "outer ContextMenu should be open").toBe(true);
closeCtxMenu(wrapper);
wrapper.find(`.${OUTER_TARGET_CLASSNAME}`).simulate("mouseleave");
});
function mountTestCase() {
/**
* Renders a component tree that looks like this:
*
* ––––––––––––––––––––––––––––––––––––––
* | outer tooltip |
* | –––––––––––––––––––––––––––––––– |
* | | ctx menu target | |
* | | –––––––––––––––––––––––––– | |
* | | | inner tooltip w/ target | | |
* | | | | | |
* | | –––––––––––––––––––––––––– | |
* | –––––––––––––––––––––––––––––––– |
* ––––––––––––––––––––––––––––––––––––––
*
* It is possible to hover on just the outer tooltip area, click on just the ctx menu target
* (and not trigger the inner tooltip), and to click/hover on the inner target.
*/
const wrapper = mount(
,
);
mountedWrappers.push(wrapper);
return wrapper;
}
});
});
describe("with Drawer as parent content", () => {
it("positions correctly", () => {
const POPOVER_CLASSNAME = "test-positions-popover";
const wrapper = mount(
,
{ attachTo: containerElement },
);
mountedWrappers.push(wrapper);
const target = wrapper.find(`.${TARGET_CLASSNAME}`).hostNodes();
expect(target.exists(), "target should exist").toBe(true);
const nonExistentPopover = wrapper.find(`.${POPOVER_CLASSNAME}`).hostNodes();
expect(
nonExistentPopover.exists(),
"ContextMenu popover should not be open before triggering contextmenu event",
).toBe(false);
const targetRect = target.getDOMNode().getBoundingClientRect();
// right click on the target
const simulateArgs = {
clientX: targetRect.left + targetRect.width / 2,
clientY: targetRect.top + targetRect.height / 2,
x: targetRect.left + targetRect.width / 2,
y: targetRect.top + targetRect.height / 2,
};
target.simulate("contextmenu", simulateArgs);
const popover = wrapper.find(`.${POPOVER_CLASSNAME}`).hostNodes();
expect(popover.exists(), "ContextMenu popover should be open").toBe(true);
});
});
function openTooltip(wrapper: ReactWrapper, targetClassName = TARGET_CLASSNAME) {
const target = wrapper.find(`.${targetClassName}`);
if (!target.exists()) {
assert.fail("tooltip target not found in mounted test case");
}
target.hostNodes().closest(`.${Classes.POPOVER_TARGET}`).simulate("mouseenter");
}
});
function openCtxMenu(ctxMenu: ReactWrapper, targetClassName = TARGET_CLASSNAME) {
const target = ctxMenu.find(`.${targetClassName}`);
if (!target.exists()) {
assert.fail("Context menu target not found in mounted test case");
}
const { clientLeft, clientTop } = target.hostNodes().getDOMNode();
target
.hostNodes()
.simulate("contextmenu", { clientX: clientLeft + 10, clientY: clientTop + 10, defaultPrevented: false })
.update();
}
function closeCtxMenu(wrapper: ReactWrapper) {
const backdrop = wrapper.find(`.${Classes.CONTEXT_MENU_BACKDROP}`);
if (backdrop.exists()) {
backdrop.simulate("mousedown");
wrapper.update();
}
}
function renderClickedInfo(targetOffset: ContextMenuContentProps["targetOffset"]) {
return targetOffset === undefined ? "" : `Clicked at (${targetOffset.left}, ${targetOffset.top})`;
}
});