mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-10-09 19:53:32 +01:00
Avoid opening tab path tooltips during automatic focus (#2601)
* Prevent tab tooltips opening on automatic focus * Target expand icons in E2E tree navigation
This commit is contained in:
@@ -1,10 +1,72 @@
|
|||||||
import { TreeItem, TreeItemLayout } from "@fluentui/react-components";
|
import { TreeItem, TreeItemLayout } from "@fluentui/react-components";
|
||||||
|
import type { Frame } from "@playwright/test";
|
||||||
import PromiseSource from "Utils/PromiseSource";
|
import PromiseSource from "Utils/PromiseSource";
|
||||||
import { mount, shallow } from "enzyme";
|
import { mount, shallow } from "enzyme";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { act } from "react-dom/test-utils";
|
import { act } from "react-dom/test-utils";
|
||||||
|
import { DataExplorer } from "../../../../test/fx";
|
||||||
import { TreeNode, TreeNodeComponent } from "./TreeNodeComponent";
|
import { TreeNode, TreeNodeComponent } from "./TreeNodeComponent";
|
||||||
|
|
||||||
|
jest.mock("@playwright/test", () => ({}));
|
||||||
|
|
||||||
|
describe("Tree expansion helper", () => {
|
||||||
|
const createNode = () => {
|
||||||
|
const expandIcon = { click: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const row = { locator: jest.fn().mockReturnValue(expandIcon), click: jest.fn() };
|
||||||
|
const container = { getAttribute: jest.fn().mockResolvedValue("false") };
|
||||||
|
const tree = { waitFor: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const frame = {
|
||||||
|
getByTestId: jest.fn((id: string) => {
|
||||||
|
if (id === "TreeNode:root") {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
if (id === "TreeNodeContainer:root") {
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
if (id === "Tree:root") {
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected locator: ${id}`);
|
||||||
|
}),
|
||||||
|
} as unknown as Frame;
|
||||||
|
return { node: new DataExplorer(frame).treeNode("root"), row, container, tree, expandIcon };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("clicks the expand icon rather than the row that contains menu actions", async () => {
|
||||||
|
const { node, row, expandIcon } = createNode();
|
||||||
|
await node.expand();
|
||||||
|
expect(row.locator).toHaveBeenCalledWith(":scope > .fui-TreeItemLayout__expandIcon");
|
||||||
|
expect(expandIcon.click).toHaveBeenCalledTimes(1);
|
||||||
|
expect(row.click).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not toggle a node that is already expanded", async () => {
|
||||||
|
const { node, row, container, tree, expandIcon } = createNode();
|
||||||
|
container.getAttribute.mockResolvedValue("true");
|
||||||
|
await node.expand();
|
||||||
|
expect(tree.waitFor).toHaveBeenCalled();
|
||||||
|
expect(expandIcon.click).not.toHaveBeenCalled();
|
||||||
|
expect(row.click).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the expand icon again when a collapsed node needs a retry", async () => {
|
||||||
|
const { node, row, tree, expandIcon } = createNode();
|
||||||
|
tree.waitFor.mockRejectedValueOnce(new Error("Children are not visible yet"));
|
||||||
|
await node.expand();
|
||||||
|
expect(expandIcon.click).toHaveBeenCalledTimes(2);
|
||||||
|
expect(row.click).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not collapse a node that expanded while waiting for children", async () => {
|
||||||
|
const { node, container, tree, expandIcon } = createNode();
|
||||||
|
container.getAttribute.mockResolvedValueOnce("false").mockResolvedValue("true");
|
||||||
|
tree.waitFor.mockRejectedValueOnce(new Error("Children are not visible yet"));
|
||||||
|
await node.expand();
|
||||||
|
expect(expandIcon.click).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tree.waitFor).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function generateTestNode(id: string, additionalProps?: Partial<TreeNode>): TreeNode {
|
function generateTestNode(id: string, additionalProps?: Partial<TreeNode>): TreeNode {
|
||||||
const node: TreeNode = {
|
const node: TreeNode = {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Spinner, SpinnerSize, TooltipHost } from "@fluentui/react";
|
import { ITooltipHost, Spinner, SpinnerSize, TooltipHost } from "@fluentui/react";
|
||||||
import { Menu, MenuItem, MenuList, MenuPopover, MenuTrigger } from "@fluentui/react-components";
|
import { Menu, MenuItem, MenuList, MenuPopover, MenuTrigger } from "@fluentui/react-components";
|
||||||
import { CollectionTabKind } from "Contracts/ViewModels";
|
import { CollectionTabKind } from "Contracts/ViewModels";
|
||||||
import Explorer from "Explorer/Explorer";
|
import Explorer from "Explorer/Explorer";
|
||||||
@@ -74,6 +74,7 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
|
|||||||
function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind?: ReactTabKind }) {
|
function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind?: ReactTabKind }) {
|
||||||
const [hovering, setHovering] = useState(false);
|
const [hovering, setHovering] = useState(false);
|
||||||
const focusTab = useRef<HTMLLIElement>() as MutableRefObject<HTMLLIElement>;
|
const focusTab = useRef<HTMLLIElement>() as MutableRefObject<HTMLLIElement>;
|
||||||
|
const tabTooltip = useRef<ITooltipHost>(null);
|
||||||
const tabId = tab ? tab.tabId : "";
|
const tabId = tab ? tab.tabId : "";
|
||||||
|
|
||||||
const getReactTabTitle = (): ko.Observable<string> => {
|
const getReactTabTitle = (): ko.Observable<string> => {
|
||||||
@@ -85,6 +86,7 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind?
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active && focusTab.current) {
|
if (active && focusTab.current) {
|
||||||
focusTab.current.focus();
|
focusTab.current.focus();
|
||||||
|
tabTooltip.current?.dismiss();
|
||||||
}
|
}
|
||||||
}, [active]);
|
}, [active]);
|
||||||
const liElement = (
|
const liElement = (
|
||||||
@@ -98,7 +100,7 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind?
|
|||||||
>
|
>
|
||||||
<span className="tabNavContentContainer">
|
<span className="tabNavContentContainer">
|
||||||
<div className="tab_Content">
|
<div className="tab_Content">
|
||||||
<TooltipHost content={useObservable(tab?.tabPath || ko.observable(""))}>
|
<TooltipHost componentRef={tabTooltip} content={useObservable(tab?.tabPath || ko.observable(""))}>
|
||||||
<span
|
<span
|
||||||
className="contentWrapper"
|
className="contentWrapper"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -1,11 +1,69 @@
|
|||||||
|
import { act, cleanup, fireEvent, render } from "@testing-library/react";
|
||||||
import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
|
import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
|
||||||
import * as ko from "knockout";
|
import * as ko from "knockout";
|
||||||
|
import React from "react";
|
||||||
import * as ViewModels from "../../Contracts/ViewModels";
|
import * as ViewModels from "../../Contracts/ViewModels";
|
||||||
import { updateUserContext } from "../../UserContext";
|
import { updateUserContext } from "../../UserContext";
|
||||||
import { useTabs } from "../../hooks/useTabs";
|
import { useTabs } from "../../hooks/useTabs";
|
||||||
import { container } from "../Controls/Settings/TestUtils";
|
import { container } from "../Controls/Settings/TestUtils";
|
||||||
import DocumentId from "../Tree/DocumentId";
|
import DocumentId from "../Tree/DocumentId";
|
||||||
import { NewQueryTab } from "./QueryTab/QueryTab";
|
import { NewQueryTab } from "./QueryTab/QueryTab";
|
||||||
|
import { Tabs } from "./Tabs";
|
||||||
|
import TabsBase from "./TabsBase";
|
||||||
|
|
||||||
|
describe("Tab path tooltips", () => {
|
||||||
|
const renderActiveTab = () => {
|
||||||
|
const tab = Object.assign(
|
||||||
|
new TabsBase({ tabKind: ViewModels.CollectionTabKind.Query, title: "Query 1", tabPath: "" }),
|
||||||
|
{ render: () => React.createElement("button", null, "Execute Query") },
|
||||||
|
);
|
||||||
|
tab.tabPath = ko.observable("t_34646986695_1_dbc3_1789160465183>testcontainer>Query 1");
|
||||||
|
useTabs.setState({ openedTabs: [tab], activeTab: tab });
|
||||||
|
return render(React.createElement(Tabs, { explorer: container }));
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
useTabs.setState({ openedTabs: [], openedReactTabs: [], activeTab: undefined, activeReactTab: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
jest.clearAllTimers();
|
||||||
|
jest.useRealTimers();
|
||||||
|
useTabs.setState({ openedTabs: [], activeTab: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses an activated tab without opening its path tooltip", () => {
|
||||||
|
const view = renderActiveTab();
|
||||||
|
expect(document.activeElement).toBe(view.getByRole("tab", { name: "Query 1" }));
|
||||||
|
|
||||||
|
act(() => jest.advanceTimersByTime(1000));
|
||||||
|
expect(document.querySelector(".ms-Tooltip")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["hover", "focus"])("keeps the path tooltip available on later %s and dismissible with Escape", (trigger) => {
|
||||||
|
const view = renderActiveTab();
|
||||||
|
const tab = view.getByRole("tab", { name: "Query 1" });
|
||||||
|
act(() => jest.advanceTimersByTime(1000));
|
||||||
|
|
||||||
|
if (trigger === "hover") {
|
||||||
|
fireEvent.mouseEnter(tab);
|
||||||
|
} else {
|
||||||
|
act(() => {
|
||||||
|
view.getByRole("button", { name: "Execute Query" }).focus();
|
||||||
|
tab.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
act(() => jest.advanceTimersByTime(1000));
|
||||||
|
expect(document.querySelector(".ms-Tooltip")?.textContent).toContain("testcontainer>Query 1");
|
||||||
|
|
||||||
|
fireEvent.keyDown(tab, { key: "Escape", keyCode: 27, which: 27 });
|
||||||
|
expect(document.querySelector(".ms-Tooltip")).toBeNull();
|
||||||
|
expect(document.activeElement).toBe(tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("useTabs tests", () => {
|
describe("useTabs tests", () => {
|
||||||
let database: ViewModels.Database;
|
let database: ViewModels.Database;
|
||||||
|
|||||||
+4
-3
@@ -118,6 +118,7 @@ function tryGetStandardName(accountType: TestAccount) {
|
|||||||
: `${process.env.DE_TEST_ACCOUNT_PREFIX}-`;
|
: `${process.env.DE_TEST_ACCOUNT_PREFIX}-`;
|
||||||
return `${actualPrefix}${accountType.toLocaleLowerCase()}`;
|
return `${actualPrefix}${accountType.toLocaleLowerCase()}`;
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maps a base API account type to its dedicated connection string (account key) account.
|
// Maps a base API account type to its dedicated connection string (account key) account.
|
||||||
@@ -328,12 +329,12 @@ class TreeNode {
|
|||||||
async expand(): Promise<void> {
|
async expand(): Promise<void> {
|
||||||
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
|
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
|
||||||
const tree = this.frame.getByTestId(`Tree:${this.id}`);
|
const tree = this.frame.getByTestId(`Tree:${this.id}`);
|
||||||
|
const expandIcon = this.element.locator(":scope > .fui-TreeItemLayout__expandIcon");
|
||||||
|
|
||||||
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
|
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
|
||||||
const expandNode = async () => {
|
const expandNode = async () => {
|
||||||
if ((await treeNodeContainer.getAttribute("aria-expanded")) !== "true") {
|
if ((await treeNodeContainer.getAttribute("aria-expanded")) !== "true") {
|
||||||
// Click the node, to trigger loading and expansion
|
await expandIcon.click();
|
||||||
await this.element.click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try three times to wait for the node to expand.
|
// Try three times to wait for the node to expand.
|
||||||
@@ -346,7 +347,7 @@ class TreeNode {
|
|||||||
// Just try again
|
// Just try again
|
||||||
if ((await treeNodeContainer.getAttribute("aria-expanded")) !== "true") {
|
if ((await treeNodeContainer.getAttribute("aria-expanded")) !== "true") {
|
||||||
// We might have collapsed the node, try expanding it again, then retry.
|
// We might have collapsed the node, try expanding it again, then retry.
|
||||||
await this.element.click();
|
await expandIcon.click();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
import { CommandBarButton, DataExplorer, TestAccount } from "../fx";
|
import { CommandBarButton, DataExplorer, generateUniqueName, TestAccount } from "../fx";
|
||||||
import { createTestSQLContainer, TestContainerContext } from "../testData";
|
import { createTestSQLContainer, TestContainerContext } from "../testData";
|
||||||
|
|
||||||
// Test container context for setup and cleanup
|
// Test container context for setup and cleanup
|
||||||
@@ -10,7 +10,10 @@ let CONTAINER_ID: string;
|
|||||||
|
|
||||||
// Set up test database and container with data before all tests
|
// Set up test database and container with data before all tests
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
testContainer = await createTestSQLContainer({ includeTestData: true });
|
testContainer = await createTestSQLContainer({
|
||||||
|
includeTestData: true,
|
||||||
|
databaseName: generateUniqueName("db_tab_tooltip_with_a_long_resource_name"),
|
||||||
|
});
|
||||||
DATABASE_ID = testContainer.database.id;
|
DATABASE_ID = testContainer.database.id;
|
||||||
CONTAINER_ID = testContainer.container.id;
|
CONTAINER_ID = testContainer.container.id;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user