mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-05-14 17:27:30 +01:00
Initial change for online partition key change
This commit is contained in:
@@ -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<void> => {
|
||||
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<void> => {
|
||||
const resumeResult: DataTransferJobGetResults = await resume(subscriptionId, resourceGroupName, accountName, jobName);
|
||||
updateDataTransferJob(resumeResult);
|
||||
};
|
||||
|
||||
export const completeDataTransferJob = async (
|
||||
subscriptionId: string,
|
||||
resourceGroupName: string,
|
||||
accountName: string,
|
||||
jobName: string,
|
||||
): Promise<void> => {
|
||||
const completeResult: DataTransferJobGetResults = await complete(
|
||||
subscriptionId,
|
||||
resourceGroupName,
|
||||
accountName,
|
||||
jobName,
|
||||
);
|
||||
updateDataTransferJob(completeResult);
|
||||
removeFromPolling(completeResult?.properties?.jobName);
|
||||
};
|
||||
|
||||
const createPayload = (
|
||||
apiType: ApiType,
|
||||
databaseName: string,
|
||||
|
||||
+112
-15
@@ -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(<PartitionKeyComponent {...props} />);
|
||||
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(<PartitionKeyComponent {...props} isReadOnly={true} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
render(<PartitionKeyComponent {...props} />);
|
||||
expect(screen.getByText("/id")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders read-only component without change button", () => {
|
||||
const { props } = setupTest();
|
||||
const { container } = render(<PartitionKeyComponent {...props} isReadOnly={true} />);
|
||||
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(<PartitionKeyComponent {...props} />);
|
||||
// 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(<PartitionKeyComponent {...props} />);
|
||||
expect(container.querySelector("[data-test='online-job-action-menu']")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<PartitionKeyComponentProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
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 ? (
|
||||
<Stack tokens={{ childrenGap: 10 }}>
|
||||
<Stack.Item>
|
||||
{/*t(Keys.controls.settings.partitionKeyEditor.confirmCancel1)*/}<br />
|
||||
<b>{jobName}</b>
|
||||
</Stack.Item>
|
||||
<Stack.Item>{/*t(Keys.controls.settings.partitionKeyEditor.confirmCancel2)*/}</Stack.Item>
|
||||
</Stack>
|
||||
) : action === CopyJobActions.complete ? (
|
||||
<Stack tokens={{ childrenGap: 10 }}>
|
||||
<Stack.Item>
|
||||
{/*t(Keys.controls.settings.partitionKeyEditor.confirmComplete1)*/}<br />
|
||||
<b>{jobName}</b>
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
{/*t(Keys.controls.settings.partitionKeyEditor.confrimComplete2)*/}
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
) : 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<PartitionKeyComponentProps> = ({
|
||||
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<PartitionKeyComponentProps> = ({
|
||||
},
|
||||
}}
|
||||
></ProgressIndicator>
|
||||
{isCurrentJobInProgress(portalDataTransferJob) && (
|
||||
<DefaultButton
|
||||
text={t(Keys.controls.settings.partitionKeyEditor.cancelButton)}
|
||||
onClick={() => cancelRunningDataTransferJob(portalDataTransferJob)}
|
||||
/>
|
||||
)}
|
||||
{isCurrentJobInProgress(portalDataTransferJob) &&
|
||||
(isOnlineJob(portalDataTransferJob) ? (
|
||||
<IconButton
|
||||
data-test="online-job-action-menu"
|
||||
role="button"
|
||||
iconProps={{
|
||||
iconName: "More",
|
||||
styles: { root: { fontSize: "20px", fontWeight: "bold" } },
|
||||
}}
|
||||
menuProps={getOnlineJobMenuProps(portalDataTransferJob)}
|
||||
menuIconProps={{ iconName: "", className: "hidden" }}
|
||||
ariaLabel={ContainerCopyMessages.MonitorJobs.Columns.actions}
|
||||
title={ContainerCopyMessages.MonitorJobs.Columns.actions}
|
||||
/>
|
||||
) : (
|
||||
<DefaultButton
|
||||
text={t(Keys.controls.settings.partitionKeyEditor.cancelButton)}
|
||||
onClick={() => cancelRunningDataTransferJob(portalDataTransferJob)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
-271
@@ -1,271 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PartitionKeyComponent renders default component and matches snapshot 1`] = `
|
||||
<Stack
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"maxWidth": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
"fontSize": 16,
|
||||
"fontWeight": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Change partition key
|
||||
</Text>
|
||||
<Stack
|
||||
horizontal={true}
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
"fontWeight": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Current partition key
|
||||
</Text>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
"fontWeight": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Partitioning
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack
|
||||
data-test="partition-key-values"
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Non-hierarchical
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<StyledMessageBar
|
||||
data-test="partition-key-warning"
|
||||
messageBarIconProps={
|
||||
{
|
||||
"className": "messageBarWarningIcon",
|
||||
"iconName": "WarningSolid",
|
||||
}
|
||||
}
|
||||
messageBarType={5}
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"selectors": {
|
||||
"&.ms-MessageBar--warning": {
|
||||
"backgroundColor": "var(--colorStatusWarningBackground1)",
|
||||
"border": "1px solid var(--colorStatusWarningBorder1)",
|
||||
},
|
||||
".ms-MessageBar-icon": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
},
|
||||
".ms-MessageBar-text": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
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.
|
||||
<StyledLinkBase
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/container-copy#how-does-container-copy-work"
|
||||
style={
|
||||
{
|
||||
"color": "var(--colorBrandForeground1)",
|
||||
}
|
||||
}
|
||||
target="_blank"
|
||||
underline={true}
|
||||
>
|
||||
Learn more
|
||||
</StyledLinkBase>
|
||||
</StyledMessageBar>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
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.
|
||||
</Text>
|
||||
<CustomizedPrimaryButton
|
||||
data-test="change-partition-key-button"
|
||||
onClick={[Function]}
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"width": "fit-content",
|
||||
},
|
||||
}
|
||||
}
|
||||
text="Change"
|
||||
/>
|
||||
</Stack>
|
||||
`;
|
||||
|
||||
exports[`PartitionKeyComponent renders read-only component and matches snapshot 1`] = `
|
||||
<Stack
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"maxWidth": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
horizontal={true}
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Stack
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
"fontWeight": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Current partition key
|
||||
</Text>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
"fontWeight": 600,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Partitioning
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack
|
||||
data-test="partition-key-values"
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
styles={
|
||||
{
|
||||
"root": {
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
Non-hierarchical
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
`;
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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 ? <div data-testid="loading-overlay">{label}</div> : 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(
|
||||
<ChangePartitionKeyPane
|
||||
sourceDatabase={mockDatabase}
|
||||
sourceCollection={mockCollection}
|
||||
explorer={mockExplorer}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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<ChangePartitionKeyPaneProps> = ({
|
||||
sourceDatabase,
|
||||
sourceCollection,
|
||||
@@ -52,6 +99,116 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
const [isExecuting, setIsExecuting] = React.useState<boolean>(false);
|
||||
const [subPartitionKeys, setSubPartitionKeys] = React.useState<string[]>([]);
|
||||
const [partitionKey, setPartitionKey] = React.useState<string>();
|
||||
const [migrationType, setMigrationType] = React.useState<CopyJobMigrationType>(CopyJobMigrationType.Offline);
|
||||
|
||||
// Pane-local account state for tracking prerequisite enablement
|
||||
const [localAccount, setLocalAccount] = React.useState<DataModels.DatabaseAccount>(userContext.databaseAccount);
|
||||
const [isEnablingPrerequisite, setIsEnablingPrerequisite] = React.useState<boolean>(false);
|
||||
const [prerequisiteLoaderMessage, setPrerequisiteLoaderMessage] = React.useState<string>("");
|
||||
|
||||
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<NodeJS.Timeout | null>(null);
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshAccount = async (): Promise<DataModels.DatabaseAccount | null> => {
|
||||
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<ChangePartitionKeyPaneProps> = ({
|
||||
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<ChangePartitionKeyPaneProps> = ({
|
||||
sourceCollectionName: sourceCollection.id(),
|
||||
targetDatabaseName: sourceDatabase.id(),
|
||||
targetCollectionName: targetCollectionId,
|
||||
mode: getModeForApi(),
|
||||
};
|
||||
await initiateDataTransfer(dataTransferParams);
|
||||
};
|
||||
@@ -133,12 +299,15 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
return !!selectedDatabase?.offer();
|
||||
};
|
||||
|
||||
const isSubmitDisabled = isOnlineMode && !onlinePrerequisitesMet;
|
||||
|
||||
return (
|
||||
<RightPaneForm
|
||||
formError={formError}
|
||||
isExecuting={isExecuting}
|
||||
onSubmit={submit}
|
||||
submitButtonText={t(Keys.common.ok)}
|
||||
isSubmitButtonDisabled={isSubmitDisabled}
|
||||
>
|
||||
<Stack tokens={{ childrenGap: 10 }} className="panelMainContent">
|
||||
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
@@ -151,6 +320,37 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
{t(Keys.common.learnMore)}
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
{/* Migration Type */}
|
||||
<Stack data-test="migration-type-section">
|
||||
<Text className="panelTextBold" variant="small" style={{ marginBottom: 4 }}>
|
||||
Migration type
|
||||
</Text>
|
||||
<ChoiceGroup
|
||||
data-test="migration-type-choice"
|
||||
selectedKey={migrationType}
|
||||
options={migrationTypeOptions}
|
||||
onChange={(_ev, option) => {
|
||||
if (option) {
|
||||
setMigrationType(option.key as CopyJobMigrationType);
|
||||
}
|
||||
}}
|
||||
ariaLabelledBy="migrationTypeChoiceGroup"
|
||||
styles={choiceGroupStyles}
|
||||
/>
|
||||
{migrationType && (
|
||||
<Text
|
||||
variant="small"
|
||||
style={{ color: "var(--colorNeutralForeground1)", marginTop: 4 }}
|
||||
data-test={`migration-type-description-${migrationType}`}
|
||||
>
|
||||
{migrationType === CopyJobMigrationType.Offline
|
||||
? ContainerCopyMessages.migrationTypeOptions.offline.description
|
||||
: ContainerCopyMessages.migrationTypeOptions.online.description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack>
|
||||
<Stack horizontal>
|
||||
<span className="mandatoryStar">* </span>
|
||||
@@ -420,6 +620,89 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Online prerequisites section */}
|
||||
{isOnlineMode && (
|
||||
<Stack data-test="online-prerequisites-section" tokens={{ childrenGap: 10 }}>
|
||||
<LoadingOverlay isLoading={isEnablingPrerequisite} label={prerequisiteLoaderMessage} />
|
||||
<Text className="panelTextBold" variant="small">
|
||||
{ContainerCopyMessages.assignPermissions.onlineConfiguration.title}
|
||||
</Text>
|
||||
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
{ContainerCopyMessages.assignPermissions.onlineConfiguration.description(accountName)}
|
||||
</Text>
|
||||
|
||||
{/* Point In Time Restore */}
|
||||
<Stack tokens={{ childrenGap: 5 }}>
|
||||
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 5 }}>
|
||||
<Icon
|
||||
iconName={pitrEnabled ? "SkypeCircleCheck" : "StatusCircleRing"}
|
||||
styles={{
|
||||
root: { color: pitrEnabled ? "green" : "var(--colorNeutralForeground1)", fontSize: 16 },
|
||||
}}
|
||||
/>
|
||||
<Text variant="small" style={{ fontWeight: 600, color: "var(--colorNeutralForeground1)" }}>
|
||||
{ContainerCopyMessages.pointInTimeRestore.title}
|
||||
</Text>
|
||||
</Stack>
|
||||
{!pitrEnabled && (
|
||||
<Stack tokens={{ childrenGap: 10, padding: "0 0 0 20px" }}>
|
||||
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
{ContainerCopyMessages.pointInTimeRestore.description(accountName)}
|
||||
</Text>
|
||||
<PrimaryButton
|
||||
data-test="enable-pitr-button"
|
||||
text={ContainerCopyMessages.pointInTimeRestore.buttonText}
|
||||
disabled={isEnablingPrerequisite}
|
||||
onClick={handleEnablePitr}
|
||||
styles={{ root: { width: "fit-content" } }}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Online Copy Enabled */}
|
||||
<Stack tokens={{ childrenGap: 5 }}>
|
||||
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 5 }}>
|
||||
<Icon
|
||||
iconName={onlineCopyFeatureEnabled ? "SkypeCircleCheck" : "StatusCircleRing"}
|
||||
styles={{
|
||||
root: {
|
||||
color: onlineCopyFeatureEnabled ? "green" : "var(--colorNeutralForeground1)",
|
||||
fontSize: 16,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Text variant="small" style={{ fontWeight: 600, color: "var(--colorNeutralForeground1)" }}>
|
||||
{ContainerCopyMessages.onlineCopyEnabled.title}
|
||||
</Text>
|
||||
</Stack>
|
||||
{!onlineCopyFeatureEnabled && (
|
||||
<Stack tokens={{ childrenGap: 10, padding: "0 0 0 20px" }}>
|
||||
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
{ContainerCopyMessages.onlineCopyEnabled.description(accountName)} 
|
||||
<Link href={ContainerCopyMessages.onlineCopyEnabled.href} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.onlineCopyEnabled.hrefText}
|
||||
</Link>
|
||||
</Text>
|
||||
<PrimaryButton
|
||||
data-test="enable-online-copy-button"
|
||||
text={ContainerCopyMessages.onlineCopyEnabled.buttonText}
|
||||
disabled={isEnablingPrerequisite || !pitrEnabled}
|
||||
onClick={handleEnableOnlineCopy}
|
||||
styles={{ root: { width: "fit-content" } }}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{!onlinePrerequisitesMet && (
|
||||
<MessageBar messageBarType={MessageBarType.warning} data-test="online-prerequisites-warning">
|
||||
Online migration prerequisites must be enabled before proceeding.
|
||||
</MessageBar>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</RightPaneForm>
|
||||
);
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user