From 481e5cde3ed02a99577228aa1182d01c857e425d Mon Sep 17 00:00:00 2001 From: BChoudhury-ms Date: Wed, 27 May 2026 15:54:45 +0530 Subject: [PATCH] feat: Add "Duplicate Tab" support for Items, Query, and Settings tabs (#2498) * feat: Add "Duplicate Tab" support for Items, Query, and Settings tabs * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * added UT * Fix QueryTab useTabs import path for duplicate tab test mock --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../DocumentsTabV2.duplicateTab.test.tsx | 96 ++++++++++++++++ .../Tabs/DocumentsTabV2/DocumentsTabV2.tsx | 21 ++++ .../Tabs/MongoQueryTab/MongoQueryTab.tsx | 25 +++++ .../QueryTab/QueryTab.duplicateTab.test.tsx | 104 +++++++++++++++++ src/Explorer/Tabs/QueryTab/QueryTab.tsx | 27 ++++- src/Explorer/Tabs/Tabs.tsx | 21 +++- src/Explorer/Tabs/TabsBase.ts | 8 ++ src/Localization/en/Resources.json | 4 + test/fx.ts | 10 ++ test/sql/duplicateTab.spec.ts | 105 ++++++++++++++++++ 10 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.duplicateTab.test.tsx create mode 100644 src/Explorer/Tabs/QueryTab/QueryTab.duplicateTab.test.tsx create mode 100644 test/sql/duplicateTab.spec.ts diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.duplicateTab.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.duplicateTab.test.tsx new file mode 100644 index 000000000..673d6e828 --- /dev/null +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.duplicateTab.test.tsx @@ -0,0 +1,96 @@ +import { useTabs } from "hooks/useTabs"; +import * as ko from "knockout"; +import * as ViewModels from "../../../Contracts/ViewModels"; +import { DocumentsTabV2 } from "./DocumentsTabV2"; + +jest.mock("hooks/useTabs", () => ({ + useTabs: { + getState: jest.fn(), + }, +})); + +jest.mock("UserContext", () => ({ + userContext: { apiType: "SQL" }, +})); + +jest.mock("Explorer/Menus/CommandBar/CommandBarComponentAdapter", () => ({ + useCommandBar: { getState: jest.fn(() => ({ setContextButtons: jest.fn() })) }, +})); + +jest.mock("Explorer/Controls/Editor/EditorReact", () => ({ + EditorReact: () => null, +})); + +const mockCollection = { + id: ko.observable("testContainer"), + databaseId: "testDb", + partitionKey: { paths: ["/pk"], kind: "Hash", version: 2 }, + selectedSubnodeKind: jest.fn(), + container: {}, +} as unknown as ViewModels.Collection; + +const buildTab = () => + new DocumentsTabV2({ + partitionKey: mockCollection.partitionKey, + documentIds: ko.observableArray([]), + tabKind: ViewModels.CollectionTabKind.Documents, + title: "Items", + collection: mockCollection, + node: mockCollection, + tabPath: "testDb>testContainer>Documents", + }); + +describe("DocumentsTabV2.duplicateTab", () => { + let activateNewTab: jest.Mock; + + beforeEach(() => { + activateNewTab = jest.fn(); + (useTabs.getState as jest.Mock).mockReturnValue({ activateNewTab }); + }); + + afterEach(() => jest.clearAllMocks()); + + it("calls activateNewTab with a new DocumentsTabV2 instance", () => { + const tab = buildTab(); + tab.duplicateTab(); + + expect(activateNewTab).toHaveBeenCalledTimes(1); + const newTab = activateNewTab.mock.calls[0][0]; + expect(newTab).toBeInstanceOf(DocumentsTabV2); + }); + + it("creates a duplicate with the same collection", () => { + const tab = buildTab(); + tab.duplicateTab(); + + const newTab = activateNewTab.mock.calls[0][0] as DocumentsTabV2; + expect(newTab.collection).toBe(mockCollection); + }); + + it("creates a duplicate with the same partitionKey", () => { + const tab = buildTab(); + tab.duplicateTab(); + + const newTab = activateNewTab.mock.calls[0][0] as DocumentsTabV2; + expect(newTab.partitionKey).toEqual(mockCollection.partitionKey); + }); + + it("creates a distinct tab instance", () => { + const tab = buildTab(); + tab.duplicateTab(); + + const newTab = activateNewTab.mock.calls[0][0]; + expect(newTab).not.toBe(tab); + }); + + it("preserves the raw title (not the display title) to avoid double-prefixing", () => { + const tab = buildTab(); + tab.duplicateTab(); + + const newTab = activateNewTab.mock.calls[0][0] as DocumentsTabV2; + // The original tabTitle() is "testC…Items" (collection "testContainer" is > 8 chars so it's truncated). + // If duplicateTab() incorrectly used tabTitle() as the new title, the duplicate's tabTitle() + // would double-prefix to "testC…testC…Items". Using the raw title "Items" keeps it "testC…Items". + expect(newTab.tabTitle()).toBe("testC\u2026Items"); + }); +}); diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx index 8f2c252d0..5d9f2bb37 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx @@ -49,6 +49,8 @@ import { userContext } from "UserContext"; import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils"; import { Allotment } from "allotment"; import { useClientWriteEnabled } from "hooks/useClientWriteEnabled"; +import { useTabs } from "hooks/useTabs"; +import ko from "knockout"; import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { format } from "react-string-format"; import DeleteDocumentIcon from "../../../../images/DeleteDocument.svg"; @@ -176,6 +178,25 @@ export class DocumentsTabV2 extends TabsBase { }; } + public canDuplicate(): boolean { + return true; + } + + public duplicateTab(): void { + const newTab = new DocumentsTabV2({ + partitionKey: this.partitionKey, + documentIds: ko.observableArray([]), + tabKind: ViewModels.CollectionTabKind.Documents, + title: this.title, + collection: this.collection, + node: this.collection, + tabPath: `${this.collection.databaseId}>${this.collection.id()}>Documents`, + isPreferredApiMongoDB: userContext.apiType === "Mongo", + resourceTokenPartitionKey: this.resourceTokenPartitionKey, + }); + useTabs.getState().activateNewTab(newTab); + } + public render(): JSX.Element { return ( ; } diff --git a/src/Explorer/Tabs/QueryTab/QueryTab.duplicateTab.test.tsx b/src/Explorer/Tabs/QueryTab/QueryTab.duplicateTab.test.tsx new file mode 100644 index 000000000..59b712f45 --- /dev/null +++ b/src/Explorer/Tabs/QueryTab/QueryTab.duplicateTab.test.tsx @@ -0,0 +1,104 @@ +import { useTabs } from "hooks/useTabs"; +import * as ko from "knockout"; +import * as ViewModels from "../../../Contracts/ViewModels"; +import { IQueryTabProps, NewQueryTab } from "./QueryTab"; + +jest.mock("hooks/useTabs", () => ({ + useTabs: { + getState: jest.fn(), + }, +})); + +jest.mock("Explorer/Menus/CommandBar/CommandBarComponentAdapter", () => ({ + useCommandBar: { getState: jest.fn(() => ({ setContextButtons: jest.fn() })) }, +})); + +jest.mock("Shared/AppStatePersistenceUtility", () => ({ + loadState: jest.fn(), + AppStateComponentNames: {}, + readSubComponentState: jest.fn(), +})); + +jest.mock("Common/MessageHandler", () => ({ + sendMessage: jest.fn(), +})); + +const mockCollection = { + id: ko.observable("testContainer"), + databaseId: "testDb", + partitionKey: { paths: ["/pk"], kind: "Hash", version: 2 }, + selectedSubnodeKind: jest.fn(), + container: {}, +} as unknown as ViewModels.Collection; + +const mockProps = { container: {} as IQueryTabProps["container"] }; + +const buildTab = (queryText = "SELECT * FROM c") => + new NewQueryTab( + { + tabKind: ViewModels.CollectionTabKind.Query, + title: "Query 1", + tabPath: "", + collection: mockCollection, + node: mockCollection, + queryText, + partitionKey: mockCollection.partitionKey, + }, + mockProps, + ); + +describe("NewQueryTab.duplicateTab", () => { + let activateNewTab: jest.Mock; + let getTabs: jest.Mock; + + beforeEach(() => { + activateNewTab = jest.fn(); + getTabs = jest.fn().mockReturnValue([]); + (useTabs.getState as jest.Mock).mockReturnValue({ activateNewTab, getTabs }); + }); + + afterEach(() => jest.clearAllMocks()); + + it("calls activateNewTab with a new NewQueryTab instance", () => { + const tab = buildTab(); + tab.duplicateTab(); + + expect(activateNewTab).toHaveBeenCalledTimes(1); + const newTab = activateNewTab.mock.calls[0][0]; + expect(newTab).toBeInstanceOf(NewQueryTab); + }); + + it("preserves the current query text in the duplicate", () => { + const queryText = "SELECT * FROM c WHERE c.id = '123'"; + const tab = buildTab(queryText); + tab.duplicateTab(); + + const newTab = activateNewTab.mock.calls[0][0] as NewQueryTab; + expect(newTab.iQueryTabComponentProps.queryText).toBe(queryText); + }); + + it("creates a duplicate with the same collection", () => { + const tab = buildTab(); + tab.duplicateTab(); + + const newTab = activateNewTab.mock.calls[0][0] as NewQueryTab; + expect(newTab.collection).toBe(mockCollection); + }); + + it("creates a distinct tab instance", () => { + const tab = buildTab(); + tab.duplicateTab(); + + const newTab = activateNewTab.mock.calls[0][0]; + expect(newTab).not.toBe(tab); + }); + + it("assigns an auto-incremented title based on existing query tabs", () => { + getTabs.mockReturnValue([{}, {}]); // 2 existing tabs → new title = "Query 3" + const tab = buildTab(); + tab.duplicateTab(); + + const newTab = activateNewTab.mock.calls[0][0] as NewQueryTab; + expect(newTab.tabTitle()).toContain("Query 3"); + }); +}); diff --git a/src/Explorer/Tabs/QueryTab/QueryTab.tsx b/src/Explorer/Tabs/QueryTab/QueryTab.tsx index e504d601e..61013a113 100644 --- a/src/Explorer/Tabs/QueryTab/QueryTab.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryTab.tsx @@ -4,7 +4,8 @@ import { MessageTypes } from "Contracts/MessageTypes"; import React from "react"; import * as DataModels from "../../../Contracts/DataModels"; import type { QueryTabOptions } from "../../../Contracts/ViewModels"; -import { useTabs } from "../../../hooks/useTabs"; +import * as ViewModels from "../../../Contracts/ViewModels"; +import { useTabs } from "hooks/useTabs"; import Explorer from "../../Explorer"; import { IQueryTabComponentProps, ITabAccessor, QueryTabComponent } from "../../Tabs/QueryTab/QueryTabComponent"; import TabsBase from "../TabsBase"; @@ -72,6 +73,30 @@ export class NewQueryTab extends TabsBase { }); } + public canDuplicate(): boolean { + return true; + } + + public duplicateTab(): void { + const queryText = this.iTabAccessor?.onSaveClickEvent() ?? this.persistedState?.query?.text ?? ""; + const id = useTabs.getState().getTabs(ViewModels.CollectionTabKind.Query).length + 1; + const newTab = new NewQueryTab( + { + tabKind: ViewModels.CollectionTabKind.Query, + title: `Query ${id}`, + tabPath: "", + collection: this.collection, + node: this.collection, + queryText, + partitionKey: this.partitionKey, + splitterDirection: this.persistedState?.splitterDirection, + queryViewSizePercent: this.persistedState?.queryViewSizePercent, + }, + this.props, + ); + useTabs.getState().activateNewTab(newTab); + } + public render(): JSX.Element { return ; } diff --git a/src/Explorer/Tabs/Tabs.tsx b/src/Explorer/Tabs/Tabs.tsx index e0fbccd76..54fefedd1 100644 --- a/src/Explorer/Tabs/Tabs.tsx +++ b/src/Explorer/Tabs/Tabs.tsx @@ -1,4 +1,5 @@ import { Spinner, SpinnerSize, TooltipHost } from "@fluentui/react"; +import { Menu, MenuItem, MenuList, MenuPopover, MenuTrigger } from "@fluentui/react-components"; import { CollectionTabKind } from "Contracts/ViewModels"; import Explorer from "Explorer/Explorer"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; @@ -10,6 +11,7 @@ import { QuickstartTab } from "Explorer/Tabs/QuickstartTab"; import { VcoreMongoConnectTab } from "Explorer/Tabs/VCoreMongoConnectTab"; import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab"; import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts"; +import { Keys, t } from "Localization"; import { isFabricNative } from "Platform/Fabric/FabricUtil"; import { userContext } from "UserContext"; import { useTeachingBubble } from "hooks/useTeachingBubble"; @@ -85,8 +87,9 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind? focusTab.current.focus(); } }, [active]); - return ( + const liElement = (
  • setHovering(true)} onMouseLeave={() => setHovering(false)} className={active ? "active tabList" : "tabList"} @@ -155,6 +158,22 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind?
  • ); + + if (!tab?.canDuplicate()) { + return liElement; + } + + return ( + + {liElement} + + + tab.duplicateTab()}>{t(Keys.tabs.tabMenu.duplicateTab)} + tab.onCloseTabButtonClick()}>{t(Keys.tabs.tabMenu.closeTab)} + + + + ); } const onKeyPressReactTabClose = (e: KeyboardEvent, tabKind: ReactTabKind): void => { diff --git a/src/Explorer/Tabs/TabsBase.ts b/src/Explorer/Tabs/TabsBase.ts index 2602b672d..40e79b531 100644 --- a/src/Explorer/Tabs/TabsBase.ts +++ b/src/Explorer/Tabs/TabsBase.ts @@ -64,6 +64,14 @@ export default class TabsBase extends WaitsForTemplateViewModel { public getPersistedState = (): OpenTab | null => this.persistedState; public triggerPersistState: () => void = undefined; + public canDuplicate(): boolean { + return false; + } + + public duplicateTab(): void { + // Subclasses override this to support tab duplication + } + public onCloseTabButtonClick(): void { useTabs.getState().closeTab(this); TelemetryProcessor.trace(Action.Tab, ActionModifiers.Close, { diff --git a/src/Localization/en/Resources.json b/src/Localization/en/Resources.json index 26fb72021..6ac748936 100644 --- a/src/Localization/en/Resources.json +++ b/src/Localization/en/Resources.json @@ -316,6 +316,10 @@ "deleteUdf": "Delete User Defined Function" }, "tabs": { + "tabMenu": { + "duplicateTab": "Duplicate tab", + "closeTab": "Close tab" + }, "documents": { "newItem": "New Item", "newDocument": "New Document", diff --git a/test/fx.ts b/test/fx.ts index d65d776b1..7c8dcbd3f 100644 --- a/test/fx.ts +++ b/test/fx.ts @@ -616,6 +616,16 @@ export class DataExplorer { await page.goto(url); return DataExplorer.waitForExplorer(page); } + + /** Returns the tab navigation
  • element for the given tab ID or React tab name (e.g. "tab0", "Home") */ + tabNavHeader(tabId: string): Locator { + return this.frame.getByTestId(`TabNav:${tabId}`); + } + + /** Returns a context menu item in the tab right-click menu by visible label */ + tabContextMenuItem(label: string): Locator { + return this.frame.getByRole("menuitem", { name: label }); + } } export async function waitForApiResponse( diff --git a/test/sql/duplicateTab.spec.ts b/test/sql/duplicateTab.spec.ts new file mode 100644 index 000000000..e91322e0f --- /dev/null +++ b/test/sql/duplicateTab.spec.ts @@ -0,0 +1,105 @@ +import { expect, test } from "@playwright/test"; + +import { DataExplorer, TestAccount } from "../fx"; +import { createTestSQLContainer, TestContainerContext } from "../testData"; + +let context: TestContainerContext = null!; +let explorer: DataExplorer = null!; + +test.beforeAll("Create Test Database", async () => { + context = await createTestSQLContainer({ includeTestData: false }); +}); + +test.afterAll("Delete Test Database", async () => { + await context?.dispose(); +}); + +test.beforeEach("Open Data Explorer", async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); +}); + +test("Duplicate Items tab opens a second Items tab", async () => { + // Open Items tab + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.expand(); + const itemsNode = await explorer.waitForContainerItemsNode(context.database.id, context.container.id); + await itemsNode.element.click(); + + const documentsTab = explorer.documentsTab("tab0"); + await documentsTab.documentsFilter.waitFor({ timeout: 30_000 }); + + // Right-click the tab nav header + await explorer.tabNavHeader("tab0").click({ button: "right" }); + + // "Duplicate tab" should be visible in the context menu + const duplicateMenuItem = explorer.tabContextMenuItem("Duplicate tab"); + await expect(duplicateMenuItem).toBeVisible(); + await duplicateMenuItem.click(); + + // A second tab should appear + const tab1 = explorer.tab("tab1"); + await expect(tab1).toBeAttached({ timeout: 30_000 }); + + // The duplicated tab should also show the Documents content + const duplicatedTab = explorer.documentsTab("tab1"); + await duplicatedTab.documentsFilter.waitFor({ timeout: 30_000 }); +}); + +test("Duplicate Query tab preserves query text in new tab", async () => { + // Open a new SQL query tab via container context menu + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.openContextMenu(); + await containerNode.contextMenuItem("New SQL Query").click(); + + const queryTab = explorer.queryTab("tab0"); + const editor = queryTab.editor(); + await editor.locator.waitFor({ timeout: 30_000 }); + + // Type a custom query + const customQuery = 'SELECT * FROM c WHERE c.id = "duplicate-query-test"'; + await editor.setText(customQuery); + + // Right-click the tab nav header + await explorer.tabNavHeader("tab0").click({ button: "right" }); + + const duplicateMenuItem = explorer.tabContextMenuItem("Duplicate tab"); + await expect(duplicateMenuItem).toBeVisible(); + await duplicateMenuItem.click(); + + // Second query tab should appear + const tab1 = explorer.tab("tab1"); + await expect(tab1).toBeAttached({ timeout: 30_000 }); + + // The duplicated tab should contain the same query text + const duplicatedQueryTab = explorer.queryTab("tab1"); + await duplicatedQueryTab.editor().locator.waitFor({ timeout: 30_000 }); + const editorText = await duplicatedQueryTab.editor().text(); + expect(editorText).toContain("duplicate-query-test"); +}); + +test("Right-click context menu does not appear for the Home tab", async () => { + // The Home tab (ReactTabKind) is never duplicable — no context menu should appear + await explorer.tabNavHeader("Home").click({ button: "right" }); + + // Neither menu item should be visible + await expect(explorer.tabContextMenuItem("Duplicate tab")).not.toBeVisible(); + await expect(explorer.tabContextMenuItem("Close tab")).not.toBeVisible(); +}); + +test("Close tab from right-click menu closes the tab", async () => { + // Open Items tab + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.expand(); + const itemsNode = await explorer.waitForContainerItemsNode(context.database.id, context.container.id); + await itemsNode.element.click(); + + const documentsTab = explorer.documentsTab("tab0"); + await documentsTab.documentsFilter.waitFor({ timeout: 30_000 }); + + // Right-click the tab nav header and close the tab + await explorer.tabNavHeader("tab0").click({ button: "right" }); + await explorer.tabContextMenuItem("Close tab").click(); + + // The tab pane should be removed + await expect(explorer.tab("tab0")).not.toBeAttached({ timeout: 15_000 }); +});