Users/chskelt/pkupdate (#2479)

* Initial change for online partition key change

* Refactoring container copy strings so they can be locallized

* Missed a file

* Fixing some issues found by lint

* Fixing errors

* Fixing unit tests

* Fixing error caused by merging from master

* Fixing minor error from merge

* Fixing merge error

* Addressing comments

* Addressing some PR comments

* Minor issues

* Refixing a formatting issue

* Fixed localization error
This commit is contained in:
vchske
2026-05-18 11:23:49 -07:00
committed by GitHub
parent c71f5d5a00
commit 05f834ed57
17 changed files with 862 additions and 318 deletions
@@ -1,23 +1,85 @@
import { shallow } from "enzyme";
import { render, screen, waitFor } from "@testing-library/react";
import { DatabaseAccount } from "Contracts/DataModels";
import {
PartitionKeyComponent,
PartitionKeyComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent";
import Explorer from "Explorer/Explorer";
import { useDataTransferJobs } from "hooks/useDataTransferJobs";
import React from "react";
import { updateUserContext } from "UserContext";
import { DataTransferJobGetResults } from "Utils/arm/generatedClients/dataTransferService/types";
import Explorer from "../../../Explorer";
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: jest.fn(() => ({ 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 = 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 +89,53 @@ 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", () => {
(useDataTransferJobs as unknown as jest.Mock).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", async () => {
(useDataTransferJobs as unknown as jest.Mock).mockReturnValue({
dataTransferJobs: [mockOnlineJob],
});
const { props } = setupTest();
const { container } = render(<PartitionKeyComponent {...props} />);
await waitFor(() => {
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,16 @@ 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 { 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";
@@ -94,11 +105,11 @@ export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({
const textSubHeadingStyle1 = {
root: { color: "var(--colorNeutralForeground1)" },
};
const startPollingforUpdate = (currentJob: DataTransferJobGetResults) => {
const startPollingforUpdate = async (currentJob: DataTransferJobGetResults) => {
if (isCurrentJobInProgress(currentJob)) {
const jobName = currentJob?.properties?.jobName;
try {
pollDataTransferJob(
await pollDataTransferJob(
jobName,
userContext.subscriptionId,
userContext.resourceGroup,
@@ -119,6 +130,124 @@ 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("", null, t(Keys.common.confirm), onConfirm, t(Keys.common.cancel), 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: t(Keys.containerCopy.monitorJobs.actions.pause),
iconProps: { iconName: "Pause" },
onClick: () => {
pauseRunningDataTransferJob(currentJob);
},
});
}
if (isPaused) {
items.push({
key: CopyJobActions.resume,
text: t(Keys.containerCopy.monitorJobs.actions.resume),
iconProps: { iconName: "Play" },
onClick: () => {
resumePausedDataTransferJob(currentJob);
},
});
}
items.push({
key: CopyJobActions.cancel,
text: t(Keys.common.cancel),
iconProps: { iconName: "Cancel" },
onClick: () =>
showActionConfirmationDialog(currentJob, CopyJobActions.cancel, () => cancelRunningDataTransferJob(currentJob)),
});
items.push({
key: CopyJobActions.complete,
text: t(Keys.containerCopy.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 (
@@ -269,12 +398,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={t(Keys.containerCopy.monitorJobs.columns.actions)}
title={t(Keys.containerCopy.monitorJobs.columns.actions)}
/>
) : (
<DefaultButton
text={t(Keys.controls.settings.partitionKeyEditor.cancelButton)}
onClick={() => cancelRunningDataTransferJob(portalDataTransferJob)}
/>
))}
</Stack>
</Stack>
)}
@@ -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", () => {
@@ -2,7 +2,6 @@ import { Keys, t } from "Localization";
import * as Constants from "../../../Common/Constants";
import * as DataModels from "../../../Contracts/DataModels";
import * as ViewModels from "../../../Contracts/ViewModels";
import { isFabricNative } from "../../../Platform/Fabric/FabricUtil";
import { userContext } from "../../../UserContext";
import { isCapabilityEnabled } from "../../../Utils/CapabilityUtils";
import { MongoIndex } from "../../../Utils/arm/generatedClients/cosmos/types";
@@ -185,9 +184,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:
@@ -429,7 +429,7 @@ exports[`SettingsComponent renders 1`] = `
"data-test": "settings-tab-header/PartitionKeyTab",
}
}
headerText="Partition Keys (preview)"
headerText="Partition Keys"
itemKey="PartitionKeyTab"
key="PartitionKeyTab"
style={