diff --git a/src/Common/dataAccess/dataTransfers.ts b/src/Common/dataAccess/dataTransfers.ts index 3e8829486..1fb0f8de5 100644 --- a/src/Common/dataAccess/dataTransfers.ts +++ b/src/Common/dataAccess/dataTransfers.ts @@ -3,9 +3,12 @@ import { ApiType, userContext } from "UserContext"; import * as NotificationConsoleUtils from "Utils/NotificationConsoleUtils"; import { cancel, + complete, create, get, listByDatabaseAccount, + pause, + resume, } from "Utils/arm/generatedClients/dataTransferService/dataTransferJobs"; import { CosmosCassandraDataTransferDataSourceSink, @@ -31,6 +34,7 @@ export interface DataTransferParams { sourceCollectionName: string; targetDatabaseName: string; targetCollectionName: string; + mode?: "Offline" | "Online"; } export const getDataTransferJobs = async ( @@ -80,6 +84,7 @@ export const initiateDataTransfer = async (params: DataTransferParams): Promise< sourceCollectionName, targetDatabaseName, targetCollectionName, + mode, } = params; const sourcePayload = createPayload(apiType, sourceDatabaseName, sourceCollectionName); const targetPayload = createPayload(apiType, targetDatabaseName, targetCollectionName); @@ -87,6 +92,7 @@ export const initiateDataTransfer = async (params: DataTransferParams): Promise< properties: { source: sourcePayload, destination: targetPayload, + ...(mode ? { mode } : {}), }, }; return create(subscriptionId, resourceGroupName, accountName, jobName, body); @@ -141,6 +147,13 @@ const pollDataTransferJobOperation = async ( NotificationConsoleUtils.logConsoleError(cancelMessage); throw new AbortError(cancelMessage); } + if (status === "Paused") { + removeFromPolling(jobName); + clearMessage && clearMessage(); + const pauseMessage = `Data transfer job ${jobName} paused`; + NotificationConsoleUtils.logConsoleInfo(pauseMessage); + return body; + } if (status === "Failed" || status === "Faulted") { removeFromPolling(jobName); const errorMessage = body?.properties?.error @@ -174,6 +187,43 @@ export const cancelDataTransferJob = async ( removeFromPolling(cancelResult?.properties?.jobName); }; +export const pauseDataTransferJob = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string, + jobName: string, +): Promise => { + const pauseResult: DataTransferJobGetResults = await pause(subscriptionId, resourceGroupName, accountName, jobName); + updateDataTransferJob(pauseResult); + removeFromPolling(pauseResult?.properties?.jobName); +}; + +export const resumeDataTransferJob = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string, + jobName: string, +): Promise => { + const resumeResult: DataTransferJobGetResults = await resume(subscriptionId, resourceGroupName, accountName, jobName); + updateDataTransferJob(resumeResult); +}; + +export const completeDataTransferJob = async ( + subscriptionId: string, + resourceGroupName: string, + accountName: string, + jobName: string, +): Promise => { + const completeResult: DataTransferJobGetResults = await complete( + subscriptionId, + resourceGroupName, + accountName, + jobName, + ); + updateDataTransferJob(completeResult); + removeFromPolling(completeResult?.properties?.jobName); +}; + const createPayload = ( apiType: ApiType, databaseName: string, diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.test.tsx index dfde4ec78..74961648e 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.test.tsx @@ -1,23 +1,84 @@ -import { shallow } from "enzyme"; +import { render, screen } from "@testing-library/react"; import { PartitionKeyComponent, PartitionKeyComponentProps, } from "Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent"; -import Explorer from "Explorer/Explorer"; import React from "react"; +import { updateUserContext } from "UserContext"; +import { DatabaseAccount } from "Contracts/DataModels"; +import { DataTransferJobGetResults } from "Utils/arm/generatedClients/dataTransferService/types"; + +jest.mock("Common/dataAccess/dataTransfers", () => ({ + cancelDataTransferJob: jest.fn().mockResolvedValue(undefined), + pauseDataTransferJob: jest.fn().mockResolvedValue(undefined), + resumeDataTransferJob: jest.fn().mockResolvedValue(undefined), + completeDataTransferJob: jest.fn().mockResolvedValue(undefined), + pollDataTransferJob: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("hooks/useDataTransferJobs", () => ({ + useDataTransferJobs: () => ({ dataTransferJobs: [] }), + refreshDataTransferJobs: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("hooks/useSidePanel", () => ({ + useSidePanel: { + getState: () => ({ + openSidePanel: jest.fn(), + }), + }, +})); + +jest.mock("ConfigContext", () => ({ + configContext: { platform: "Portal" }, + Platform: { Emulator: "Emulator", Portal: "Portal" }, +})); + +jest.mock("Explorer/Explorer", () => { + return jest.fn().mockImplementation(() => ({ + refreshAllDatabases: jest.fn(), + refreshExplorer: jest.fn(), + })); +}); + +const mockOfflineJob = { + properties: { + jobName: "Portal_test_123", + source: { component: "CosmosDBSql" as const, databaseName: "testDb", containerName: "testCol" }, + destination: { component: "CosmosDBSql" as const, databaseName: "testDb", containerName: "newCol" }, + status: "InProgress", + processedCount: 50, + totalCount: 100, + mode: "Offline" as const, + }, +} as DataTransferJobGetResults; + +const mockOnlineJob = { + properties: { + jobName: "Portal_test_456", + source: { component: "CosmosDBSql" as const, databaseName: "testDb", containerName: "testCol" }, + destination: { component: "CosmosDBSql" as const, databaseName: "testDb", containerName: "newCol" }, + status: "InProgress", + processedCount: 50, + totalCount: 100, + mode: "Online" as const, + }, +} as DataTransferJobGetResults; describe("PartitionKeyComponent", () => { - // Create a test setup function to get fresh instances for each test const setupTest = () => { - // Create an instance of the mocked Explorer + const Explorer = require("Explorer/Explorer"); const explorer = new Explorer(); - // Create minimal mock objects for database and collection // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockDatabase = {} as any as import("../../../../Contracts/ViewModels").Database; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mockCollection = {} as any as import("../../../../Contracts/ViewModels").Collection; + const mockCollection = { + id: jest.fn().mockReturnValue("testCol"), + databaseId: "testDb", + partitionKey: { kind: "Hash", paths: ["/id"], version: 2 }, + partitionKeyProperties: ["id"], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any as import("../../../../Contracts/ViewModels").Collection; - // Create props with the mocked Explorer instance const props: PartitionKeyComponentProps = { database: mockDatabase, collection: mockCollection, @@ -27,15 +88,51 @@ describe("PartitionKeyComponent", () => { return { explorer, props }; }; - it("renders default component and matches snapshot", () => { - const { props } = setupTest(); - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + beforeEach(() => { + jest.clearAllMocks(); + updateUserContext({ + databaseAccount: { + name: "testAccount", + id: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/testAccount", + properties: { + documentEndpoint: "https://test.documents.azure.com", + }, + } as unknown as DatabaseAccount, + subscriptionId: "sub1", + resourceGroup: "rg1", + }); }); - it("renders read-only component and matches snapshot", () => { + it("renders partition key value", () => { const { props } = setupTest(); - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + render(); + expect(screen.getByText("/id")).toBeTruthy(); + }); + + it("renders read-only component without change button", () => { + const { props } = setupTest(); + const { container } = render(); + expect(container.querySelector("[data-test='change-partition-key-button']")).toBeNull(); + }); + + it("shows cancel button for offline job in progress", () => { + jest.spyOn(require("hooks/useDataTransferJobs"), "useDataTransferJobs").mockReturnValue({ + dataTransferJobs: [mockOfflineJob], + }); + + const { props } = setupTest(); + const { container } = render(); + // For offline jobs, the online action menu should not be present + expect(container.querySelector("[data-test='online-job-action-menu']")).toBeNull(); + }); + + it("shows ellipsis action menu for online job in progress", () => { + jest.spyOn(require("hooks/useDataTransferJobs"), "useDataTransferJobs").mockReturnValue({ + dataTransferJobs: [mockOnlineJob], + }); + + const { props } = setupTest(); + const { container } = render(); + expect(container.querySelector("[data-test='online-job-action-menu']")).toBeTruthy(); }); }); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx index d276d6371..eb140167f 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx @@ -1,7 +1,10 @@ import { DefaultButton, + DirectionalHint, FontWeights, + IContextualMenuProps, IMessageBarStyles, + IconButton, Link, MessageBar, MessageBarType, @@ -14,8 +17,17 @@ import * as React from "react"; import * as ViewModels from "../../../../Contracts/ViewModels"; import { handleError } from "Common/ErrorHandlingUtils"; -import { cancelDataTransferJob, pollDataTransferJob } from "Common/dataAccess/dataTransfers"; +import { + cancelDataTransferJob, + completeDataTransferJob, + pauseDataTransferJob, + pollDataTransferJob, + resumeDataTransferJob, +} from "Common/dataAccess/dataTransfers"; import { Platform, configContext } from "ConfigContext"; +import ContainerCopyMessages from "Explorer/ContainerCopy/ContainerCopyMessages"; +import { CopyJobActions, CopyJobMigrationType } from "Explorer/ContainerCopy/Enums/CopyJobEnums"; +import { useDialog } from "Explorer/Controls/Dialog"; import Explorer from "Explorer/Explorer"; import { ChangePartitionKeyPane } from "Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane"; import { Keys, t } from "Localization"; @@ -119,6 +131,132 @@ export const PartitionKeyComponent: React.FC = ({ ); }; + const pauseRunningDataTransferJob = async (currentJob: DataTransferJobGetResults) => { + await pauseDataTransferJob( + userContext.subscriptionId, + userContext.resourceGroup, + userContext.databaseAccount.name, + currentJob?.properties?.jobName, + ); + }; + + const resumePausedDataTransferJob = async (currentJob: DataTransferJobGetResults) => { + await resumeDataTransferJob( + userContext.subscriptionId, + userContext.resourceGroup, + userContext.databaseAccount.name, + currentJob?.properties?.jobName, + ); + startPollingforUpdate(currentJob); + }; + + const completeOnlineDataTransferJob = async (currentJob: DataTransferJobGetResults) => { + await completeDataTransferJob( + userContext.subscriptionId, + userContext.resourceGroup, + userContext.databaseAccount.name, + currentJob?.properties?.jobName, + ); + }; + + const isOnlineJob = (currentJob: DataTransferJobGetResults): boolean => { + const mode = (currentJob?.properties?.mode ?? "").toLowerCase(); + return mode === CopyJobMigrationType.Online; + }; + + const showActionConfirmationDialog = ( + currentJob: DataTransferJobGetResults, + action: CopyJobActions, + onConfirm: () => void, + ): void => { + const jobName = currentJob?.properties?.jobName; + const dialogBody = + action === CopyJobActions.cancel ? ( + + + {/*t(Keys.controls.settings.partitionKeyEditor.confirmCancel1)*/}
+ {jobName} +
+ {/*t(Keys.controls.settings.partitionKeyEditor.confirmCancel2)*/} +
+ ) : action === CopyJobActions.complete ? ( + + + {/*t(Keys.controls.settings.partitionKeyEditor.confirmComplete1)*/}
+ {jobName} +
+ + {/*t(Keys.controls.settings.partitionKeyEditor.confrimComplete2)*/} + +
+ ) : null; + + useDialog + .getState() + .showOkCancelModalDialog( + ContainerCopyMessages.MonitorJobs.dialog.heading, + null, + ContainerCopyMessages.MonitorJobs.dialog.confirmButtonText, + onConfirm, + ContainerCopyMessages.MonitorJobs.dialog.cancelButtonText, + null, + dialogBody, + ); + }; + + const getOnlineJobMenuProps = (currentJob: DataTransferJobGetResults): IContextualMenuProps => { + const jobStatus = currentJob?.properties?.status; + const isPaused = jobStatus === "Paused"; + + const items: IContextualMenuProps["items"] = []; + + if (!isPaused) { + items.push({ + key: CopyJobActions.pause, + text: ContainerCopyMessages.MonitorJobs.Actions.pause, + iconProps: { iconName: "Pause" }, + onClick: () => { + pauseRunningDataTransferJob(currentJob); + }, + }); + } + + if (isPaused) { + items.push({ + key: CopyJobActions.resume, + text: ContainerCopyMessages.MonitorJobs.Actions.resume, + iconProps: { iconName: "Play" }, + onClick: () => { + resumePausedDataTransferJob(currentJob); + }, + }); + } + + items.push({ + key: CopyJobActions.cancel, + text: ContainerCopyMessages.MonitorJobs.Actions.cancel, + iconProps: { iconName: "Cancel" }, + onClick: () => + showActionConfirmationDialog(currentJob, CopyJobActions.cancel, () => cancelRunningDataTransferJob(currentJob)), + }); + + items.push({ + key: CopyJobActions.complete, + text: ContainerCopyMessages.MonitorJobs.Actions.complete, + iconProps: { iconName: "CheckMark" }, + onClick: () => + showActionConfirmationDialog(currentJob, CopyJobActions.complete, () => + completeOnlineDataTransferJob(currentJob), + ), + }); + + return { + items, + directionalHint: DirectionalHint.leftTopEdge, + directionalHintFixed: false, + }; + }; + const isCurrentJobInProgress = (currentJob: DataTransferJobGetResults) => { const jobStatus = currentJob?.properties?.status; return ( @@ -152,9 +290,9 @@ export const PartitionKeyComponent: React.FC = ({ const processedCountString = totalCount > 0 ? t(Keys.controls.settings.partitionKeyEditor.documentsProcessed, { - processedCount: String(processedCount), - totalCount: String(totalCount), - }) + processedCount: String(processedCount), + totalCount: String(totalCount), + }) : ""; return `${portalDataTransferJob?.properties?.status} ${processedCountString}`; }; @@ -269,12 +407,26 @@ export const PartitionKeyComponent: React.FC = ({ }, }} > - {isCurrentJobInProgress(portalDataTransferJob) && ( - cancelRunningDataTransferJob(portalDataTransferJob)} - /> - )} + {isCurrentJobInProgress(portalDataTransferJob) && + (isOnlineJob(portalDataTransferJob) ? ( + + ) : ( + cancelRunningDataTransferJob(portalDataTransferJob)} + /> + ))} )} diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap deleted file mode 100644 index 3c75bf55f..000000000 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap +++ /dev/null @@ -1,271 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PartitionKeyComponent renders default component and matches snapshot 1`] = ` - - - - Change partition key - - - - - Current partition key - - - Partitioning - - - - - - Non-hierarchical - - - - - - To safeguard the integrity of the data being copied to the new container, ensure that no updates are made to the source container for the entire duration of the partition key change process. - - Learn more - - - - To change the partition key, a new destination container must be created or an existing destination container selected. Data will then be copied to the destination container. - - - -`; - -exports[`PartitionKeyComponent renders read-only component and matches snapshot 1`] = ` - - - - - - Current partition key - - - Partitioning - - - - - - Non-hierarchical - - - - - -`; diff --git a/src/Explorer/Controls/Settings/SettingsUtils.test.tsx b/src/Explorer/Controls/Settings/SettingsUtils.test.tsx index ea05388ae..716d90a99 100644 --- a/src/Explorer/Controls/Settings/SettingsUtils.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsUtils.test.tsx @@ -291,7 +291,7 @@ describe("SettingsUtils", () => { it("handles partition key tab title based on fabric native", () => { // Assuming initially not fabric native - expect(getTabTitle(SettingsV2TabTypes.PartitionKeyTab)).toBe("Partition Keys (preview)"); + expect(getTabTitle(SettingsV2TabTypes.PartitionKeyTab)).toBe("Partition Keys"); }); it("throws error for unknown tab type", () => { diff --git a/src/Explorer/Controls/Settings/SettingsUtils.tsx b/src/Explorer/Controls/Settings/SettingsUtils.tsx index f445caefd..d19e9d3ae 100644 --- a/src/Explorer/Controls/Settings/SettingsUtils.tsx +++ b/src/Explorer/Controls/Settings/SettingsUtils.tsx @@ -1,8 +1,7 @@ +import { Keys, t } from "Localization"; import * as Constants from "../../../Common/Constants"; import * as DataModels from "../../../Contracts/DataModels"; import * as ViewModels from "../../../Contracts/ViewModels"; -import { Keys, t } from "Localization"; -import { isFabricNative } from "../../../Platform/Fabric/FabricUtil"; import { userContext } from "../../../UserContext"; import { isCapabilityEnabled } from "../../../Utils/CapabilityUtils"; import { MongoIndex } from "../../../Utils/arm/generatedClients/cosmos/types"; @@ -184,9 +183,7 @@ export const getTabTitle = (tab: SettingsV2TabTypes): string => { case SettingsV2TabTypes.IndexingPolicyTab: return t(Keys.controls.settings.tabTitles.indexingPolicy); case SettingsV2TabTypes.PartitionKeyTab: - return isFabricNative() - ? t(Keys.controls.settings.tabTitles.partitionKeys) - : t(Keys.controls.settings.tabTitles.partitionKeysPreview); + return t(Keys.controls.settings.tabTitles.partitionKeys); case SettingsV2TabTypes.ComputedPropertiesTab: return t(Keys.controls.settings.tabTitles.computedProperties); case SettingsV2TabTypes.ContainerVectorPolicyTab: @@ -286,13 +283,12 @@ export const getPartitionKeyPlaceHolder = (apiType: string, index?: number): str case "Gremlin": return t(Keys.controls.settings.partitionKey.gremlinPlaceholder); case "SQL": - return `${ - index === undefined + return `${index === undefined ? t(Keys.controls.settings.partitionKey.sqlFirstPartitionKey) : index === 0 - ? t(Keys.controls.settings.partitionKey.sqlSecondPartitionKey) - : t(Keys.controls.settings.partitionKey.sqlThirdPartitionKey) - }`; + ? t(Keys.controls.settings.partitionKey.sqlSecondPartitionKey) + : t(Keys.controls.settings.partitionKey.sqlThirdPartitionKey) + }`; default: return t(Keys.controls.settings.partitionKey.defaultPlaceholder); } diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap index daa26da90..9abb900b5 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -426,7 +426,7 @@ exports[`SettingsComponent renders 1`] = ` "data-test": "settings-tab-header/PartitionKeyTab", } } - headerText="Partition Keys (preview)" + headerText="Partition Keys" itemKey="PartitionKeyTab" key="PartitionKeyTab" style={ diff --git a/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.test.tsx b/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.test.tsx new file mode 100644 index 000000000..50da20ae6 --- /dev/null +++ b/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.test.tsx @@ -0,0 +1,201 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { CopyJobMigrationType } from "Explorer/ContainerCopy/Enums/CopyJobEnums"; +import * as React from "react"; +import * as ViewModels from "Contracts/ViewModels"; +import Explorer from "Explorer/Explorer"; +import { ChangePartitionKeyPane } from "./ChangePartitionKeyPane"; +import { userContext, updateUserContext } from "UserContext"; +import { DatabaseAccount } from "Contracts/DataModels"; + +jest.mock("Common/dataAccess/createCollection", () => ({ + createCollection: jest.fn().mockResolvedValue({}), +})); + +jest.mock("Common/dataAccess/dataTransfers", () => ({ + initiateDataTransfer: jest.fn().mockResolvedValue({}), +})); + +jest.mock("Utils/arm/databaseAccountUtils", () => ({ + fetchDatabaseAccount: jest.fn().mockResolvedValue(null), +})); + +jest.mock("Utils/arm/generatedClients/cosmos/databaseAccounts", () => ({ + update: jest.fn().mockResolvedValue({}), +})); + +jest.mock("hooks/useSidePanel", () => ({ + useSidePanel: { + getState: () => ({ + closeSidePanel: jest.fn(), + openSidePanel: jest.fn(), + }), + }, +})); + +jest.mock("Explorer/useDatabases", () => { + const state: Record = { + databases: [], + resourceTokenCollection: undefined, + resourceTokenDatabase: undefined, + sampleDataResourceTokenCollection: undefined, + }; + const mockStore = Object.assign( + jest.fn(() => state), + { + getState: () => state, + setState: jest.fn(), + subscribe: jest.fn(), + destroy: jest.fn(), + }, + ); + return { useDatabases: mockStore }; +}); + +jest.mock("Common/LoadingOverlay", () => { + return { + __esModule: true, + default: ({ isLoading, label }: { isLoading: boolean; label: string }) => + isLoading ?
{label}
: null, + }; +}); + +const createMockCollection = (id: string): ViewModels.Collection => { + const mockCollection = { + id: jest.fn().mockReturnValue(id), + offer: jest.fn().mockReturnValue(undefined), + partitionKey: { kind: "Hash", paths: ["/id"], version: 2 }, + partitionKeyProperties: ["id"], + databaseId: "testDb", + } as unknown as ViewModels.Collection; + return mockCollection; +}; + +const createMockDatabase = (id: string, collections: ViewModels.Collection[] = []): ViewModels.Database => { + return { + id: jest.fn().mockReturnValue(id), + collections: jest.fn().mockReturnValue(collections), + } as unknown as ViewModels.Database; +}; + +describe("ChangePartitionKeyPane", () => { + const mockExplorer = new Explorer(); + const mockOnClose = jest.fn().mockResolvedValue(undefined); + const mockCollection = createMockCollection("testCollection"); + const mockDatabase = createMockDatabase("testDb", [mockCollection]); + + beforeEach(() => { + jest.clearAllMocks(); + updateUserContext({ + databaseAccount: { + name: "testAccount", + id: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/testAccount", + properties: { + documentEndpoint: "https://test.documents.azure.com", + capabilities: [], + backupPolicy: { type: "Periodic" }, + }, + } as unknown as DatabaseAccount, + subscriptionId: "sub1", + resourceGroup: "rg1", + apiType: "SQL", + }); + }); + + const renderPane = () => { + return render( + , + ); + }; + + it("renders migration type choice group", () => { + renderPane(); + expect(screen.getByText("Migration type")).toBeTruthy(); + expect(screen.getByText("Offline mode")).toBeTruthy(); + expect(screen.getByText("Online mode")).toBeTruthy(); + }); + + it("defaults to offline migration type", () => { + renderPane(); + const offlineRadio = screen.getByRole("radio", { name: "Offline mode" }) as HTMLInputElement; + expect(offlineRadio.checked).toBe(true); + }); + + it("does not show online prerequisites section when offline is selected", () => { + const { container } = renderPane(); + expect(container.querySelector("[data-test='online-prerequisites-section']")).toBeNull(); + }); + + it("shows online prerequisites section when online is selected", () => { + renderPane(); + const onlineRadio = screen.getByRole("radio", { name: "Online mode" }); + fireEvent.click(onlineRadio); + expect(screen.getByText("Online container copy")).toBeTruthy(); + expect(screen.getByText("Point In Time Restore enabled")).toBeTruthy(); + expect(screen.getByText("Online copy enabled")).toBeTruthy(); + }); + + it("shows prerequisite sections when online prerequisites are not met", () => { + renderPane(); + const onlineRadio = screen.getByRole("radio", { name: "Online mode" }); + fireEvent.click(onlineRadio); + // When prerequisites aren't met, the enable buttons should be visible + expect(screen.getByText("Enable Point In Time Restore")).toBeTruthy(); + expect(screen.getAllByRole("button", { name: "Enable Online Copy" }).length).toBeGreaterThan(0); + }); + + it("shows enable PITR button when PITR is not enabled", () => { + renderPane(); + const onlineRadio = screen.getByRole("radio", { name: "Online mode" }); + fireEvent.click(onlineRadio); + expect(screen.getByText("Enable Point In Time Restore")).toBeTruthy(); + }); + + it("does not show PITR enable button when PITR is already enabled", () => { + updateUserContext({ + databaseAccount: { + name: "testAccount", + id: "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/testAccount", + properties: { + documentEndpoint: "https://test.documents.azure.com", + capabilities: [], + backupPolicy: { type: "Continuous" }, + }, + } as unknown as DatabaseAccount, + }); + + renderPane(); + const onlineRadio = screen.getByRole("radio", { name: "Online mode" }); + fireEvent.click(onlineRadio); + expect(screen.queryByText("Enable Point In Time Restore")).toBeNull(); + }); + + it("disables online copy button when PITR is not enabled", () => { + renderPane(); + const onlineRadio = screen.getByRole("radio", { name: "Online mode" }); + fireEvent.click(onlineRadio); + const enableOnlineCopyBtns = screen.getAllByRole("button", { name: "Enable Online Copy" }); + expect(enableOnlineCopyBtns.length).toBeGreaterThan(0); + expect((enableOnlineCopyBtns[0] as HTMLButtonElement).disabled).toBe(true); + }); + + it("passes mode to initiateDataTransfer when submitting", async () => { + const { initiateDataTransfer } = require("Common/dataAccess/dataTransfers"); + renderPane(); + + // Submit form with offline mode (default) + const form = document.querySelector("form"); + if (form) { + fireEvent.submit(form); + } + + // The mode should be Offline (capitalized for ARM API) + if (initiateDataTransfer.mock.calls.length > 0) { + expect(initiateDataTransfer.mock.calls[0][0].mode).toBe("Offline"); + } + }); +}); diff --git a/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.tsx b/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.tsx index 5a91c6755..a92354d4d 100644 --- a/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.tsx +++ b/src/Explorer/Panes/ChangePartitionKeyPane/ChangePartitionKeyPane.tsx @@ -1,18 +1,25 @@ import { + ChoiceGroup, DefaultButton, DirectionalHint, Dropdown, + IChoiceGroupOption, IDropdownOption, Icon, IconButton, Link, MessageBar, + MessageBarType, + PrimaryButton, Stack, Text, TooltipHost, } from "@fluentui/react"; +import { CapabilityNames } from "Common/Constants"; import * as Constants from "Common/Constants"; import { handleError } from "Common/ErrorHandlingUtils"; +import LoadingOverlay from "Common/LoadingOverlay"; +import { logError } from "Common/Logger"; import { createCollection } from "Common/dataAccess/createCollection"; import { DataTransferParams, initiateDataTransfer } from "Common/dataAccess/dataTransfers"; import * as DataModels from "Contracts/DataModels"; @@ -23,6 +30,8 @@ import { getPartitionKeySubtext, getPartitionKeyTooltipText, } from "Explorer/Controls/Settings/SettingsUtils"; +import ContainerCopyMessages from "Explorer/ContainerCopy/ContainerCopyMessages"; +import { BackupPolicyType, CopyJobMigrationType } from "Explorer/ContainerCopy/Enums/CopyJobEnums"; import Explorer from "Explorer/Explorer"; import { RightPaneForm } from "Explorer/Panes/RightPaneForm/RightPaneForm"; import { useDatabases } from "Explorer/useDatabases"; @@ -30,6 +39,8 @@ import { Keys, t } from "Localization"; import { userContext } from "UserContext"; import { getCollectionName } from "Utils/APITypeUtils"; import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils"; +import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils"; +import { update as updateDatabaseAccount } from "Utils/arm/generatedClients/cosmos/databaseAccounts"; import { useSidePanel } from "hooks/useSidePanel"; import * as React from "react"; @@ -40,6 +51,42 @@ export interface ChangePartitionKeyPaneProps { onClose: () => Promise; } +const migrationTypeOptions: IChoiceGroupOption[] = [ + { + key: CopyJobMigrationType.Offline, + text: ContainerCopyMessages.migrationTypeOptions.offline.title, + styles: { root: { width: "33%" } }, + }, + { + key: CopyJobMigrationType.Online, + text: ContainerCopyMessages.migrationTypeOptions.online.title, + styles: { root: { width: "33%" } }, + }, +]; + +const choiceGroupStyles = { + flexContainer: { display: "flex" as const }, + root: { + selectors: { + ".ms-ChoiceField": { + color: "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + color: "var(--colorNeutralForeground1)", + }, + }, + }, +}; + +const checkPitrEnabled = (account: DataModels.DatabaseAccount): boolean => { + return account?.properties?.backupPolicy?.type === BackupPolicyType.Continuous; +}; + +const checkOnlineCopyEnabled = (account: DataModels.DatabaseAccount): boolean => { + const capabilities = account?.properties?.capabilities ?? []; + return capabilities.some((cap) => cap.name === CapabilityNames.EnableOnlineCopyFeature); +}; + export const ChangePartitionKeyPane: React.FC = ({ sourceDatabase, sourceCollection, @@ -52,6 +99,116 @@ export const ChangePartitionKeyPane: React.FC = ({ const [isExecuting, setIsExecuting] = React.useState(false); const [subPartitionKeys, setSubPartitionKeys] = React.useState([]); const [partitionKey, setPartitionKey] = React.useState(); + const [migrationType, setMigrationType] = React.useState(CopyJobMigrationType.Offline); + + // Pane-local account state for tracking prerequisite enablement + const [localAccount, setLocalAccount] = React.useState(userContext.databaseAccount); + const [isEnablingPrerequisite, setIsEnablingPrerequisite] = React.useState(false); + const [prerequisiteLoaderMessage, setPrerequisiteLoaderMessage] = React.useState(""); + + const pitrEnabled = checkPitrEnabled(localAccount); + const onlineCopyFeatureEnabled = checkOnlineCopyEnabled(localAccount); + const onlinePrerequisitesMet = pitrEnabled && onlineCopyFeatureEnabled; + const isOnlineMode = migrationType === CopyJobMigrationType.Online; + + const accountName = localAccount?.name ?? ""; + const subscriptionId = userContext.subscriptionId; + const resourceGroup = userContext.resourceGroup; + + const intervalRef = React.useRef(null); + const timeoutRef = React.useRef(null); + + React.useEffect(() => { + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const refreshAccount = async (): Promise => { + try { + const account = await fetchDatabaseAccount(subscriptionId, resourceGroup, accountName); + if (account) { + setLocalAccount(account); + } + return account; + } catch (error) { + logError( + error.message || "Error fetching account after enabling prerequisite.", + "ChangePartitionKey/refreshAccount", + ); + return null; + } + }; + + const clearPollingTimers = () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + + const startPollingForAccountUpdate = () => { + intervalRef.current = setInterval(() => { + refreshAccount(); + }, 30 * 1000); + + timeoutRef.current = setTimeout( + () => { + clearPollingTimers(); + setIsEnablingPrerequisite(false); + }, + 10 * 60 * 1000, + ); + }; + + const handleEnablePitr = () => { + const featureUrl = `https://portal.azure.com/#@/resource/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}/backupRestore`; + setIsEnablingPrerequisite(true); + setPrerequisiteLoaderMessage(ContainerCopyMessages.popoverOverlaySpinnerLabel); + window.open(featureUrl, "_blank"); + startPollingForAccountUpdate(); + }; + + const handleEnableOnlineCopy = async () => { + setIsEnablingPrerequisite(true); + try { + setPrerequisiteLoaderMessage( + ContainerCopyMessages.onlineCopyEnabled.validateAllVersionsAndDeletesChangeFeedSpinnerLabel, + ); + const currentAccount = await fetchDatabaseAccount(subscriptionId, resourceGroup, accountName); + if (!currentAccount?.properties?.enableAllVersionsAndDeletesChangeFeed) { + setPrerequisiteLoaderMessage( + ContainerCopyMessages.onlineCopyEnabled.enablingAllVersionsAndDeletesChangeFeedSpinnerLabel, + ); + await updateDatabaseAccount(subscriptionId, resourceGroup, accountName, { + properties: { + enableAllVersionsAndDeletesChangeFeed: true, + }, + }); + } + const capabilities = currentAccount?.properties?.capabilities ?? []; + setPrerequisiteLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.enablingOnlineCopySpinnerLabel(accountName)); + await updateDatabaseAccount(subscriptionId, resourceGroup, accountName, { + properties: { + capabilities: [...capabilities, { name: CapabilityNames.EnableOnlineCopyFeature }], + }, + }); + startPollingForAccountUpdate(); + } catch (error) { + logError(error.message || "Failed to enable online copy feature.", "ChangePartitionKey/handleEnableOnlineCopy"); + setFormError("Failed to enable online copy feature. Please try again."); + setIsEnablingPrerequisite(false); + } + }; const getCollectionOptions = (): IDropdownOption[] => { return sourceDatabase @@ -84,9 +241,17 @@ export const ChangePartitionKeyPane: React.FC = ({ setFormError("Choose an existing container"); return false; } + if (isOnlineMode && !onlinePrerequisitesMet) { + setFormError("Online migration prerequisites must be enabled before proceeding."); + return false; + } return true; }; + const getModeForApi = (): "Offline" | "Online" => { + return migrationType === CopyJobMigrationType.Online ? "Online" : "Offline"; + }; + const createDataTransferJob = async () => { const jobName = `Portal_${targetCollectionId}_${Math.floor(Date.now() / 1000)}`; const dataTransferParams: DataTransferParams = { @@ -99,6 +264,7 @@ export const ChangePartitionKeyPane: React.FC = ({ sourceCollectionName: sourceCollection.id(), targetDatabaseName: sourceDatabase.id(), targetCollectionName: targetCollectionId, + mode: getModeForApi(), }; await initiateDataTransfer(dataTransferParams); }; @@ -133,12 +299,15 @@ export const ChangePartitionKeyPane: React.FC = ({ return !!selectedDatabase?.offer(); }; + const isSubmitDisabled = isOnlineMode && !onlinePrerequisitesMet; + return ( @@ -151,6 +320,37 @@ export const ChangePartitionKeyPane: React.FC = ({ {t(Keys.common.learnMore)} + + {/* Migration Type */} + + + Migration type + + { + if (option) { + setMigrationType(option.key as CopyJobMigrationType); + } + }} + ariaLabelledBy="migrationTypeChoiceGroup" + styles={choiceGroupStyles} + /> + {migrationType && ( + + {migrationType === CopyJobMigrationType.Offline + ? ContainerCopyMessages.migrationTypeOptions.offline.description + : ContainerCopyMessages.migrationTypeOptions.online.description} + + )} + + @@ -420,6 +620,89 @@ export const ChangePartitionKeyPane: React.FC = ({ /> )} + + {/* Online prerequisites section */} + {isOnlineMode && ( + + + + {ContainerCopyMessages.assignPermissions.onlineConfiguration.title} + + + {ContainerCopyMessages.assignPermissions.onlineConfiguration.description(accountName)} + + + {/* Point In Time Restore */} + + + + + {ContainerCopyMessages.pointInTimeRestore.title} + + + {!pitrEnabled && ( + + + {ContainerCopyMessages.pointInTimeRestore.description(accountName)} + + + + )} + + + {/* Online Copy Enabled */} + + + + + {ContainerCopyMessages.onlineCopyEnabled.title} + + + {!onlineCopyFeatureEnabled && ( + + + {ContainerCopyMessages.onlineCopyEnabled.description(accountName)}  + + {ContainerCopyMessages.onlineCopyEnabled.hrefText} + + + + + )} + + + {!onlinePrerequisitesMet && ( + + Online migration prerequisites must be enabled before proceeding. + + )} + + )} ); diff --git a/src/Localization/en/Resources.json b/src/Localization/en/Resources.json index 14dd422c1..613d6a6c0 100644 --- a/src/Localization/en/Resources.json +++ b/src/Localization/en/Resources.json @@ -760,7 +760,6 @@ "settings": "Settings", "indexingPolicy": "Indexing Policy", "partitionKeys": "Partition Keys", - "partitionKeysPreview": "Partition Keys (preview)", "computedProperties": "Computed Properties", "containerPolicies": "Container Policies", "throughputBuckets": "Throughput Buckets", @@ -895,6 +894,10 @@ }, "partitionKeyEditor": { "changePartitionKey": "Change {{partitionKeyName}}", + "confirmCancel1": "You are about to cancel the following copy job.", + "confirmCancel2": "Cancelling will stop the job immediately.", + "confirmComplete1": "You are about to complete the following copy job.", + "confrimComplete2": "Once completed, continuous data copy will stop after any pending documents are processed. To maintain data integrity, we recommend stopping updates to the source container before completing the job.", "currentPartitionKey": "Current {{partitionKeyName}}", "partitioning": "Partitioning", "hierarchical": "Hierarchical", @@ -972,4 +975,4 @@ } } } -} +} \ No newline at end of file