diff --git a/src/Explorer/ContextMenuButtonFactory.duplicateTab.test.tsx b/src/Explorer/ContextMenuButtonFactory.duplicateTab.test.tsx new file mode 100644 index 000000000..fcaa2aeef --- /dev/null +++ b/src/Explorer/ContextMenuButtonFactory.duplicateTab.test.tsx @@ -0,0 +1,110 @@ +import { createCollectionContextMenuButton } from "Explorer/ContextMenuButtonFactory"; +import * as ko from "knockout"; +import * as ViewModels from "../../Contracts/ViewModels"; + +jest.mock("UserContext", () => ({ + userContext: { apiType: "SQL", fabricContext: undefined, features: {} }, +})); + +jest.mock("ConfigContext", () => ({ + configContext: { platform: "Hosted" }, + Platform: { Fabric: "Fabric" }, +})); + +jest.mock("Platform/Fabric/FabricUtil", () => ({ + isFabric: () => false, + isFabricNative: () => false, +})); + +jest.mock("Common/DatabaseAccountUtility", () => ({ + isGlobalSecondaryIndexEnabled: () => false, +})); + +jest.mock("Explorer/Notebook/useNotebook", () => ({ + useNotebook: { getState: jest.fn(() => ({ isShellEnabled: false })) }, +})); + +jest.mock("Explorer/useDatabases", () => ({ + useDatabases: { getState: jest.fn(() => ({ isPinned: () => false })) }, +})); + +jest.mock("hooks/useSidePanel", () => ({ + useSidePanel: { getState: jest.fn(() => ({ openSidePanel: jest.fn() })) }, +})); + +jest.mock("hooks/useTabs", () => ({ + useTabs: { getState: jest.fn() }, +})); + +jest.mock("Explorer/useSelectedNode", () => ({ + useSelectedNode: { getState: jest.fn(() => ({ setSelectedNode: jest.fn() })) }, +})); + +jest.mock("Platform/Hosted/extractFeatures", () => ({ + extractFeatures: () => ({}), +})); + +const { useTabs } = require("hooks/useTabs"); + +const mockContainer = {} as any; + +const mockCollection = { + id: ko.observable("testContainer"), + databaseId: "testDb", + partitionKey: { paths: ["/pk"], kind: "Hash", version: 2 }, + materializedViewDefinition: ko.observable(undefined), + onNewQueryClick: jest.fn(), + onNewStoredProcedureClick: jest.fn(), + onNewUserDefinedFunctionClick: jest.fn(), + onNewTriggerClick: jest.fn(), + onDocumentDBDocumentsClick: jest.fn(), +} as unknown as ViewModels.Collection; + +describe("createCollectionContextMenuButton - Duplicate tab", () => { + afterEach(() => jest.clearAllMocks()); + + it("includes a 'Duplicate tab' menu item", () => { + useTabs.getState.mockReturnValue({ activeTab: null }); + const items = createCollectionContextMenuButton(mockContainer, mockCollection); + const labels = items.map((i) => i.label); + expect(labels).toContain("Duplicate tab"); + }); + + it("calls duplicateTab() on the active tab when it belongs to this collection", () => { + const duplicateTab = jest.fn(); + const mockActiveTab = { + duplicateTab, + collection: mockCollection, + }; + useTabs.getState.mockReturnValue({ activeTab: mockActiveTab }); + + const items = createCollectionContextMenuButton(mockContainer, mockCollection); + const duplicateItem = items.find((i) => i.label === "Duplicate tab"); + duplicateItem.onClick(); + + expect(duplicateTab).toHaveBeenCalledTimes(1); + }); + + it("opens a new Items tab when no active tab belongs to this collection", () => { + useTabs.getState.mockReturnValue({ activeTab: null }); + + const items = createCollectionContextMenuButton(mockContainer, mockCollection); + const duplicateItem = items.find((i) => i.label === "Duplicate tab"); + duplicateItem.onClick(); + + expect(mockCollection.onDocumentDBDocumentsClick).toHaveBeenCalledTimes(1); + }); + + it("opens a new Items tab when the active tab belongs to a different collection", () => { + const otherCollection = { ...mockCollection, id: ko.observable("other") } as unknown as ViewModels.Collection; + useTabs.getState.mockReturnValue({ + activeTab: { duplicateTab: jest.fn(), collection: otherCollection }, + }); + + const items = createCollectionContextMenuButton(mockContainer, mockCollection); + const duplicateItem = items.find((i) => i.label === "Duplicate tab"); + duplicateItem.onClick(); + + expect(mockCollection.onDocumentDBDocumentsClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/Explorer/ContextMenuButtonFactory.tsx b/src/Explorer/ContextMenuButtonFactory.tsx index 59c49e5ac..518b3e0e1 100644 --- a/src/Explorer/ContextMenuButtonFactory.tsx +++ b/src/Explorer/ContextMenuButtonFactory.tsx @@ -15,6 +15,7 @@ import AddSqlQueryIcon from "../../images/AddSqlQuery_16x16.svg"; import AddStoredProcedureIcon from "../../images/AddStoredProcedure.svg"; import AddTriggerIcon from "../../images/AddTrigger.svg"; import AddUdfIcon from "../../images/AddUdf.svg"; +import CopyIcon from "../../images/Copy.svg"; import DeleteCollectionIcon from "../../images/DeleteCollection.svg"; import DeleteDatabaseIcon from "../../images/DeleteDatabase.svg"; import DeleteSprocIcon from "../../images/DeleteSproc.svg"; @@ -27,6 +28,7 @@ import { extractFeatures } from "../Platform/Hosted/extractFeatures"; import { userContext } from "../UserContext"; import { getCollectionName, getDatabaseName } from "../Utils/APITypeUtils"; import { useSidePanel } from "../hooks/useSidePanel"; +import { useTabs } from "../hooks/useTabs"; import Explorer from "./Explorer"; import { useNotebook } from "./Notebook/useNotebook"; import { DeleteCollectionConfirmationPane } from "./Panes/DeleteCollectionConfirmationPane/DeleteCollectionConfirmationPane"; @@ -175,6 +177,22 @@ export const createCollectionContextMenuButton = ( }); } + items.push({ + iconSrc: CopyIcon, + onClick: () => { + const activeTab = useTabs.getState().activeTab; + if ( + activeTab?.collection?.databaseId === selectedCollection.databaseId && + activeTab?.collection?.id() === selectedCollection.id() + ) { + activeTab.duplicateTab(); + } else { + selectedCollection.onDocumentDBDocumentsClick(); + } + }, + label: t(Keys.contextMenu.duplicateTab), + }); + if (!isFabric() || (isFabric() && !userContext.fabricContext?.isReadOnly)) { items.push({ iconSrc: DeleteCollectionIcon, 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..64b575150 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx @@ -1,20 +1,20 @@ import { Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos"; import { - Button, - Link, - MessageBar, - MessageBarBody, - MessageBarTitle, - TableRowId, - makeStyles, - shorthands, + Button, + Link, + MessageBar, + MessageBarBody, + MessageBarTitle, + TableRowId, + makeStyles, + shorthands, } from "@fluentui/react-components"; import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils"; import MongoUtility from "Common/MongoUtility"; import { createDocument } from "Common/dataAccess/createDocument"; import { - deleteDocument as deleteNoSqlDocument, - deleteDocuments as deleteNoSqlDocuments, + deleteDocument as deleteNoSqlDocument, + deleteDocuments as deleteNoSqlDocuments, } from "Common/dataAccess/deleteDocument"; import { queryDocuments } from "Common/dataAccess/queryDocuments"; import { readDocument } from "Common/dataAccess/readDocument"; @@ -28,13 +28,13 @@ import { ProgressModalDialog } from "Explorer/Controls/ProgressModalDialog"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; import { - ColumnsSelection, - FilterHistory, - SubComponentName, - TabDivider, - deleteDocumentsTabSubComponentState, - readDocumentsTabSubComponentState, - saveDocumentsTabSubComponentState, + ColumnsSelection, + FilterHistory, + SubComponentName, + TabDivider, + deleteDocumentsTabSubComponentState, + readDocumentsTabSubComponentState, + saveDocumentsTabSubComponentState, } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil"; import { usePrevious } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper"; import { CosmosFluentProvider, LayoutConstants, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil"; @@ -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,21 @@ export class DocumentsTabV2 extends TabsBase { }; } + 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 ( ({ + 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 any }; + +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..8193783b8 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,26 @@ export class NewQueryTab extends TabsBase { }); } + 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/Tabs.tsx b/src/Explorer/Tabs/Tabs.tsx index e0fbccd76..fff3118b3 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"; @@ -86,13 +87,15 @@ 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" - > + + +
  • setHovering(true)} + onMouseLeave={() => setHovering(false)} + className={active ? "active tabList" : "tabList"} + style={active ? { fontWeight: "bolder" } : {}} + role="presentation" + >
    @@ -153,7 +156,23 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind?
    -
  • +
  • + + + + {tab && ( + tab.duplicateTab()}>Duplicate tab + )} + { + tab ? tab.onCloseTabButtonClick() : useTabs.getState().closeReactTab(tabKind); + }} + > + Close tab + + + + ); } diff --git a/src/Explorer/Tabs/TabsBase.ts b/src/Explorer/Tabs/TabsBase.ts index 2602b672d..ad627093b 100644 --- a/src/Explorer/Tabs/TabsBase.ts +++ b/src/Explorer/Tabs/TabsBase.ts @@ -64,6 +64,10 @@ export default class TabsBase extends WaitsForTemplateViewModel { public getPersistedState = (): OpenTab | null => this.persistedState; public triggerPersistState: () => void = undefined; + 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..a20e86610 100644 --- a/src/Localization/en/Resources.json +++ b/src/Localization/en/Resources.json @@ -313,7 +313,8 @@ "newTrigger": "New Trigger", "deleteStoredProcedure": "Delete Stored Procedure", "deleteTrigger": "Delete Trigger", - "deleteUdf": "Delete User Defined Function" + "deleteUdf": "Delete User Defined Function", + "duplicateTab": "Duplicate tab" }, "tabs": { "documents": {