feat: Add "Duplicate Tab" support for Items, Query, and Settings tabs

This commit is contained in:
Bikram Choudhury
2026-05-14 23:03:17 +05:30
parent 98eb31da7e
commit 974ca3df0b
9 changed files with 377 additions and 66 deletions
@@ -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,25 @@ export class DocumentsTabV2 extends TabsBase {
}; };
} }
public canDuplicate(): boolean {
return true;
}
public duplicateTab(): void {
const newTab = new DocumentsTabV2({
partitionKey: this.partitionKey,
documentIds: ko.observableArray<DocumentId>([]),
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
@@ -2,6 +2,7 @@ import { ActionType, TabKind } from "Contracts/ActionContracts";
import React from "react"; import React from "react";
import MongoUtility from "../../../Common/MongoUtility"; import MongoUtility from "../../../Common/MongoUtility";
import * as ViewModels from "../../../Contracts/ViewModels"; import * as ViewModels from "../../../Contracts/ViewModels";
import { useTabs } from "../../../hooks/useTabs";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { NewQueryTab } from "../QueryTab/QueryTab"; import { NewQueryTab } from "../QueryTab/QueryTab";
import { IQueryTabComponentProps, ITabAccessor, QueryTabComponent } from "../QueryTab/QueryTabComponent"; import { IQueryTabComponentProps, ITabAccessor, QueryTabComponent } from "../QueryTab/QueryTabComponent";
@@ -67,6 +68,29 @@ export class NewMongoQueryTab extends NewQueryTab {
return MongoUtility.tojson(value, undefined, false); return MongoUtility.tojson(value, undefined, false);
} }
public canDuplicate(): boolean {
return true;
}
public duplicateTab(): void {
const id = useTabs.getState().getTabs(ViewModels.CollectionTabKind.Query).length + 1;
const newTab = new NewMongoQueryTab(
{
tabKind: ViewModels.CollectionTabKind.Query,
title: `Query ${id}`,
tabPath: "",
collection: this.collection,
node: this.collection,
queryText: this.persistedState?.query?.text ?? "",
partitionKey: this.partitionKey,
splitterDirection: this.persistedState?.splitterDirection,
queryViewSizePercent: this.persistedState?.queryViewSizePercent,
},
this.mongoQueryTabProps,
);
useTabs.getState().activateNewTab(newTab);
}
public render(): JSX.Element { public render(): JSX.Element {
return <QueryTabComponent {...this.iMongoQueryTabComponentProps} />; return <QueryTabComponent {...this.iMongoQueryTabComponentProps} />;
} }
@@ -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<string>("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");
});
});
+25
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,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 { public render(): JSX.Element {
return <QueryTabComponent {...this.iQueryTabComponentProps} />; return <QueryTabComponent {...this.iQueryTabComponentProps} />;
} }
+16
View File
@@ -1,6 +1,7 @@
import { ActionType, OpenCollectionTab, TabKind } from "Contracts/ActionContracts"; import { ActionType, OpenCollectionTab, TabKind } from "Contracts/ActionContracts";
import React from "react"; import React from "react";
import * as ViewModels from "../../Contracts/ViewModels"; import * as ViewModels from "../../Contracts/ViewModels";
import { useTabs } from "../../hooks/useTabs";
import { SettingsComponent } from "../Controls/Settings/SettingsComponent"; import { SettingsComponent } from "../Controls/Settings/SettingsComponent";
import TabsBase from "./TabsBase"; 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 { public onActivate(): void {
super.onActivate(); super.onActivate();
this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.CollectionSettingsV2); this.collection.selectedSubnodeKind(ViewModels.CollectionTabKind.CollectionSettingsV2);
+90 -66
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";
@@ -10,6 +11,7 @@ import { QuickstartTab } from "Explorer/Tabs/QuickstartTab";
import { VcoreMongoConnectTab } from "Explorer/Tabs/VCoreMongoConnectTab"; import { VcoreMongoConnectTab } from "Explorer/Tabs/VCoreMongoConnectTab";
import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab"; import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab";
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts"; import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
import { Keys, t } from "Localization";
import { isFabricNative } from "Platform/Fabric/FabricUtil"; import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
import { useTeachingBubble } from "hooks/useTeachingBubble"; import { useTeachingBubble } from "hooks/useTeachingBubble";
@@ -86,74 +88,96 @@ function TabNav({ tab, active, tabKind }: { tab?: Tab; active: boolean; tabKind?
} }
}, [active]); }, [active]);
return ( return (
<li <Menu openOnContext>
onMouseOver={() => setHovering(true)} <MenuTrigger disableButtonEnhancement>
onMouseLeave={() => setHovering(false)} <li
className={active ? "active tabList" : "tabList"} onMouseOver={() => setHovering(true)}
style={active ? { fontWeight: "bolder" } : {}} onMouseLeave={() => setHovering(false)}
role="presentation" className={active ? "active tabList" : "tabList"}
> style={active ? { fontWeight: "bolder" } : {}}
<span className="tabNavContentContainer"> role="presentation"
<div className="tab_Content"> >
<TooltipHost content={useObservable(tab?.tabPath || ko.observable(""))}> <span className="tabNavContentContainer">
<span <div className="tab_Content">
className="contentWrapper" <TooltipHost content={useObservable(tab?.tabPath || ko.observable(""))}>
onClick={() => { <span
if (tab) { className="contentWrapper"
tab.onTabClick(); onClick={() => {
} else if (tabKind !== undefined) { if (tab) {
useTabs.getState().activateReactTab(tabKind); tab.onTabClick();
} } else if (tabKind !== undefined) {
}} useTabs.getState().activateReactTab(tabKind);
onKeyPress={({ nativeEvent: e }) => { }
if (tab) { }}
tab.onKeyPressActivate(undefined, e); onKeyPress={({ nativeEvent: e }) => {
} else if (tabKind !== undefined) { if (tab) {
onKeyPressReactTab(e, tabKind); tab.onKeyPressActivate(undefined, e);
} } else if (tabKind !== undefined) {
}} onKeyPressReactTab(e, tabKind);
aria-selected={active} }
aria-expanded={active} }}
aria-controls={tabId} aria-selected={active}
tabIndex={0} aria-expanded={active}
role="tab" aria-controls={tabId}
ref={focusTab} tabIndex={0}
> role="tab"
<span className="statusIconContainer" style={{ width: tabKind === ReactTabKind.Home ? 0 : 18 }}> ref={focusTab}
{useObservable(tab?.isExecutionError || ko.observable(false)) && ( >
<ErrorIcon tab={tab} active={active} /> <span className="statusIconContainer" style={{ width: tabKind === ReactTabKind.Home ? 0 : 18 }}>
)} {useObservable(tab?.isExecutionError || ko.observable(false)) && (
{useObservable(tab?.isExecutionWarning || ko.observable(false)) && ( <ErrorIcon tab={tab} active={active} />
<WarningIcon tab={tab} active={active} /> )}
)} {useObservable(tab?.isExecutionWarning || ko.observable(false)) && (
{isTabExecuting(tab, tabKind) && ( <WarningIcon tab={tab} active={active} />
<Spinner )}
size={SpinnerSize.small} {isTabExecuting(tab, tabKind) && (
styles={{ <Spinner
circle: { size={SpinnerSize.small}
borderTopColor: "var(--colorNeutralForeground1)", styles={{
borderLeftColor: "var(--colorNeutralForeground1)", circle: {
borderBottomColor: "var(--colorNeutralForeground1)", borderTopColor: "var(--colorNeutralForeground1)",
borderRightColor: "var(--colorNeutralBackground1)", borderLeftColor: "var(--colorNeutralForeground1)",
}, borderBottomColor: "var(--colorNeutralForeground1)",
}} borderRightColor: "var(--colorNeutralBackground1)",
/> },
)} }}
{isQueryErrorThrown(tab, tabKind) && ( />
<TooltipHost content="Error"> )}
<img src={errorQuery} alt="Error" style={{ marginTop: 4, marginLeft: 4, width: 10, height: 11 }} /> {isQueryErrorThrown(tab, tabKind) && (
</TooltipHost> <TooltipHost content="Error">
)} <img
src={errorQuery}
alt="Error"
style={{ marginTop: 4, marginLeft: 4, width: 10, height: 11 }}
/>
</TooltipHost>
)}
</span>
<span className="tabNavText">{tabTitle}</span>
</span>
</TooltipHost>
<span className="tabIconSection">
<CloseButton tab={tab} active={active} hovering={hovering} tabKind={tabKind} ariaLabel={tabTitle} />
</span> </span>
<span className="tabNavText">{tabTitle}</span> </div>
</span>
</TooltipHost>
<span className="tabIconSection">
<CloseButton tab={tab} active={active} hovering={hovering} tabKind={tabKind} ariaLabel={tabTitle} />
</span> </span>
</div> </li>
</span> </MenuTrigger>
</li> <MenuPopover>
<MenuList>
{tab?.canDuplicate() && (
<MenuItem onClick={() => tab.duplicateTab()}>{t(Keys.tabs.tabMenu.duplicateTab)}</MenuItem>
)}
<MenuItem
onClick={() => {
tab ? tab.onCloseTabButtonClick() : useTabs.getState().closeReactTab(tabKind);
}}
>
{t(Keys.tabs.tabMenu.closeTab)}
</MenuItem>
</MenuList>
</MenuPopover>
</Menu>
); );
} }
+8
View File
@@ -64,6 +64,14 @@ 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 canDuplicate(): boolean {
return false;
}
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, {
+4
View File
@@ -316,6 +316,10 @@
"deleteUdf": "Delete User Defined Function" "deleteUdf": "Delete User Defined Function"
}, },
"tabs": { "tabs": {
"tabMenu": {
"duplicateTab": "Duplicate tab",
"closeTab": "Close tab"
},
"documents": { "documents": {
"newItem": "New Item", "newItem": "New Item",
"newDocument": "New Document", "newDocument": "New Document",