duplicate tab changes

This commit is contained in:
Bikram Choudhury
2026-04-28 15:49:11 +05:30
parent 98eb31da7e
commit d922866d38
9 changed files with 405 additions and 26 deletions
@@ -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<string>("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);
});
});
+18
View File
@@ -15,6 +15,7 @@ import AddSqlQueryIcon from "../../images/AddSqlQuery_16x16.svg";
import AddStoredProcedureIcon from "../../images/AddStoredProcedure.svg"; import AddStoredProcedureIcon from "../../images/AddStoredProcedure.svg";
import AddTriggerIcon from "../../images/AddTrigger.svg"; import AddTriggerIcon from "../../images/AddTrigger.svg";
import AddUdfIcon from "../../images/AddUdf.svg"; import AddUdfIcon from "../../images/AddUdf.svg";
import CopyIcon from "../../images/Copy.svg";
import DeleteCollectionIcon from "../../images/DeleteCollection.svg"; import DeleteCollectionIcon from "../../images/DeleteCollection.svg";
import DeleteDatabaseIcon from "../../images/DeleteDatabase.svg"; import DeleteDatabaseIcon from "../../images/DeleteDatabase.svg";
import DeleteSprocIcon from "../../images/DeleteSproc.svg"; import DeleteSprocIcon from "../../images/DeleteSproc.svg";
@@ -27,6 +28,7 @@ import { extractFeatures } from "../Platform/Hosted/extractFeatures";
import { userContext } from "../UserContext"; import { userContext } from "../UserContext";
import { getCollectionName, getDatabaseName } from "../Utils/APITypeUtils"; import { getCollectionName, getDatabaseName } from "../Utils/APITypeUtils";
import { useSidePanel } from "../hooks/useSidePanel"; import { useSidePanel } from "../hooks/useSidePanel";
import { useTabs } from "../hooks/useTabs";
import Explorer from "./Explorer"; import Explorer from "./Explorer";
import { useNotebook } from "./Notebook/useNotebook"; import { useNotebook } from "./Notebook/useNotebook";
import { DeleteCollectionConfirmationPane } from "./Panes/DeleteCollectionConfirmationPane/DeleteCollectionConfirmationPane"; 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)) { if (!isFabric() || (isFabric() && !userContext.fabricContext?.isReadOnly)) {
items.push({ items.push({
iconSrc: DeleteCollectionIcon, iconSrc: DeleteCollectionIcon,
@@ -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<string>("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);
});
});
@@ -49,6 +49,8 @@ import { userContext } from "UserContext";
import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils"; import { logConsoleError, logConsoleInfo } from "Utils/NotificationConsoleUtils";
import { Allotment } from "allotment"; import { Allotment } from "allotment";
import { useClientWriteEnabled } from "hooks/useClientWriteEnabled"; 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 React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { format } from "react-string-format"; import { format } from "react-string-format";
import DeleteDocumentIcon from "../../../../images/DeleteDocument.svg"; 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 { public render(): JSX.Element {
return ( return (
<DocumentsTabComponent <DocumentsTabComponent
@@ -0,0 +1,104 @@
import { useTabs } from "hooks/useTabs";
import * as ko from "knockout";
import * as ViewModels from "../../../Contracts/ViewModels";
import { 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<string>("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");
});
});
+21
View File
@@ -4,6 +4,7 @@ import { MessageTypes } from "Contracts/MessageTypes";
import React from "react"; import React from "react";
import * as DataModels from "../../../Contracts/DataModels"; import * as DataModels from "../../../Contracts/DataModels";
import type { QueryTabOptions } from "../../../Contracts/ViewModels"; import type { QueryTabOptions } from "../../../Contracts/ViewModels";
import * as ViewModels from "../../../Contracts/ViewModels";
import { useTabs } from "../../../hooks/useTabs"; import { useTabs } from "../../../hooks/useTabs";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { IQueryTabComponentProps, ITabAccessor, QueryTabComponent } from "../../Tabs/QueryTab/QueryTabComponent"; 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 { public render(): JSX.Element {
return <QueryTabComponent {...this.iQueryTabComponentProps} />; return <QueryTabComponent {...this.iQueryTabComponentProps} />;
} }
+19
View File
@@ -1,4 +1,5 @@
import { Spinner, SpinnerSize, TooltipHost } from "@fluentui/react"; import { Spinner, SpinnerSize, TooltipHost } from "@fluentui/react";
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";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
@@ -86,6 +87,8 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind?
} }
}, [active]); }, [active]);
return ( return (
<Menu openOnContext>
<MenuTrigger disableButtonEnhancement>
<li <li
onMouseOver={() => setHovering(true)} onMouseOver={() => setHovering(true)}
onMouseLeave={() => setHovering(false)} onMouseLeave={() => setHovering(false)}
@@ -154,6 +157,22 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind?
</div> </div>
</span> </span>
</li> </li>
</MenuTrigger>
<MenuPopover>
<MenuList>
{tab && (
<MenuItem onClick={() => tab.duplicateTab()}>Duplicate tab</MenuItem>
)}
<MenuItem
onClick={() => {
tab ? tab.onCloseTabButtonClick() : useTabs.getState().closeReactTab(tabKind);
}}
>
Close tab
</MenuItem>
</MenuList>
</MenuPopover>
</Menu>
); );
} }
+4
View File
@@ -64,6 +64,10 @@ export default class TabsBase extends WaitsForTemplateViewModel {
public getPersistedState = (): OpenTab | null => this.persistedState; public getPersistedState = (): OpenTab | null => this.persistedState;
public triggerPersistState: () => void = undefined; public triggerPersistState: () => void = undefined;
public duplicateTab(): void {
// Subclasses override this to support tab duplication
}
public onCloseTabButtonClick(): void { public onCloseTabButtonClick(): void {
useTabs.getState().closeTab(this); useTabs.getState().closeTab(this);
TelemetryProcessor.trace(Action.Tab, ActionModifiers.Close, { TelemetryProcessor.trace(Action.Tab, ActionModifiers.Close, {
+2 -1
View File
@@ -313,7 +313,8 @@
"newTrigger": "New Trigger", "newTrigger": "New Trigger",
"deleteStoredProcedure": "Delete Stored Procedure", "deleteStoredProcedure": "Delete Stored Procedure",
"deleteTrigger": "Delete Trigger", "deleteTrigger": "Delete Trigger",
"deleteUdf": "Delete User Defined Function" "deleteUdf": "Delete User Defined Function",
"duplicateTab": "Duplicate tab"
}, },
"tabs": { "tabs": {
"documents": { "documents": {