From 974ca3df0b082eb163ca342ecfcfab379b63bf8f Mon Sep 17 00:00:00 2001 From: Bikram Choudhury Date: Thu, 14 May 2026 23:03:17 +0530 Subject: [PATCH] feat: Add "Duplicate Tab" support for Items, Query, and Settings tabs --- .../DocumentsTabV2.duplicateTab.test.tsx | 85 ++++++++++ .../Tabs/DocumentsTabV2/DocumentsTabV2.tsx | 21 +++ .../Tabs/MongoQueryTab/MongoQueryTab.tsx | 24 +++ .../QueryTab/QueryTab.duplicateTab.test.tsx | 104 ++++++++++++ src/Explorer/Tabs/QueryTab/QueryTab.tsx | 25 +++ src/Explorer/Tabs/SettingsTabV2.tsx | 16 ++ src/Explorer/Tabs/Tabs.tsx | 156 ++++++++++-------- src/Explorer/Tabs/TabsBase.ts | 8 + src/Localization/en/Resources.json | 4 + 9 files changed, 377 insertions(+), 66 deletions(-) create mode 100644 src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.duplicateTab.test.tsx create mode 100644 src/Explorer/Tabs/QueryTab/QueryTab.duplicateTab.test.tsx 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..7641f0a41 --- /dev/null +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.duplicateTab.test.tsx @@ -0,0 +1,85 @@ +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); + }); +}); diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx index 8f2c252d0..14645d878 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: "Items", + 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..2adb8017b 100644 --- a/src/Explorer/Tabs/QueryTab/QueryTab.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryTab.tsx @@ -4,6 +4,7 @@ import { MessageTypes } from "Contracts/MessageTypes"; import React from "react"; import * as DataModels from "../../../Contracts/DataModels"; import type { QueryTabOptions } from "../../../Contracts/ViewModels"; +import * as ViewModels from "../../../Contracts/ViewModels"; import { useTabs } from "../../../hooks/useTabs"; import Explorer from "../../Explorer"; import { IQueryTabComponentProps, ITabAccessor, QueryTabComponent } from "../../Tabs/QueryTab/QueryTabComponent"; @@ -72,6 +73,30 @@ export class NewQueryTab extends TabsBase { }); } + public canDuplicate(): boolean { + return true; + } + + public duplicateTab(): void { + const queryText = 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/SettingsTabV2.tsx b/src/Explorer/Tabs/SettingsTabV2.tsx index f87290d91..afb29d774 100644 --- a/src/Explorer/Tabs/SettingsTabV2.tsx +++ b/src/Explorer/Tabs/SettingsTabV2.tsx @@ -1,6 +1,7 @@ import { ActionType, OpenCollectionTab, TabKind } from "Contracts/ActionContracts"; import React from "react"; import * as ViewModels from "../../Contracts/ViewModels"; +import { useTabs } from "../../hooks/useTabs"; import { SettingsComponent } from "../Controls/Settings/SettingsComponent"; import TabsBase from "./TabsBase"; @@ -23,6 +24,21 @@ export class CollectionSettingsTabV2 extends SettingsTabV2 { }; } + public canDuplicate(): boolean { + return true; + } + + public duplicateTab(): void { + const newTab = new CollectionSettingsTabV2({ + tabKind: ViewModels.CollectionTabKind.CollectionSettingsV2, + title: this.tabTitle(), + tabPath: "", + collection: this.collection, + node: this.collection, + }); + useTabs.getState().activateNewTab(newTab); + } + public onActivate(): void { super.onActivate(); this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.CollectionSettingsV2); diff --git a/src/Explorer/Tabs/Tabs.tsx b/src/Explorer/Tabs/Tabs.tsx index e0fbccd76..6c97c7cbf 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"; @@ -86,74 +88,96 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind? } }, [active]); return ( -
  • setHovering(true)} - onMouseLeave={() => setHovering(false)} - className={active ? "active tabList" : "tabList"} - style={active ? { fontWeight: "bolder" } : {}} - role="presentation" - > - -
    - - { - if (tab) { - tab.onTabClick(); - } else if (tabKind !== undefined) { - useTabs.getState().activateReactTab(tabKind); - } - }} - onKeyPress={({ nativeEvent: e }) => { - if (tab) { - tab.onKeyPressActivate(undefined, e); - } else if (tabKind !== undefined) { - onKeyPressReactTab(e, tabKind); - } - }} - aria-selected={active} - aria-expanded={active} - aria-controls={tabId} - tabIndex={0} - role="tab" - ref={focusTab} - > - - {useObservable(tab?.isExecutionError || ko.observable(false)) && ( - - )} - {useObservable(tab?.isExecutionWarning || ko.observable(false)) && ( - - )} - {isTabExecuting(tab, tabKind) && ( - - )} - {isQueryErrorThrown(tab, tabKind) && ( - - Error - - )} + + +
  • setHovering(true)} + onMouseLeave={() => setHovering(false)} + className={active ? "active tabList" : "tabList"} + style={active ? { fontWeight: "bolder" } : {}} + role="presentation" + > + +
    + + { + if (tab) { + tab.onTabClick(); + } else if (tabKind !== undefined) { + useTabs.getState().activateReactTab(tabKind); + } + }} + onKeyPress={({ nativeEvent: e }) => { + if (tab) { + tab.onKeyPressActivate(undefined, e); + } else if (tabKind !== undefined) { + onKeyPressReactTab(e, tabKind); + } + }} + aria-selected={active} + aria-expanded={active} + aria-controls={tabId} + tabIndex={0} + role="tab" + ref={focusTab} + > + + {useObservable(tab?.isExecutionError || ko.observable(false)) && ( + + )} + {useObservable(tab?.isExecutionWarning || ko.observable(false)) && ( + + )} + {isTabExecuting(tab, tabKind) && ( + + )} + {isQueryErrorThrown(tab, tabKind) && ( + + Error + + )} + + {tabTitle} + + + + - {tabTitle} - - - - +
    -
  • -
    -
  • + + + + + {tab?.canDuplicate() && ( + tab.duplicateTab()}>{t(Keys.tabs.tabMenu.duplicateTab)} + )} + { + tab ? tab.onCloseTabButtonClick() : useTabs.getState().closeReactTab(tabKind); + }} + > + {t(Keys.tabs.tabMenu.closeTab)} + + + + ); } 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 2faa9a7b4..e49699c8b 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",