+
= ({ explorer }) => {
);
};
+CopyJobCommandBar.displayName = "CopyJobCommandBar";
+
export default CopyJobCommandBar;
diff --git a/src/Explorer/ContainerCopy/CommandBar/Utils.test.ts b/src/Explorer/ContainerCopy/CommandBar/Utils.test.ts
new file mode 100644
index 000000000..003dc22a2
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CommandBar/Utils.test.ts
@@ -0,0 +1,272 @@
+import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
+import Explorer from "../../Explorer";
+import * as Actions from "../Actions/CopyJobActions";
+import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
+import { getCommandBarButtons } from "./Utils";
+
+jest.mock("../../../ConfigContext", () => ({
+ configContext: {
+ platform: "Portal",
+ },
+ Platform: {
+ Portal: "Portal",
+ Emulator: "Emulator",
+ Hosted: "Hosted",
+ },
+}));
+
+jest.mock("../Actions/CopyJobActions", () => ({
+ openCreateCopyJobPanel: jest.fn(),
+}));
+
+jest.mock("../MonitorCopyJobs/MonitorCopyJobRefState", () => ({
+ MonitorCopyJobsRefState: jest.fn(),
+}));
+
+describe("CommandBar Utils", () => {
+ let mockExplorer: Explorer;
+ let mockOpenContainerCopyFeedbackBlade: jest.Mock;
+ let mockRefreshJobList: jest.Mock;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockOpenContainerCopyFeedbackBlade = jest.fn();
+ mockRefreshJobList = jest.fn();
+
+ mockExplorer = {
+ openContainerCopyFeedbackBlade: mockOpenContainerCopyFeedbackBlade,
+ } as unknown as Explorer;
+
+ (MonitorCopyJobsRefState as unknown as jest.Mock).mockImplementation((selector) => {
+ const state = {
+ ref: {
+ refreshJobList: mockRefreshJobList,
+ },
+ };
+ return selector(state);
+ });
+ });
+
+ describe("getCommandBarButtons", () => {
+ it("should return an array of command button props", () => {
+ const buttons = getCommandBarButtons(mockExplorer, false);
+
+ expect(buttons).toBeDefined();
+ expect(Array.isArray(buttons)).toBe(true);
+ expect(buttons.length).toBeGreaterThan(0);
+ });
+
+ it("should include create copy job button", () => {
+ const buttons = getCommandBarButtons(mockExplorer, false);
+ const createButton = buttons[0];
+
+ expect(createButton).toBeDefined();
+ expect(createButton.commandButtonLabel).toBeUndefined();
+ expect(createButton.ariaLabel).toBe("Create a new container copy job");
+ expect(createButton.tooltipText).toBe("Create Copy Job");
+ expect(createButton.hasPopup).toBe(false);
+ expect(createButton.disabled).toBe(false);
+ });
+
+ it("should include refresh button", () => {
+ const buttons = getCommandBarButtons(mockExplorer, false);
+ const refreshButton = buttons[1];
+
+ expect(refreshButton).toBeDefined();
+ expect(refreshButton.ariaLabel).toBe("Refresh copy jobs");
+ expect(refreshButton.tooltipText).toBe("Refresh");
+ expect(refreshButton.disabled).toBe(false);
+ });
+
+ it("should include feedback button when platform is Portal", () => {
+ const buttons = getCommandBarButtons(mockExplorer, false);
+
+ expect(buttons.length).toBe(4);
+
+ const feedbackButton = buttons[3];
+ expect(feedbackButton).toBeDefined();
+ expect(feedbackButton.ariaLabel).toBe("Provide feedback on copy jobs");
+ expect(feedbackButton.tooltipText).toBe("Feedback");
+ expect(feedbackButton.disabled).toBe(false);
+ });
+
+ it("should not include feedback button when platform is not Portal", async () => {
+ jest.resetModules();
+ jest.doMock("../../../ConfigContext", () => ({
+ configContext: {
+ platform: "Emulator",
+ },
+ Platform: {
+ Portal: "Portal",
+ Emulator: "Emulator",
+ Hosted: "Hosted",
+ },
+ }));
+
+ const { getCommandBarButtons: getCommandBarButtonsEmulator } = await import("./Utils");
+ const buttons = getCommandBarButtonsEmulator(mockExplorer, false);
+
+ expect(buttons.length).toBe(3);
+ });
+
+ it("should call openCreateCopyJobPanel when create button is clicked", () => {
+ const buttons = getCommandBarButtons(mockExplorer, false);
+ const createButton = buttons[0];
+
+ createButton.onCommandClick({} as React.SyntheticEvent);
+
+ expect(Actions.openCreateCopyJobPanel).toHaveBeenCalledWith(mockExplorer);
+ expect(Actions.openCreateCopyJobPanel).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call refreshJobList when refresh button is clicked", () => {
+ const buttons = getCommandBarButtons(mockExplorer, false);
+ const refreshButton = buttons[1];
+
+ refreshButton.onCommandClick({} as React.SyntheticEvent);
+
+ expect(mockRefreshJobList).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call openContainerCopyFeedbackBlade when feedback button is clicked", () => {
+ const buttons = getCommandBarButtons(mockExplorer, false);
+ const feedbackButton = buttons[3];
+
+ feedbackButton.onCommandClick({} as React.SyntheticEvent);
+
+ expect(mockOpenContainerCopyFeedbackBlade).toHaveBeenCalledTimes(1);
+ });
+
+ it("should return buttons with correct icon sources", () => {
+ const buttons = getCommandBarButtons(mockExplorer, false);
+
+ expect(buttons[0].iconSrc).toBeDefined();
+ expect(buttons[0].iconAlt).toBe("Create Copy Job");
+
+ expect(buttons[1].iconSrc).toBeDefined();
+ expect(buttons[1].iconAlt).toBe("Refresh");
+
+ expect(buttons[2].iconSrc).toBeDefined();
+ expect(buttons[2].iconAlt).toBe("Dark Theme");
+
+ expect(buttons[3].iconSrc).toBeDefined();
+ expect(buttons[3].iconAlt).toBe("Feedback");
+ });
+
+ it("should handle null MonitorCopyJobsRefState ref gracefully", () => {
+ (MonitorCopyJobsRefState as unknown as jest.Mock).mockImplementationOnce((selector) => {
+ const state: { ref: null } = { ref: null };
+ return selector(state);
+ });
+
+ const buttons = getCommandBarButtons(mockExplorer, false);
+ const refreshButton = buttons[1];
+
+ expect(() => refreshButton.onCommandClick({} as React.SyntheticEvent)).not.toThrow();
+ });
+
+ it("should set hasPopup to false for all buttons", () => {
+ const buttons = getCommandBarButtons(mockExplorer, false);
+
+ buttons.forEach((button) => {
+ expect(button.hasPopup).toBe(false);
+ });
+ });
+
+ it("should set commandButtonLabel to undefined for all buttons", () => {
+ const buttons = getCommandBarButtons(mockExplorer, false);
+
+ buttons.forEach((button) => {
+ expect(button.commandButtonLabel).toBeUndefined();
+ });
+ });
+
+ it("should respect disabled state when provided", () => {
+ const buttons = getCommandBarButtons(mockExplorer, false);
+
+ buttons.forEach((button) => {
+ expect(button.disabled).toBe(false);
+ });
+ });
+
+ it("should return CommandButtonComponentProps with all required properties", () => {
+ const buttons = getCommandBarButtons(mockExplorer, false);
+
+ buttons.forEach((button: CommandButtonComponentProps) => {
+ expect(button).toHaveProperty("iconSrc");
+ expect(button).toHaveProperty("iconAlt");
+ expect(button).toHaveProperty("onCommandClick");
+ expect(button).toHaveProperty("commandButtonLabel");
+ expect(button).toHaveProperty("ariaLabel");
+ expect(button).toHaveProperty("tooltipText");
+ expect(button).toHaveProperty("hasPopup");
+ expect(button).toHaveProperty("disabled");
+ });
+ });
+
+ it("should maintain button order: create, refresh, themeToggle, feedback", () => {
+ const buttons = getCommandBarButtons(mockExplorer, false);
+
+ expect(buttons[0].tooltipText).toBe("Create Copy Job");
+ expect(buttons[1].tooltipText).toBe("Refresh");
+ expect(buttons[2].tooltipText).toBe("Dark Theme");
+ expect(buttons[3].tooltipText).toBe("Feedback");
+ });
+ });
+
+ describe("Button click handlers", () => {
+ it("should execute click handlers without errors", () => {
+ const buttons = getCommandBarButtons(mockExplorer, false);
+
+ buttons.forEach((button) => {
+ expect(() => button.onCommandClick({} as React.SyntheticEvent)).not.toThrow();
+ });
+ });
+
+ it("should call correct action for each button", () => {
+ const buttons = getCommandBarButtons(mockExplorer, false);
+
+ buttons[0].onCommandClick({} as React.SyntheticEvent);
+ expect(Actions.openCreateCopyJobPanel).toHaveBeenCalledWith(mockExplorer);
+
+ buttons[1].onCommandClick({} as React.SyntheticEvent);
+ expect(mockRefreshJobList).toHaveBeenCalled();
+
+ buttons[3].onCommandClick({} as React.SyntheticEvent);
+ expect(mockOpenContainerCopyFeedbackBlade).toHaveBeenCalled();
+ });
+ });
+
+ describe("Accessibility", () => {
+ it("should have aria labels for all buttons", () => {
+ const buttons = getCommandBarButtons(mockExplorer, false);
+
+ buttons.forEach((button) => {
+ expect(button.ariaLabel).toBeDefined();
+ expect(typeof button.ariaLabel).toBe("string");
+ expect(button.ariaLabel.length).toBeGreaterThan(0);
+ });
+ });
+
+ it("should have tooltip text for all buttons", () => {
+ const buttons = getCommandBarButtons(mockExplorer, false);
+
+ buttons.forEach((button) => {
+ expect(button.tooltipText).toBeDefined();
+ expect(typeof button.tooltipText).toBe("string");
+ expect(button.tooltipText.length).toBeGreaterThan(0);
+ });
+ });
+
+ it("should have icon alt text for all buttons", () => {
+ const buttons = getCommandBarButtons(mockExplorer, false);
+
+ buttons.forEach((button) => {
+ expect(button.iconAlt).toBeDefined();
+ expect(typeof button.iconAlt).toBe("string");
+ expect(button.iconAlt.length).toBeGreaterThan(0);
+ });
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CommandBar/Utils.ts b/src/Explorer/ContainerCopy/CommandBar/Utils.ts
index a1472793b..d00b3788d 100644
--- a/src/Explorer/ContainerCopy/CommandBar/Utils.ts
+++ b/src/Explorer/ContainerCopy/CommandBar/Utils.ts
@@ -1,7 +1,10 @@
import AddIcon from "../../../../images/Add.svg";
import FeedbackIcon from "../../../../images/Feedback-Command.svg";
+import MoonIcon from "../../../../images/MoonIcon.svg";
import RefreshIcon from "../../../../images/refresh-cosmos.svg";
+import SunIcon from "../../../../images/SunIcon.svg";
import { configContext, Platform } from "../../../ConfigContext";
+import { useThemeStore } from "../../../hooks/useTheme";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../../Explorer";
import * as Actions from "../Actions/CopyJobActions";
@@ -9,7 +12,7 @@ import ContainerCopyMessages from "../ContainerCopyMessages";
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
import { CopyJobCommandBarBtnType } from "../Types/CopyJobTypes";
-function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] {
+function getCopyJobBtns(explorer: Explorer, isDarkMode: boolean): CopyJobCommandBarBtnType[] {
const monitorCopyJobsRef = MonitorCopyJobsRefState((state) => state.ref);
const buttons: CopyJobCommandBarBtnType[] = [
{
@@ -17,7 +20,7 @@ function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] {
iconSrc: AddIcon,
label: ContainerCopyMessages.createCopyJobButtonLabel,
ariaLabel: ContainerCopyMessages.createCopyJobButtonAriaLabel,
- onClick: Actions.openCreateCopyJobPanel.bind(null, explorer),
+ onClick: () => Actions.openCreateCopyJobPanel(explorer),
},
{
key: "refresh",
@@ -26,7 +29,15 @@ function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] {
ariaLabel: ContainerCopyMessages.refreshButtonAriaLabel,
onClick: () => monitorCopyJobsRef?.refreshJobList(),
},
+ {
+ key: "themeToggle",
+ iconSrc: isDarkMode ? SunIcon : MoonIcon,
+ label: isDarkMode ? "Light Theme" : "Dark Theme",
+ ariaLabel: isDarkMode ? "Switch to Light Theme" : "Switch to Dark Theme",
+ onClick: () => useThemeStore.getState().toggleTheme(),
+ },
];
+
if (configContext.platform === Platform.Portal) {
buttons.push({
key: "feedback",
@@ -54,6 +65,6 @@ function btnMapper(config: CopyJobCommandBarBtnType): CommandButtonComponentProp
};
}
-export function getCommandBarButtons(explorer: Explorer): CommandButtonComponentProps[] {
- return getCopyJobBtns(explorer).map(btnMapper);
+export function getCommandBarButtons(explorer: Explorer, isDarkMode: boolean): CommandButtonComponentProps[] {
+ return getCopyJobBtns(explorer, isDarkMode).map(btnMapper);
}
diff --git a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts
index 526b6ffab..d63f0cfad 100644
--- a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts
+++ b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts
@@ -25,7 +25,18 @@ export default {
subscriptionDropdownPlaceholder: "Select a subscription",
sourceAccountDropdownLabel: "Account",
sourceAccountDropdownPlaceholder: "Select an account",
- migrationTypeCheckboxLabel: "Copy container in offline mode",
+ migrationTypeOptions: {
+ offline: {
+ title: "Offline mode",
+ description:
+ "Offline container copy jobs let you copy data from a source container to a destination Cosmos DB container for supported APIs. To ensure data integrity between the source and destination, we recommend stopping updates on the source container before creating the copy job. Learn more about [offline copy jobs](https://learn.microsoft.com/azure/cosmos-db/how-to-container-copy?tabs=offline-copy&pivots=api-nosql).",
+ },
+ online: {
+ title: "Online mode",
+ description:
+ "Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the [All Versions and Delete](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about [online copy jobs](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started).",
+ },
+ },
// Select Source and Target Containers Screen
selectSourceAndTargetContainersDescription:
@@ -55,13 +66,15 @@ export default {
"To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.",
intraAccountOnlineDescription: (accountName: string) =>
`Follow the steps below to enable online copy on your "${accountName}" account.`,
- commonConfiguration: {
- title: "Common configuration",
- description: "Basic permissions required for copy operations",
+ crossAccountConfiguration: {
+ title: "Cross-account container copy",
+ description: (sourceAccount: string, destinationAccount: string) =>
+ `Please follow the instruction below to grant requisite permissions to copy data from "${sourceAccount}" to "${destinationAccount}".`,
},
onlineConfiguration: {
- title: "Online copy configuration",
- description: "Additional permissions required for online copy operations",
+ title: "Online container copy",
+ description: (accountName: string) =>
+ `Please follow the instructions below to enable online copy on your "${accountName}" account.`,
},
},
toggleBtn: {
@@ -87,7 +100,7 @@ export default {
enablementTitle: "Enable system assigned managed identity",
enablementDescription: (accountName: string) =>
accountName
- ? `Enable system-assigned managed identity on the ${accountName}. To confirm, click the "Yes" button. `
+ ? `Enable system-assigned managed identity on the ${accountName}. To confirm, click the "Yes" button.`
: "",
},
defaultManagedIdentity: {
@@ -114,7 +127,7 @@ export default {
},
popoverTitle: "Read permissions assigned to default identity.",
popoverDescription:
- "Assign read permissions of the source account to the default identity of the destination account. To confirm click the “Yes” button. ",
+ "Assign read permissions of the source account to the default identity of the destination account. To confirm click the “Yes” button.",
},
pointInTimeRestore: {
title: "Point In Time Restore enabled",
@@ -129,10 +142,17 @@ export default {
},
onlineCopyEnabled: {
title: "Online copy enabled",
- description: (accountName: string) => `Enable Online copy on "${accountName}".`,
+ description: (accountName: string) =>
+ `Enable online container copy by clicking the button below on your "${accountName}" account.`,
hrefText: "Learn more about online copy jobs",
href: "https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy",
buttonText: "Enable Online Copy",
+ validateAllVersionsAndDeletesChangeFeedSpinnerLabel:
+ "Validating All versions and deletes change feed mode (preview)...",
+ enablingAllVersionsAndDeletesChangeFeedSpinnerLabel:
+ "Enabling All versions and deletes change feed mode (preview)...",
+ enablingOnlineCopySpinnerLabel: (accountName: string) =>
+ `Enabling online copy on your "${accountName}" account ...`,
},
MonitorJobs: {
Columns: {
@@ -153,10 +173,10 @@ export default {
viewDetails: "View Details",
},
Status: {
- Pending: "Pending",
- InProgress: "In Progress",
- Running: "In Progress",
- Partitioning: "In Progress",
+ Pending: "Queued",
+ InProgress: "Running",
+ Running: "Running",
+ Partitioning: "Running",
Paused: "Paused",
Completed: "Completed",
Failed: "Failed",
diff --git a/src/Explorer/ContainerCopy/ContainerCopyPanel.test.tsx b/src/Explorer/ContainerCopy/ContainerCopyPanel.test.tsx
new file mode 100644
index 000000000..0f559e026
--- /dev/null
+++ b/src/Explorer/ContainerCopy/ContainerCopyPanel.test.tsx
@@ -0,0 +1,131 @@
+import "@testing-library/jest-dom";
+import { render, screen, waitFor } from "@testing-library/react";
+import React from "react";
+import Explorer from "../Explorer";
+import ContainerCopyPanel from "./ContainerCopyPanel";
+import { MonitorCopyJobsRefState } from "./MonitorCopyJobs/MonitorCopyJobRefState";
+
+jest.mock("./CommandBar/CopyJobCommandBar", () => {
+ const MockCopyJobCommandBar = () => {
+ return CopyJobCommandBar
;
+ };
+ MockCopyJobCommandBar.displayName = "CopyJobCommandBar";
+ return MockCopyJobCommandBar;
+});
+
+jest.mock("./MonitorCopyJobs/MonitorCopyJobs", () => {
+ const React = jest.requireActual("react");
+ const MockMonitorCopyJobs = React.forwardRef((_props: any, ref: any) => {
+ React.useImperativeHandle(ref, () => ({
+ refreshJobList: jest.fn(),
+ }));
+ return MonitorCopyJobs
;
+ });
+ MockMonitorCopyJobs.displayName = "MonitorCopyJobs";
+ return MockMonitorCopyJobs;
+});
+
+jest.mock("./MonitorCopyJobs/MonitorCopyJobRefState", () => ({
+ MonitorCopyJobsRefState: {
+ getState: jest.fn(() => ({
+ setRef: jest.fn(),
+ })),
+ },
+}));
+
+describe("ContainerCopyPanel", () => {
+ let mockExplorer: Explorer;
+ let mockSetRef: jest.Mock;
+
+ beforeEach(() => {
+ mockExplorer = {} as Explorer;
+
+ mockSetRef = jest.fn();
+ (MonitorCopyJobsRefState.getState as jest.Mock).mockReturnValue({
+ setRef: mockSetRef,
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("renders the component with correct structure", () => {
+ render( );
+
+ const wrapper = document.querySelector("#containerCopyWrapper");
+ expect(wrapper).toBeInTheDocument();
+ expect(wrapper).toHaveClass("flexContainer", "hideOverflows");
+ });
+
+ it("renders CopyJobCommandBar component", () => {
+ render( );
+
+ const commandBar = screen.getByTestId("copy-job-command-bar");
+ expect(commandBar).toBeInTheDocument();
+ expect(commandBar).toHaveTextContent("CopyJobCommandBar");
+ });
+
+ it("renders MonitorCopyJobs component", () => {
+ render( );
+
+ const monitorCopyJobs = screen.getByTestId("monitor-copy-jobs");
+ expect(monitorCopyJobs).toBeInTheDocument();
+ expect(monitorCopyJobs).toHaveTextContent("MonitorCopyJobs");
+ });
+
+ it("passes explorer prop to child components", () => {
+ render( );
+
+ expect(screen.getByTestId("copy-job-command-bar")).toBeInTheDocument();
+ expect(screen.getByTestId("monitor-copy-jobs")).toBeInTheDocument();
+ });
+
+ it("sets the MonitorCopyJobs ref in the state on mount", async () => {
+ render( );
+
+ await waitFor(() => {
+ expect(mockSetRef).toHaveBeenCalledTimes(1);
+ });
+
+ const refArgument = mockSetRef.mock.calls[0][0];
+ expect(refArgument).toBeDefined();
+ expect(refArgument).toHaveProperty("refreshJobList");
+ expect(typeof refArgument.refreshJobList).toBe("function");
+ });
+
+ it("updates the ref state when monitorCopyJobsRef changes", async () => {
+ const { rerender } = render( );
+ await waitFor(() => {
+ expect(mockSetRef).toHaveBeenCalledTimes(1);
+ });
+ mockSetRef.mockClear();
+ rerender( );
+ });
+
+ it("handles missing explorer prop gracefully", () => {
+ const { container } = render( );
+ expect(container.querySelector("#containerCopyWrapper")).toBeInTheDocument();
+ });
+
+ it("applies correct CSS classes to wrapper", () => {
+ render( );
+
+ const wrapper = document.querySelector("#containerCopyWrapper");
+ expect(wrapper).toHaveClass("flexContainer");
+ expect(wrapper).toHaveClass("hideOverflows");
+ });
+
+ it("maintains ref across re-renders", async () => {
+ const { rerender } = render( );
+
+ await waitFor(() => {
+ expect(mockSetRef).toHaveBeenCalled();
+ });
+
+ const firstCallRef = mockSetRef.mock.calls[0][0];
+ const newExplorer = {} as Explorer;
+ rerender( );
+ expect(mockSetRef.mock.calls[0][0]).toBe(firstCallRef);
+ });
+});
diff --git a/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx b/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx
index 2d7cccb87..1c82ad4a6 100644
--- a/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx
+++ b/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx
@@ -20,4 +20,6 @@ const ContainerCopyPanel: React.FC = ({ explorer }) => {
);
};
+ContainerCopyPanel.displayName = "ContainerCopyPanel";
+
export default ContainerCopyPanel;
diff --git a/src/Explorer/ContainerCopy/Context/CopyJobContext.test.tsx b/src/Explorer/ContainerCopy/Context/CopyJobContext.test.tsx
new file mode 100644
index 000000000..7a0e7d874
--- /dev/null
+++ b/src/Explorer/ContainerCopy/Context/CopyJobContext.test.tsx
@@ -0,0 +1,660 @@
+import "@testing-library/jest-dom";
+import { act, render, screen } from "@testing-library/react";
+import React from "react";
+import Explorer from "../../Explorer";
+import { CopyJobMigrationType } from "../Enums/CopyJobEnums";
+import CopyJobContextProvider, { CopyJobContext, useCopyJobContext } from "./CopyJobContext";
+
+jest.mock("UserContext", () => ({
+ userContext: {
+ subscriptionId: "test-subscription-id",
+ databaseAccount: {
+ id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
+ name: "test-account",
+ location: "East US",
+ kind: "GlobalDocumentDB",
+ },
+ },
+}));
+
+describe("CopyJobContext", () => {
+ let mockExplorer: Explorer;
+
+ beforeEach(() => {
+ mockExplorer = {} as Explorer;
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("CopyJobContextProvider", () => {
+ it("should render children correctly", () => {
+ render(
+
+ Test Child
+ ,
+ );
+
+ expect(screen.getByTestId("test-child")).toBeInTheDocument();
+ expect(screen.getByTestId("test-child")).toHaveTextContent("Test Child");
+ });
+
+ it("should initialize with default state", () => {
+ let contextValue: any;
+
+ render(
+
+
+ {(value) => {
+ contextValue = value;
+ return null;
+ }}
+
+ ,
+ );
+
+ expect(contextValue).toBeDefined();
+ expect(contextValue.copyJobState).toEqual({
+ jobName: "",
+ migrationType: CopyJobMigrationType.Offline,
+ source: {
+ subscription: null,
+ account: null,
+ databaseId: "",
+ containerId: "",
+ },
+ target: {
+ subscriptionId: "test-subscription-id",
+ account: {
+ id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
+ name: "test-account",
+ location: "East US",
+ kind: "GlobalDocumentDB",
+ },
+ databaseId: "",
+ containerId: "",
+ },
+ sourceReadAccessFromTarget: false,
+ });
+ expect(contextValue.flow).toBeNull();
+ expect(contextValue.contextError).toBeNull();
+ expect(contextValue.explorer).toBe(mockExplorer);
+ });
+
+ it("should provide setCopyJobState function", () => {
+ let contextValue: any;
+
+ render(
+
+
+ {(value) => {
+ contextValue = value;
+ return null;
+ }}
+
+ ,
+ );
+
+ expect(contextValue.setCopyJobState).toBeDefined();
+ expect(typeof contextValue.setCopyJobState).toBe("function");
+ });
+
+ it("should provide setFlow function", () => {
+ let contextValue: any;
+
+ render(
+
+
+ {(value) => {
+ contextValue = value;
+ return null;
+ }}
+
+ ,
+ );
+
+ expect(contextValue.setFlow).toBeDefined();
+ expect(typeof contextValue.setFlow).toBe("function");
+ });
+
+ it("should provide setContextError function", () => {
+ let contextValue: any;
+
+ render(
+
+
+ {(value) => {
+ contextValue = value;
+ return null;
+ }}
+
+ ,
+ );
+
+ expect(contextValue.setContextError).toBeDefined();
+ expect(typeof contextValue.setContextError).toBe("function");
+ });
+
+ it("should provide resetCopyJobState function", () => {
+ let contextValue: any;
+
+ render(
+
+
+ {(value) => {
+ contextValue = value;
+ return null;
+ }}
+
+ ,
+ );
+
+ expect(contextValue.resetCopyJobState).toBeDefined();
+ expect(typeof contextValue.resetCopyJobState).toBe("function");
+ });
+
+ it("should update copyJobState when setCopyJobState is called", () => {
+ let contextValue: any;
+
+ const TestComponent = (): JSX.Element => {
+ const context = useCopyJobContext();
+ contextValue = context;
+
+ return (
+
+ context.setCopyJobState({
+ ...context.copyJobState,
+ jobName: "test-job",
+ migrationType: CopyJobMigrationType.Online,
+ })
+ }
+ >
+ Update Job
+
+ );
+ };
+
+ render(
+
+
+ ,
+ );
+
+ const button = screen.getByText("Update Job");
+ act(() => {
+ button.click();
+ });
+
+ expect(contextValue.copyJobState.jobName).toBe("test-job");
+ expect(contextValue.copyJobState.migrationType).toBe(CopyJobMigrationType.Online);
+ });
+
+ it("should update flow when setFlow is called", () => {
+ let contextValue: any;
+
+ const TestComponent = (): JSX.Element => {
+ const context = useCopyJobContext();
+ contextValue = context;
+
+ const handleSetFlow = (): void => {
+ context.setFlow({ currentScreen: "source-selection" });
+ };
+
+ return Set Flow ;
+ };
+
+ render(
+
+
+ ,
+ );
+
+ expect(contextValue.flow).toBeNull();
+
+ const button = screen.getByText("Set Flow");
+ act(() => {
+ button.click();
+ });
+
+ expect(contextValue.flow).toEqual({ currentScreen: "source-selection" });
+ });
+
+ it("should update contextError when setContextError is called", () => {
+ let contextValue: any;
+
+ const TestComponent = (): JSX.Element => {
+ const context = useCopyJobContext();
+ contextValue = context;
+
+ return context.setContextError("Test error message")}>Set Error ;
+ };
+
+ render(
+
+
+ ,
+ );
+
+ expect(contextValue.contextError).toBeNull();
+
+ const button = screen.getByText("Set Error");
+ act(() => {
+ button.click();
+ });
+
+ expect(contextValue.contextError).toBe("Test error message");
+ });
+
+ it("should reset copyJobState when resetCopyJobState is called", () => {
+ let contextValue: any;
+
+ const TestComponent = (): JSX.Element => {
+ const context = useCopyJobContext();
+ contextValue = context;
+
+ const handleUpdate = (): void => {
+ context.setCopyJobState({
+ ...context.copyJobState,
+ jobName: "modified-job",
+ migrationType: CopyJobMigrationType.Online,
+ source: {
+ ...context.copyJobState.source,
+ databaseId: "test-db",
+ containerId: "test-container",
+ },
+ });
+ };
+
+ return (
+ <>
+ Update
+ Reset
+ >
+ );
+ };
+
+ render(
+
+
+ ,
+ );
+
+ const updateButton = screen.getByText("Update");
+ act(() => {
+ updateButton.click();
+ });
+
+ expect(contextValue.copyJobState.jobName).toBe("modified-job");
+ expect(contextValue.copyJobState.migrationType).toBe(CopyJobMigrationType.Online);
+ expect(contextValue.copyJobState.source.databaseId).toBe("test-db");
+
+ const resetButton = screen.getByText("Reset");
+ act(() => {
+ resetButton.click();
+ });
+
+ expect(contextValue.copyJobState.jobName).toBe("");
+ expect(contextValue.copyJobState.migrationType).toBe(CopyJobMigrationType.Offline);
+ expect(contextValue.copyJobState.source.databaseId).toBe("");
+ expect(contextValue.copyJobState.source.containerId).toBe("");
+ });
+
+ it("should maintain explorer reference", () => {
+ let contextValue: any;
+
+ render(
+
+
+ {(value) => {
+ contextValue = value;
+ return null;
+ }}
+
+ ,
+ );
+
+ expect(contextValue.explorer).toBe(mockExplorer);
+ });
+
+ it("should handle multiple state updates correctly", () => {
+ let contextValue: any;
+
+ const TestComponent = (): JSX.Element => {
+ const context = useCopyJobContext();
+ contextValue = context;
+
+ return (
+ <>
+ context.setCopyJobState({ ...context.copyJobState, jobName: "job-1" })}>
+ Update 1
+
+ context.setFlow({ currentScreen: "screen-1" })}>Flow 1
+ context.setContextError("error-1")}>Error 1
+ >
+ );
+ };
+
+ render(
+
+
+ ,
+ );
+
+ act(() => {
+ screen.getByText("Update 1").click();
+ });
+ expect(contextValue.copyJobState.jobName).toBe("job-1");
+
+ act(() => {
+ screen.getByText("Flow 1").click();
+ });
+ expect(contextValue.flow).toEqual({ currentScreen: "screen-1" });
+
+ act(() => {
+ screen.getByText("Error 1").click();
+ });
+ expect(contextValue.contextError).toBe("error-1");
+ });
+
+ it("should handle partial state updates", () => {
+ let contextValue: any;
+
+ const TestComponent = (): JSX.Element => {
+ const context = useCopyJobContext();
+ contextValue = context;
+
+ const handlePartialUpdate = (): void => {
+ context.setCopyJobState((prev) => ({
+ ...prev,
+ jobName: "partial-update",
+ }));
+ };
+
+ return Partial Update ;
+ };
+
+ render(
+
+
+ ,
+ );
+
+ const initialState = { ...contextValue.copyJobState };
+
+ act(() => {
+ screen.getByText("Partial Update").click();
+ });
+
+ expect(contextValue.copyJobState.jobName).toBe("partial-update");
+ expect(contextValue.copyJobState.migrationType).toBe(initialState.migrationType);
+ expect(contextValue.copyJobState.source).toEqual(initialState.source);
+ expect(contextValue.copyJobState.target).toEqual(initialState.target);
+ });
+ });
+
+ describe("useCopyJobContext", () => {
+ it("should return context value when used within provider", () => {
+ let contextValue: any;
+
+ const TestComponent = (): null => {
+ const context = useCopyJobContext();
+ contextValue = context;
+ return null;
+ };
+
+ render(
+
+
+ ,
+ );
+
+ expect(contextValue).toBeDefined();
+ expect(contextValue.copyJobState).toBeDefined();
+ expect(contextValue.setCopyJobState).toBeDefined();
+ expect(contextValue.flow).toBeNull();
+ expect(contextValue.setFlow).toBeDefined();
+ expect(contextValue.contextError).toBeNull();
+ expect(contextValue.setContextError).toBeDefined();
+ expect(contextValue.resetCopyJobState).toBeDefined();
+ expect(contextValue.explorer).toBe(mockExplorer);
+ });
+
+ it("should throw error when used outside provider", () => {
+ const originalError = console.error;
+ console.error = jest.fn();
+
+ const TestComponent = (): null => {
+ useCopyJobContext();
+ return null;
+ };
+
+ expect(() => {
+ render( );
+ }).toThrow("useCopyJobContext must be used within a CopyJobContextProvider");
+
+ console.error = originalError;
+ });
+
+ it("should allow updating state through hook", () => {
+ let contextValue: any;
+
+ const TestComponent = (): JSX.Element => {
+ const context = useCopyJobContext();
+ contextValue = context;
+
+ return (
+
+ context.setCopyJobState({
+ ...context.copyJobState,
+ jobName: "hook-test-job",
+ })
+ }
+ >
+ Update
+
+ );
+ };
+
+ render(
+
+
+ ,
+ );
+
+ act(() => {
+ screen.getByText("Update").click();
+ });
+
+ expect(contextValue.copyJobState.jobName).toBe("hook-test-job");
+ });
+
+ it("should allow resetting state through hook", () => {
+ let contextValue: any;
+
+ const TestComponent = (): JSX.Element => {
+ const context = useCopyJobContext();
+ contextValue = context;
+
+ return (
+ <>
+
+ context.setCopyJobState({
+ ...context.copyJobState,
+ jobName: "modified",
+ source: {
+ ...context.copyJobState.source,
+ databaseId: "modified-db",
+ },
+ })
+ }
+ >
+ Modify
+
+ context.resetCopyJobState()}>Reset
+ >
+ );
+ };
+
+ render(
+
+
+ ,
+ );
+
+ act(() => {
+ screen.getByText("Modify").click();
+ });
+
+ expect(contextValue.copyJobState.jobName).toBe("modified");
+ expect(contextValue.copyJobState.source.databaseId).toBe("modified-db");
+
+ act(() => {
+ screen.getByText("Reset").click();
+ });
+
+ expect(contextValue.copyJobState.jobName).toBe("");
+ expect(contextValue.copyJobState.source.databaseId).toBe("");
+ });
+
+ it("should maintain state consistency across multiple components", () => {
+ let contextValue1: any;
+ let contextValue2: any;
+
+ const TestComponent1 = (): JSX.Element => {
+ const context = useCopyJobContext();
+ contextValue1 = context;
+
+ return (
+
+ context.setCopyJobState({
+ ...context.copyJobState,
+ jobName: "shared-job",
+ })
+ }
+ >
+ Update From Component 1
+
+ );
+ };
+
+ const TestComponent2 = (): JSX.Element => {
+ const context = useCopyJobContext();
+ contextValue2 = context;
+ return Component 2
;
+ };
+
+ render(
+
+
+
+ ,
+ );
+
+ expect(contextValue1.copyJobState).toEqual(contextValue2.copyJobState);
+
+ act(() => {
+ screen.getByText("Update From Component 1").click();
+ });
+
+ expect(contextValue1.copyJobState.jobName).toBe("shared-job");
+ expect(contextValue2.copyJobState.jobName).toBe("shared-job");
+ });
+ });
+
+ describe("Initial State", () => {
+ it("should initialize with offline migration type", () => {
+ let contextValue: any;
+
+ render(
+
+
+ {(value) => {
+ contextValue = value;
+ return null;
+ }}
+
+ ,
+ );
+
+ expect(contextValue.copyJobState.migrationType).toBe(CopyJobMigrationType.Offline);
+ });
+
+ it("should initialize source with userContext values", () => {
+ let contextValue: any;
+
+ render(
+
+
+ {(value) => {
+ contextValue = value;
+ return null;
+ }}
+
+ ,
+ );
+
+ expect(contextValue.copyJobState.source?.subscription?.subscriptionId).toBeUndefined();
+ expect(contextValue.copyJobState.source?.account?.name).toBeUndefined();
+ });
+
+ it("should initialize target with userContext values", () => {
+ let contextValue: any;
+
+ render(
+
+
+ {(value) => {
+ contextValue = value;
+ return null;
+ }}
+
+ ,
+ );
+
+ expect(contextValue.copyJobState.target.subscriptionId).toBe("test-subscription-id");
+ expect(contextValue.copyJobState.target.account.name).toBe("test-account");
+ });
+
+ it("should initialize sourceReadAccessFromTarget as false", () => {
+ let contextValue: any;
+
+ render(
+
+
+ {(value) => {
+ contextValue = value;
+ return null;
+ }}
+
+ ,
+ );
+
+ expect(contextValue.copyJobState.sourceReadAccessFromTarget).toBe(false);
+ });
+
+ it("should initialize with empty database and container ids", () => {
+ let contextValue: any;
+
+ render(
+
+
+ {(value) => {
+ contextValue = value;
+ return null;
+ }}
+
+ ,
+ );
+
+ expect(contextValue.copyJobState.source.databaseId).toBe("");
+ expect(contextValue.copyJobState.source.containerId).toBe("");
+ expect(contextValue.copyJobState.target.databaseId).toBe("");
+ expect(contextValue.copyJobState.target.containerId).toBe("");
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx
index dab4bd3c0..ddb936dcf 100644
--- a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx
+++ b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx
@@ -1,5 +1,4 @@
import Explorer from "Explorer/Explorer";
-import { Subscription } from "Contracts/DataModels";
import React from "react";
import { userContext } from "UserContext";
import { CopyJobMigrationType } from "../Enums/CopyJobEnums";
@@ -24,10 +23,8 @@ const getInitialCopyJobState = (): CopyJobContextState => {
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
- subscription: {
- subscriptionId: userContext.subscriptionId || "",
- } as Subscription,
- account: userContext.databaseAccount || null,
+ subscription: null,
+ account: null,
databaseId: "",
containerId: "",
},
diff --git a/src/Explorer/ContainerCopy/CopyJobUtils.test.ts b/src/Explorer/ContainerCopy/CopyJobUtils.test.ts
new file mode 100644
index 000000000..5c0a2a49d
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CopyJobUtils.test.ts
@@ -0,0 +1,490 @@
+import { DatabaseAccount } from "Contracts/DataModels";
+import * as CopyJobUtils from "./CopyJobUtils";
+import { CopyJobContextState, CopyJobErrorType, CopyJobType } from "./Types/CopyJobTypes";
+
+describe("CopyJobUtils", () => {
+ describe("buildResourceLink", () => {
+ const mockResource: DatabaseAccount = {
+ id: "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1",
+ name: "account1",
+ location: "eastus",
+ type: "Microsoft.DocumentDB/databaseAccounts",
+ kind: "GlobalDocumentDB",
+ properties: {},
+ };
+
+ let originalLocation: Location;
+
+ beforeEach(() => {
+ originalLocation = window.location;
+ });
+
+ afterEach(() => {
+ (window as any).location = originalLocation;
+ });
+
+ it("should build resource link with Azure portal endpoint", () => {
+ delete (window as any).location;
+ (window as any).location = {
+ ...originalLocation,
+ origin: "https://portal.azure.com",
+ ancestorOrigins: ["https://portal.azure.com"] as any,
+ } as Location;
+
+ const link = CopyJobUtils.buildResourceLink(mockResource);
+ expect(link).toBe(
+ "https://portal.azure.com/#resource/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1",
+ );
+ });
+
+ it("should replace cosmos.azure with portal.azure", () => {
+ delete (window as any).location;
+ (window as any).location = {
+ ...originalLocation,
+ origin: "https://cosmos.azure.com",
+ ancestorOrigins: ["https://cosmos.azure.com"] as any,
+ } as Location;
+
+ const link = CopyJobUtils.buildResourceLink(mockResource);
+ expect(link).toContain("https://portal.azure.com");
+ });
+
+ it("should use Azure portal endpoint for localhost", () => {
+ delete (window as any).location;
+ (window as any).location = {
+ ...originalLocation,
+ origin: "http://localhost:1234",
+ ancestorOrigins: ["http://localhost:1234"] as any,
+ } as Location;
+
+ const link = CopyJobUtils.buildResourceLink(mockResource);
+ expect(link).toContain("https://ms.portal.azure.com");
+ });
+
+ it("should remove trailing slash from origin", () => {
+ delete (window as any).location;
+ (window as any).location = {
+ ...originalLocation,
+ origin: "https://portal.azure.com/",
+ ancestorOrigins: ["https://portal.azure.com/"] as any,
+ } as Location;
+
+ const link = CopyJobUtils.buildResourceLink(mockResource);
+ expect(link).toBe(
+ "https://portal.azure.com/#resource/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1",
+ );
+ });
+ });
+
+ describe("buildDataTransferJobPath", () => {
+ it("should build basic path without jobName or action", () => {
+ const path = CopyJobUtils.buildDataTransferJobPath({
+ subscriptionId: "sub123",
+ resourceGroup: "rg1",
+ accountName: "account1",
+ });
+
+ expect(path).toBe(
+ "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1/dataTransferJobs",
+ );
+ });
+
+ it("should build path with jobName", () => {
+ const path = CopyJobUtils.buildDataTransferJobPath({
+ subscriptionId: "sub123",
+ resourceGroup: "rg1",
+ accountName: "account1",
+ jobName: "job1",
+ });
+
+ expect(path).toBe(
+ "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1/dataTransferJobs/job1",
+ );
+ });
+
+ it("should build path with jobName and action", () => {
+ const path = CopyJobUtils.buildDataTransferJobPath({
+ subscriptionId: "sub123",
+ resourceGroup: "rg1",
+ accountName: "account1",
+ jobName: "job1",
+ action: "cancel",
+ });
+
+ expect(path).toBe(
+ "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1/dataTransferJobs/job1/cancel",
+ );
+ });
+ });
+
+ describe("convertTime", () => {
+ it("should convert time string with hours, minutes, and seconds", () => {
+ const result = CopyJobUtils.convertTime("02:30:45");
+ expect(result).toBe("02 hours, 30 minutes, 45 seconds");
+ });
+
+ it("should convert time string with only seconds", () => {
+ const result = CopyJobUtils.convertTime("00:00:30");
+ expect(result).toBe("30 seconds");
+ });
+
+ it("should convert time string with only minutes and seconds", () => {
+ const result = CopyJobUtils.convertTime("00:05:15");
+ expect(result).toBe("05 minutes, 15 seconds");
+ });
+
+ it("should round seconds", () => {
+ const result = CopyJobUtils.convertTime("00:00:45.678");
+ expect(result).toBe("46 seconds");
+ });
+
+ it("should return '0 seconds' for zero time", () => {
+ const result = CopyJobUtils.convertTime("00:00:00");
+ expect(result).toBe("0 seconds");
+ });
+
+ it("should return null for invalid time format", () => {
+ const result = CopyJobUtils.convertTime("invalid");
+ expect(result).toBeNull();
+ });
+
+ it("should return null for incomplete time string", () => {
+ const result = CopyJobUtils.convertTime("10:30");
+ expect(result).toBeNull();
+ });
+
+ it("should pad single digit values", () => {
+ const result = CopyJobUtils.convertTime("1:5:9");
+ expect(result).toBe("01 hours, 05 minutes, 09 seconds");
+ });
+ });
+
+ describe("formatUTCDateTime", () => {
+ it("should format valid UTC date string", () => {
+ const result = CopyJobUtils.formatUTCDateTime("2025-11-26T10:30:00Z");
+ expect(result).not.toBeNull();
+ expect(result?.formattedDateTime).toContain("11/26/25, 10:30:00 AM");
+ expect(result?.timestamp).toBeGreaterThan(0);
+ });
+
+ it("should return null for invalid date string", () => {
+ const result = CopyJobUtils.formatUTCDateTime("invalid-date");
+ expect(result).toBeNull();
+ });
+
+ it("should return timestamp for valid date", () => {
+ const result = CopyJobUtils.formatUTCDateTime("2025-01-01T00:00:00Z");
+ expect(result).not.toBeNull();
+ expect(typeof result?.timestamp).toBe("number");
+ expect(result?.timestamp).toBe(new Date("2025-01-01T00:00:00Z").getTime());
+ });
+ });
+
+ describe("convertToCamelCase", () => {
+ it("should convert string to camel case", () => {
+ const result = CopyJobUtils.convertToCamelCase("hello world");
+ expect(result).toBe("HelloWorld");
+ });
+
+ it("should handle single word", () => {
+ const result = CopyJobUtils.convertToCamelCase("hello");
+ expect(result).toBe("Hello");
+ });
+
+ it("should handle multiple spaces", () => {
+ const result = CopyJobUtils.convertToCamelCase("hello world test");
+ expect(result).toBe("HelloWorldTest");
+ });
+
+ it("should handle mixed case input", () => {
+ const result = CopyJobUtils.convertToCamelCase("HELLO WORLD");
+ expect(result).toBe("HelloWorld");
+ });
+
+ it("should handle empty string", () => {
+ const result = CopyJobUtils.convertToCamelCase("");
+ expect(result).toBe("");
+ });
+ });
+
+ describe("extractErrorMessage", () => {
+ it("should extract first part of error message before line breaks", () => {
+ const error: CopyJobErrorType = {
+ message: "Error occurred\r\n\r\nAdditional details\r\n\r\nMore info",
+ code: "500",
+ };
+
+ const result = CopyJobUtils.extractErrorMessage(error);
+ expect(result.message).toBe("Error occurred");
+ expect(result.code).toBe("500");
+ });
+
+ it("should return same message if no line breaks", () => {
+ const error: CopyJobErrorType = {
+ message: "Simple error message",
+ code: "404",
+ };
+
+ const result = CopyJobUtils.extractErrorMessage(error);
+ expect(result.message).toBe("Simple error message");
+ expect(result.code).toBe("404");
+ });
+ });
+
+ describe("getAccountDetailsFromResourceId", () => {
+ it("should extract account details from valid resource ID", () => {
+ const resourceId =
+ "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1";
+ const details = CopyJobUtils.getAccountDetailsFromResourceId(resourceId);
+
+ expect(details).toEqual({
+ subscriptionId: "sub123",
+ resourceGroup: "rg1",
+ accountName: "account1",
+ });
+ });
+
+ it("should be case insensitive", () => {
+ const resourceId =
+ "/subscriptions/sub123/resourceGroups/rg1/providers/microsoft.documentdb/databaseAccounts/account1";
+ const details = CopyJobUtils.getAccountDetailsFromResourceId(resourceId);
+
+ expect(details).toEqual({
+ subscriptionId: "sub123",
+ resourceGroup: "rg1",
+ accountName: "account1",
+ });
+ });
+
+ it("should return null for undefined resource ID", () => {
+ const details = CopyJobUtils.getAccountDetailsFromResourceId(undefined);
+ expect(details).toBeNull();
+ });
+
+ it("should return null for invalid resource ID", () => {
+ const details = CopyJobUtils.getAccountDetailsFromResourceId("invalid-resource-id");
+ expect(details).toEqual({ accountName: undefined, resourceGroup: undefined, subscriptionId: undefined });
+ });
+ });
+
+ describe("getContainerIdentifiers", () => {
+ it("should extract container identifiers", () => {
+ const container = {
+ account: {
+ id: "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1",
+ name: "account1",
+ location: "eastus",
+ type: "Microsoft.DocumentDB/databaseAccounts",
+ kind: "GlobalDocumentDB",
+ properties: {},
+ },
+ databaseId: "db1",
+ containerId: "container1",
+ } as CopyJobContextState["source"];
+
+ const identifiers = CopyJobUtils.getContainerIdentifiers(container);
+ expect(identifiers).toEqual({
+ accountId: container.account.id,
+ databaseId: "db1",
+ containerId: "container1",
+ });
+ });
+
+ it("should return empty strings for undefined values", () => {
+ const container = {
+ account: undefined,
+ databaseId: undefined,
+ containerId: undefined,
+ } as CopyJobContextState["source"];
+
+ const identifiers = CopyJobUtils.getContainerIdentifiers(container);
+ expect(identifiers).toEqual({
+ accountId: "",
+ databaseId: "",
+ containerId: "",
+ });
+ });
+ });
+
+ describe("isIntraAccountCopy", () => {
+ const sourceAccountId =
+ "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1";
+ const targetAccountId =
+ "/subscriptions/sub123/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1";
+ const differentAccountId =
+ "/subscriptions/sub456/resourceGroups/rg2/providers/Microsoft.DocumentDB/databaseAccounts/account2";
+
+ it("should return true for same account", () => {
+ const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, targetAccountId);
+ expect(result).toBe(true);
+ });
+
+ it("should return false for different accounts", () => {
+ const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, differentAccountId);
+ expect(result).toBe(false);
+ });
+
+ it("should return false for different subscriptions", () => {
+ const differentSubId =
+ "/subscriptions/sub999/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/account1";
+ const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, differentSubId);
+ expect(result).toBe(false);
+ });
+
+ it("should return false for different resource groups", () => {
+ const differentRgId =
+ "/subscriptions/sub123/resourceGroups/rg999/providers/Microsoft.DocumentDB/databaseAccounts/account1";
+ const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, differentRgId);
+ expect(result).toBe(false);
+ });
+
+ it("should return false for undefined source", () => {
+ const result = CopyJobUtils.isIntraAccountCopy(undefined, targetAccountId);
+ expect(result).toBe(false);
+ });
+
+ it("should return false for undefined target", () => {
+ const result = CopyJobUtils.isIntraAccountCopy(sourceAccountId, undefined);
+ expect(result).toBe(false);
+ });
+ });
+
+ describe("isEqual", () => {
+ const createMockJob = (name: string, status: string): CopyJobType => ({
+ ID: name,
+ Mode: "Online",
+ Name: name,
+ Status: status as any,
+ CompletionPercentage: 50,
+ Duration: "00:05:00",
+ LastUpdatedTime: "2025-11-26T10:00:00Z",
+ timestamp: Date.now(),
+ Source: {} as any,
+ Destination: {} as any,
+ });
+
+ it("should return true for equal job arrays", () => {
+ const jobs1 = [createMockJob("job1", "Running"), createMockJob("job2", "Completed")];
+ const jobs2 = [createMockJob("job1", "Running"), createMockJob("job2", "Completed")];
+
+ const result = CopyJobUtils.isEqual(jobs1, jobs2);
+ expect(result).toBe(true);
+ });
+
+ it("should return false for different lengths", () => {
+ const jobs1 = [createMockJob("job1", "Running")];
+ const jobs2 = [createMockJob("job1", "Running"), createMockJob("job2", "Completed")];
+
+ const result = CopyJobUtils.isEqual(jobs1, jobs2);
+ expect(result).toBe(false);
+ });
+
+ it("should return false for different status", () => {
+ const jobs1 = [createMockJob("job1", "Running")];
+ const jobs2 = [createMockJob("job1", "Completed")];
+
+ const result = CopyJobUtils.isEqual(jobs1, jobs2);
+ expect(result).toBe(false);
+ });
+
+ it("should return false for missing job in second array", () => {
+ const jobs1 = [createMockJob("job1", "Running")];
+ const jobs2 = [createMockJob("job2", "Running")];
+
+ const result = CopyJobUtils.isEqual(jobs1, jobs2);
+ expect(result).toBe(false);
+ });
+
+ it("should return true for empty arrays", () => {
+ const result = CopyJobUtils.isEqual([], []);
+ expect(result).toBe(true);
+ });
+ });
+
+ describe("getDefaultJobName", () => {
+ beforeEach(() => {
+ jest.spyOn(Date.prototype, "getTime").mockReturnValue(1234567890);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it("should generate default job name for single container", () => {
+ const containers = [
+ {
+ sourceDatabaseName: "sourceDb",
+ sourceContainerName: "sourceCont",
+ targetDatabaseName: "targetDb",
+ targetContainerName: "targetCont",
+ },
+ ];
+
+ const jobName = CopyJobUtils.getDefaultJobName(containers);
+ expect(jobName).toBe("sourc.sourc_targe.targe_1234567890");
+ });
+
+ it("should truncate long names", () => {
+ const containers = [
+ {
+ sourceDatabaseName: "veryLongSourceDatabaseName",
+ sourceContainerName: "veryLongSourceContainerName",
+ targetDatabaseName: "veryLongTargetDatabaseName",
+ targetContainerName: "veryLongTargetContainerName",
+ },
+ ];
+
+ const jobName = CopyJobUtils.getDefaultJobName(containers);
+ expect(jobName).toBe("veryL.veryL_veryL.veryL_1234567890");
+ });
+
+ it("should return empty string for multiple containers", () => {
+ const containers = [
+ {
+ sourceDatabaseName: "db1",
+ sourceContainerName: "cont1",
+ targetDatabaseName: "db2",
+ targetContainerName: "cont2",
+ },
+ {
+ sourceDatabaseName: "db3",
+ sourceContainerName: "cont3",
+ targetDatabaseName: "db4",
+ targetContainerName: "cont4",
+ },
+ ];
+
+ const jobName = CopyJobUtils.getDefaultJobName(containers);
+ expect(jobName).toBe("");
+ });
+
+ it("should return empty string for empty array", () => {
+ const jobName = CopyJobUtils.getDefaultJobName([]);
+ expect(jobName).toBe("");
+ });
+
+ it("should handle short names without truncation", () => {
+ const containers = [
+ {
+ sourceDatabaseName: "src",
+ sourceContainerName: "cont",
+ targetDatabaseName: "tgt",
+ targetContainerName: "dest",
+ },
+ ];
+
+ const jobName = CopyJobUtils.getDefaultJobName(containers);
+ expect(jobName).toBe("src.cont_tgt.dest_1234567890");
+ });
+ });
+
+ describe("constants", () => {
+ it("should have correct COSMOS_SQL_COMPONENT value", () => {
+ expect(CopyJobUtils.COSMOS_SQL_COMPONENT).toBe("CosmosDBSql");
+ });
+
+ it("should have correct COPY_JOB_API_VERSION value", () => {
+ expect(CopyJobUtils.COPY_JOB_API_VERSION).toBe("2025-05-01-preview");
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CopyJobUtils.ts b/src/Explorer/ContainerCopy/CopyJobUtils.ts
index 75cc4acd2..a84b3d461 100644
--- a/src/Explorer/ContainerCopy/CopyJobUtils.ts
+++ b/src/Explorer/ContainerCopy/CopyJobUtils.ts
@@ -147,7 +147,7 @@ export function isEqual(prevJobs: CopyJobType[], newJobs: CopyJobType[]): boolea
}
const truncateLength = 5;
-const truncateName = (name: string, length: number = truncateLength): string => {
+export const truncateName = (name: string, length: number = truncateLength): string => {
return name.length <= length ? name : name.slice(0, length);
};
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.test.tsx
new file mode 100644
index 000000000..6022b98d4
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.test.tsx
@@ -0,0 +1,295 @@
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { DatabaseAccount } from "Contracts/DataModels";
+import { CopyJobContextProviderType } from "Explorer/ContainerCopy/Types/CopyJobTypes";
+import React from "react";
+import { updateSystemIdentity } from "../../../../../Utils/arm/identityUtils";
+import ContainerCopyMessages from "../../../ContainerCopyMessages";
+import { CopyJobContext } from "../../../Context/CopyJobContext";
+import AddManagedIdentity from "./AddManagedIdentity";
+
+jest.mock("../../../../../Utils/arm/identityUtils", () => ({
+ updateSystemIdentity: jest.fn(),
+}));
+
+jest.mock("@fluentui/react", () => ({
+ ...jest.requireActual("@fluentui/react"),
+ getTheme: () => ({
+ semanticColors: {
+ bodySubtext: "#666666",
+ errorIcon: "#d13438",
+ successIcon: "#107c10",
+ },
+ palette: {
+ themePrimary: "#0078d4",
+ },
+ }),
+ mergeStyles: () => "mocked-styles",
+ mergeStyleSets: (styleSet: any) => {
+ const result: any = {};
+ Object.keys(styleSet).forEach((key) => {
+ result[key] = "mocked-style-" + key;
+ });
+ return result;
+ },
+}));
+
+jest.mock("../../../CopyJobUtils", () => ({
+ getAccountDetailsFromResourceId: jest.fn(() => ({
+ subscriptionId: "test-subscription-id",
+ resourceGroup: "test-resource-group",
+ accountName: "test-account-name",
+ })),
+}));
+
+jest.mock("../../../../../Common/Logger", () => ({
+ logError: jest.fn(),
+}));
+
+const mockUpdateSystemIdentity = updateSystemIdentity as jest.MockedFunction;
+
+describe("AddManagedIdentity", () => {
+ const mockCopyJobState = {
+ jobName: "test-job",
+ migrationType: "Offline" as any,
+ source: {
+ subscription: { subscriptionId: "source-sub-id" },
+ account: { id: "source-account-id", name: "source-account-name" },
+ databaseId: "source-db",
+ containerId: "source-container",
+ },
+ target: {
+ subscriptionId: "target-sub-id",
+ account: {
+ id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
+ name: "test-target-account",
+ },
+ databaseId: "target-db",
+ containerId: "target-container",
+ },
+ sourceReadAccessFromTarget: false,
+ };
+
+ const mockContextValue = {
+ copyJobState: mockCopyJobState,
+ setCopyJobState: jest.fn(),
+ flow: { currentScreen: "AssignPermissions" },
+ setFlow: jest.fn(),
+ resetCopyJobState: jest.fn(),
+ explorer: {} as any,
+ contextError: "",
+ setContextError: jest.fn(),
+ } as unknown as CopyJobContextProviderType;
+
+ const renderWithContext = (contextValue = mockContextValue) => {
+ return render(
+
+
+ ,
+ );
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUpdateSystemIdentity.mockResolvedValue({
+ id: "updated-account-id",
+ name: "updated-account-name",
+ } as any);
+ });
+
+ describe("Snapshot Tests", () => {
+ it("renders initial state correctly", () => {
+ const { container } = renderWithContext();
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("renders with toggle on and popover visible", () => {
+ const { container } = renderWithContext();
+
+ const toggle = screen.getByRole("switch");
+ fireEvent.click(toggle);
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("renders loading state", async () => {
+ mockUpdateSystemIdentity.mockImplementation(
+ () => new Promise((resolve) => setTimeout(() => resolve({} as any), 100)),
+ );
+
+ const { container } = renderWithContext();
+
+ const toggle = screen.getByRole("switch");
+ fireEvent.click(toggle);
+
+ const primaryButton = screen.getByText("Yes");
+ fireEvent.click(primaryButton);
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+ });
+
+ describe("Component Rendering", () => {
+ it("renders all required elements", () => {
+ renderWithContext();
+
+ expect(screen.getByText(ContainerCopyMessages.addManagedIdentity.description)).toBeInTheDocument();
+ expect(screen.getByText(ContainerCopyMessages.addManagedIdentity.descriptionHrefText)).toBeInTheDocument();
+ expect(screen.getByRole("switch")).toBeInTheDocument();
+ });
+
+ it("renders description link with correct href", () => {
+ renderWithContext();
+
+ const link = screen.getByText(ContainerCopyMessages.addManagedIdentity.descriptionHrefText);
+ expect(link.closest("a")).toHaveAttribute("href", ContainerCopyMessages.addManagedIdentity.descriptionHref);
+ expect(link.closest("a")).toHaveAttribute("target", "_blank");
+ expect(link.closest("a")).toHaveAttribute("rel", "noopener noreferrer");
+ });
+
+ it("toggle shows correct initial state", () => {
+ renderWithContext();
+
+ const toggle = screen.getByRole("switch");
+ expect(toggle).not.toBeChecked();
+ });
+ });
+
+ describe("Toggle Functionality", () => {
+ it("toggles state when clicked", () => {
+ renderWithContext();
+
+ const toggle = screen.getByRole("switch");
+ expect(toggle).not.toBeChecked();
+
+ fireEvent.click(toggle);
+ expect(toggle).toBeChecked();
+
+ fireEvent.click(toggle);
+ expect(toggle).not.toBeChecked();
+ });
+
+ it("shows popover when toggle is on", () => {
+ renderWithContext();
+
+ const toggle = screen.getByRole("switch");
+ fireEvent.click(toggle);
+
+ expect(screen.getByText(ContainerCopyMessages.addManagedIdentity.enablementTitle)).toBeInTheDocument();
+ });
+
+ it("hides popover when toggle is off", () => {
+ renderWithContext();
+
+ const toggle = screen.getByRole("switch");
+ fireEvent.click(toggle);
+ fireEvent.click(toggle);
+
+ expect(screen.queryByText(ContainerCopyMessages.addManagedIdentity.enablementTitle)).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Popover Functionality", () => {
+ beforeEach(() => {
+ renderWithContext();
+ const toggle = screen.getByRole("switch");
+ fireEvent.click(toggle);
+ });
+
+ it("displays correct enablement description with account name", () => {
+ const expectedDescription = ContainerCopyMessages.addManagedIdentity.enablementDescription(
+ mockCopyJobState.target.account.name,
+ );
+ expect(screen.getByText(expectedDescription)).toBeInTheDocument();
+ });
+
+ it("calls handleAddSystemIdentity when primary button clicked", async () => {
+ const primaryButton = screen.getByText("Yes");
+ fireEvent.click(primaryButton);
+
+ await waitFor(() => {
+ expect(mockUpdateSystemIdentity).toHaveBeenCalledWith(
+ "test-subscription-id",
+ "test-resource-group",
+ "test-account-name",
+ );
+ });
+ });
+
+ it.skip("closes popover when cancel button clicked", () => {
+ const cancelButton = screen.getByText("Cancel");
+ fireEvent.click(cancelButton);
+
+ expect(screen.queryByText(ContainerCopyMessages.addManagedIdentity.enablementTitle)).not.toBeInTheDocument();
+
+ const toggle = screen.getByRole("switch");
+ expect(toggle).not.toBeChecked();
+ });
+ });
+
+ describe("Managed Identity Operations", () => {
+ it("successfully updates system identity", async () => {
+ const setCopyJobState = jest.fn();
+ const contextWithMockSetter = {
+ ...mockContextValue,
+ setCopyJobState,
+ };
+
+ renderWithContext(contextWithMockSetter);
+
+ const toggle = screen.getByRole("switch");
+ fireEvent.click(toggle);
+
+ const primaryButton = screen.getByText("Yes");
+ fireEvent.click(primaryButton);
+
+ await waitFor(() => {
+ expect(mockUpdateSystemIdentity).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(setCopyJobState).toHaveBeenCalledWith(expect.any(Function));
+ });
+ });
+
+ it("handles error during identity update", async () => {
+ const setContextError = jest.fn();
+ const contextWithErrorHandler = {
+ ...mockContextValue,
+ setContextError,
+ };
+
+ const errorMessage = "Failed to update identity";
+ mockUpdateSystemIdentity.mockRejectedValue(new Error(errorMessage));
+
+ renderWithContext(contextWithErrorHandler);
+
+ const toggle = screen.getByRole("switch");
+ fireEvent.click(toggle);
+
+ const primaryButton = screen.getByText("Yes");
+ fireEvent.click(primaryButton);
+
+ await waitFor(() => {
+ expect(setContextError).toHaveBeenCalledWith(errorMessage);
+ });
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("handles missing target account gracefully", () => {
+ const contextWithoutTargetAccount = {
+ ...mockContextValue,
+ copyJobState: {
+ ...mockCopyJobState,
+ target: {
+ ...mockCopyJobState.target,
+ account: null as DatabaseAccount | null,
+ },
+ },
+ } as unknown as CopyJobContextProviderType;
+
+ expect(() => renderWithContext(contextWithoutTargetAccount)).not.toThrow();
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.tsx
index 1cff2c213..86c59611c 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.tsx
@@ -12,7 +12,12 @@ import useToggle from "./hooks/useToggle";
const managedIdentityTooltip = (
{ContainerCopyMessages.addManagedIdentity.tooltip.content}
-
+
{ContainerCopyMessages.addManagedIdentity.tooltip.hrefText}
@@ -26,7 +31,7 @@ const AddManagedIdentity: React.FC = () => {
return (
-
+
{ContainerCopyMessages.addManagedIdentity.description}
{ContainerCopyMessages.addManagedIdentity.descriptionHrefText}
@@ -35,6 +40,7 @@ const AddManagedIdentity: React.FC = () => {
({
+ logError: jest.fn(),
+}));
+
+jest.mock("../../../../../Utils/arm/RbacUtils", () => ({
+ assignRole: jest.fn(),
+}));
+
+jest.mock("../../../CopyJobUtils", () => ({
+ getAccountDetailsFromResourceId: jest.fn(),
+}));
+
+jest.mock("../Components/InfoTooltip", () => {
+ const MockInfoTooltip = ({ content }: { content: React.ReactNode }) => {
+ return {content}
;
+ };
+ MockInfoTooltip.displayName = "MockInfoTooltip";
+ return MockInfoTooltip;
+});
+
+jest.mock("../Components/PopoverContainer", () => {
+ const MockPopoverContainer = ({
+ isLoading,
+ visible,
+ title,
+ onCancel,
+ onPrimary,
+ children,
+ }: {
+ isLoading?: boolean;
+ visible: boolean;
+ title: string;
+ onCancel: () => void;
+ onPrimary: () => void;
+ children: React.ReactNode;
+ }) => {
+ if (!visible) {
+ return null;
+ }
+ return (
+
+
{title}
+
{children}
+
+ Cancel
+
+
+ Primary
+
+
+ );
+ };
+ MockPopoverContainer.displayName = "MockPopoverContainer";
+ return MockPopoverContainer;
+});
+
+jest.mock("./hooks/useToggle", () => {
+ return jest.fn();
+});
+
+import { Subscription } from "Contracts/DataModels";
+import { CopyJobMigrationType } from "Explorer/ContainerCopy/Enums/CopyJobEnums";
+import { logError } from "../../../../../Common/Logger";
+import { assignRole, RoleAssignmentType } from "../../../../../Utils/arm/RbacUtils";
+import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
+import useToggle from "./hooks/useToggle";
+
+describe("AddReadPermissionToDefaultIdentity Component", () => {
+ const mockUseToggle = useToggle as jest.MockedFunction;
+ const mockAssignRole = assignRole as jest.MockedFunction;
+ const mockGetAccountDetailsFromResourceId = getAccountDetailsFromResourceId as jest.MockedFunction<
+ typeof getAccountDetailsFromResourceId
+ >;
+ const mockLogError = logError as jest.MockedFunction;
+
+ const mockContextValue: CopyJobContextProviderType = {
+ copyJobState: {
+ jobName: "test-job",
+ migrationType: CopyJobMigrationType.Offline,
+ source: {
+ subscription: { subscriptionId: "source-sub-id" } as Subscription,
+ account: {
+ id: "/subscriptions/source-sub-id/resourceGroups/source-rg/providers/Microsoft.DocumentDB/databaseAccounts/source-account",
+ name: "source-account",
+ location: "East US",
+ kind: "GlobalDocumentDB",
+ type: "Microsoft.DocumentDB/databaseAccounts",
+ properties: {
+ documentEndpoint: "https://source-account.documents.azure.com:443/",
+ },
+ },
+ databaseId: "source-db",
+ containerId: "source-container",
+ },
+ target: {
+ subscriptionId: "target-sub-id",
+ account: {
+ id: "/subscriptions/target-sub-id/resourceGroups/target-rg/providers/Microsoft.DocumentDB/databaseAccounts/target-account",
+ name: "target-account",
+ location: "West US",
+ kind: "GlobalDocumentDB",
+ type: "Microsoft.DocumentDB/databaseAccounts",
+ properties: {
+ documentEndpoint: "https://target-account.documents.azure.com:443/",
+ },
+ identity: {
+ principalId: "target-principal-id",
+ type: "SystemAssigned",
+ },
+ },
+ databaseId: "target-db",
+ containerId: "target-container",
+ },
+ sourceReadAccessFromTarget: false,
+ },
+ setCopyJobState: jest.fn(),
+ setContextError: jest.fn(),
+ contextError: null,
+ flow: null,
+ setFlow: jest.fn(),
+ resetCopyJobState: jest.fn(),
+ explorer: {} as any,
+ };
+
+ const renderComponent = (contextValue = mockContextValue) => {
+ return render(
+
+
+ ,
+ );
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseToggle.mockReturnValue([false, jest.fn()]);
+ });
+
+ describe("Rendering", () => {
+ it("should render correctly with default state", () => {
+ const { container } = renderComponent();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should render correctly when toggle is on", () => {
+ mockUseToggle.mockReturnValue([true, jest.fn()]);
+ const { container } = renderComponent();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should render correctly with different context states", () => {
+ const contextWithError = {
+ ...mockContextValue,
+ contextError: "Test error message",
+ };
+ const { container } = renderComponent(contextWithError);
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should render correctly when sourceReadAccessFromTarget is true", () => {
+ const contextWithAccess = {
+ ...mockContextValue,
+ copyJobState: {
+ ...mockContextValue.copyJobState,
+ sourceReadAccessFromTarget: true,
+ },
+ };
+ const { container } = renderComponent(contextWithAccess);
+ expect(container).toMatchSnapshot();
+ });
+ });
+
+ describe("Component Structure", () => {
+ it("should display the description text", () => {
+ renderComponent();
+ expect(screen.getByText(ContainerCopyMessages.readPermissionAssigned.description)).toBeInTheDocument();
+ });
+
+ it("should display the info tooltip", () => {
+ renderComponent();
+ expect(screen.getByTestId("info-tooltip")).toBeInTheDocument();
+ });
+
+ it("should display the toggle component", () => {
+ renderComponent();
+ expect(screen.getByRole("switch")).toBeInTheDocument();
+ });
+ });
+
+ describe("Toggle Interaction", () => {
+ it("should call onToggle when toggle is clicked", () => {
+ const mockOnToggle = jest.fn();
+ mockUseToggle.mockReturnValue([false, mockOnToggle]);
+
+ renderComponent();
+ const toggle = screen.getByRole("switch");
+
+ fireEvent.click(toggle);
+ expect(mockOnToggle).toHaveBeenCalledTimes(1);
+ });
+
+ it("should show popover when toggle is turned on", () => {
+ mockUseToggle.mockReturnValue([true, jest.fn()]);
+ renderComponent();
+
+ expect(screen.getByTestId("popover-message")).toBeInTheDocument();
+ expect(screen.getByTestId("popover-title")).toHaveTextContent(
+ ContainerCopyMessages.readPermissionAssigned.popoverTitle,
+ );
+ expect(screen.getByTestId("popover-content")).toHaveTextContent(
+ ContainerCopyMessages.readPermissionAssigned.popoverDescription,
+ );
+ });
+
+ it("should not show popover when toggle is turned off", () => {
+ mockUseToggle.mockReturnValue([false, jest.fn()]);
+ renderComponent();
+
+ expect(screen.queryByTestId("popover-message")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Popover Interactions", () => {
+ beforeEach(() => {
+ mockUseToggle.mockReturnValue([true, jest.fn()]);
+ });
+
+ it("should call onToggle with false when cancel button is clicked", () => {
+ const mockOnToggle = jest.fn();
+ mockUseToggle.mockReturnValue([true, mockOnToggle]);
+
+ renderComponent();
+ const cancelButton = screen.getByTestId("popover-cancel");
+
+ fireEvent.click(cancelButton);
+ expect(mockOnToggle).toHaveBeenCalledWith(null, false);
+ });
+
+ it("should call handleAddReadPermission when primary button is clicked", async () => {
+ mockGetAccountDetailsFromResourceId.mockReturnValue({
+ subscriptionId: "source-sub-id",
+ resourceGroup: "source-rg",
+ accountName: "source-account",
+ });
+ mockAssignRole.mockResolvedValue({ id: "role-assignment-id" } as RoleAssignmentType);
+
+ renderComponent();
+ const primaryButton = screen.getByTestId("popover-primary");
+
+ fireEvent.click(primaryButton);
+
+ await waitFor(() => {
+ expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalledWith(
+ "/subscriptions/source-sub-id/resourceGroups/source-rg/providers/Microsoft.DocumentDB/databaseAccounts/source-account",
+ );
+ });
+ });
+ });
+
+ describe("handleAddReadPermission Function", () => {
+ beforeEach(() => {
+ mockUseToggle.mockReturnValue([true, jest.fn()]);
+ });
+
+ it("should successfully assign role and update context", async () => {
+ mockGetAccountDetailsFromResourceId.mockReturnValue({
+ subscriptionId: "source-sub-id",
+ resourceGroup: "source-rg",
+ accountName: "source-account",
+ });
+ mockAssignRole.mockResolvedValue({ id: "role-assignment-id" } as RoleAssignmentType);
+
+ renderComponent();
+ const primaryButton = screen.getByTestId("popover-primary");
+
+ fireEvent.click(primaryButton);
+
+ await waitFor(() => {
+ expect(mockAssignRole).toHaveBeenCalledWith(
+ "source-sub-id",
+ "source-rg",
+ "source-account",
+ "target-principal-id",
+ );
+ });
+
+ await waitFor(() => {
+ expect(mockContextValue.setCopyJobState).toHaveBeenCalledWith(expect.any(Function));
+ });
+ });
+
+ it("should handle error when assignRole fails", async () => {
+ mockGetAccountDetailsFromResourceId.mockReturnValue({
+ subscriptionId: "source-sub-id",
+ resourceGroup: "source-rg",
+ accountName: "source-account",
+ });
+ mockAssignRole.mockRejectedValue(new Error("Permission denied"));
+
+ renderComponent();
+ const primaryButton = screen.getByTestId("popover-primary");
+
+ fireEvent.click(primaryButton);
+
+ await waitFor(() => {
+ expect(mockLogError).toHaveBeenCalledWith(
+ "Permission denied",
+ "CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission",
+ );
+ });
+
+ await waitFor(() => {
+ expect(mockContextValue.setContextError).toHaveBeenCalledWith("Permission denied");
+ });
+ });
+
+ it("should handle error without message", async () => {
+ mockGetAccountDetailsFromResourceId.mockReturnValue({
+ subscriptionId: "source-sub-id",
+ resourceGroup: "source-rg",
+ accountName: "source-account",
+ });
+ mockAssignRole.mockRejectedValue({});
+
+ renderComponent();
+ const primaryButton = screen.getByTestId("popover-primary");
+
+ fireEvent.click(primaryButton);
+
+ await waitFor(() => {
+ expect(mockLogError).toHaveBeenCalledWith(
+ "Error assigning read permission to default identity. Please try again later.",
+ "CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission",
+ );
+ });
+
+ await waitFor(() => {
+ expect(mockContextValue.setContextError).toHaveBeenCalledWith(
+ "Error assigning read permission to default identity. Please try again later.",
+ );
+ });
+ });
+
+ it("should show loading state during role assignment", async () => {
+ mockGetAccountDetailsFromResourceId.mockReturnValue({
+ subscriptionId: "source-sub-id",
+ resourceGroup: "source-rg",
+ accountName: "source-account",
+ });
+
+ mockAssignRole.mockImplementation(
+ () => new Promise((resolve) => setTimeout(() => resolve({ id: "role-id" } as RoleAssignmentType), 100)),
+ );
+
+ renderComponent();
+ const primaryButton = screen.getByTestId("popover-primary");
+
+ fireEvent.click(primaryButton);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("popover-message")).toHaveAttribute("data-loading", "true");
+ });
+ });
+
+ it.skip("should not assign role when assignRole returns falsy", async () => {
+ mockGetAccountDetailsFromResourceId.mockReturnValue({
+ subscriptionId: "source-sub-id",
+ resourceGroup: "source-rg",
+ accountName: "source-account",
+ });
+ mockAssignRole.mockResolvedValue(null);
+
+ renderComponent();
+ const primaryButton = screen.getByTestId("popover-primary");
+
+ fireEvent.click(primaryButton);
+
+ await waitFor(() => {
+ expect(mockAssignRole).toHaveBeenCalled();
+ });
+
+ expect(mockContextValue.setCopyJobState).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("should handle missing target account identity", () => {
+ const contextWithoutIdentity = {
+ ...mockContextValue,
+ copyJobState: {
+ ...mockContextValue.copyJobState,
+ target: {
+ ...mockContextValue.copyJobState.target,
+ account: {
+ ...mockContextValue.copyJobState.target.account!,
+ identity: undefined as any,
+ },
+ },
+ },
+ };
+
+ const { container } = renderComponent(contextWithoutIdentity);
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should handle missing source account", () => {
+ const contextWithoutSource = {
+ ...mockContextValue,
+ copyJobState: {
+ ...mockContextValue.copyJobState,
+ source: {
+ ...mockContextValue.copyJobState.source,
+ account: null as any,
+ },
+ },
+ };
+
+ const { container } = renderComponent(contextWithoutSource);
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should handle empty string principal ID", async () => {
+ const contextWithEmptyPrincipal = {
+ ...mockContextValue,
+ copyJobState: {
+ ...mockContextValue.copyJobState,
+ target: {
+ ...mockContextValue.copyJobState.target,
+ account: {
+ ...mockContextValue.copyJobState.target.account!,
+ identity: {
+ principalId: "",
+ type: "SystemAssigned",
+ },
+ },
+ },
+ },
+ };
+
+ mockUseToggle.mockReturnValue([true, jest.fn()]);
+ mockGetAccountDetailsFromResourceId.mockReturnValue({
+ subscriptionId: "source-sub-id",
+ resourceGroup: "source-rg",
+ accountName: "source-account",
+ });
+ mockAssignRole.mockResolvedValue({ id: "role-assignment-id" } as RoleAssignmentType);
+
+ renderComponent(contextWithEmptyPrincipal);
+ const primaryButton = screen.getByTestId("popover-primary");
+
+ fireEvent.click(primaryButton);
+
+ await waitFor(() => {
+ expect(mockAssignRole).toHaveBeenCalledWith("source-sub-id", "source-rg", "source-account", "");
+ });
+ });
+ });
+
+ describe("Component Integration", () => {
+ it("should work with all context updates", async () => {
+ const setCopyJobStateMock = jest.fn();
+ const setContextErrorMock = jest.fn();
+
+ const fullContextValue = {
+ ...mockContextValue,
+ setCopyJobState: setCopyJobStateMock,
+ setContextError: setContextErrorMock,
+ };
+
+ mockUseToggle.mockReturnValue([true, jest.fn()]);
+ mockGetAccountDetailsFromResourceId.mockReturnValue({
+ subscriptionId: "source-sub-id",
+ resourceGroup: "source-rg",
+ accountName: "source-account",
+ });
+ mockAssignRole.mockResolvedValue({ id: "role-assignment-id" } as RoleAssignmentType);
+
+ renderComponent(fullContextValue);
+ const primaryButton = screen.getByTestId("popover-primary");
+
+ fireEvent.click(primaryButton);
+
+ await waitFor(() => {
+ expect(setCopyJobStateMock).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ const setCopyJobStateCall = setCopyJobStateMock.mock.calls[0][0];
+ const updatedState = setCopyJobStateCall(mockContextValue.copyJobState);
+
+ expect(updatedState).toEqual({
+ ...mockContextValue.copyJobState,
+ sourceReadAccessFromTarget: true,
+ });
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx
index 160c6d973..5af5630d7 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx
@@ -1,5 +1,5 @@
import { Link, Stack, Text, Toggle } from "@fluentui/react";
-import React, { useCallback } from "react";
+import React from "react";
import { logError } from "../../../../../Common/Logger";
import { assignRole } from "../../../../../Utils/arm/RbacUtils";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
@@ -13,7 +13,12 @@ import useToggle from "./hooks/useToggle";
const TooltipContent = (
{ContainerCopyMessages.readPermissionAssigned.tooltip.content}
-
+
{ContainerCopyMessages.readPermissionAssigned.tooltip.hrefText}
@@ -25,7 +30,7 @@ const AddReadPermissionToDefaultIdentity: React.FC {
+ const handleAddReadPermission = async () => {
const { source, target } = copyJobState;
const selectedSourceAccount = source?.account;
try {
@@ -56,7 +61,7 @@ const AddReadPermissionToDefaultIdentity: React.FC
@@ -65,6 +70,7 @@ const AddReadPermissionToDefaultIdentity: React.FC
({
+ useCopyJobPrerequisitesCache: () => ({
+ validationCache: new Map(),
+ setValidationCache: jest.fn(),
+ }),
+}));
+
+jest.mock("../../../CopyJobUtils", () => ({
+ isIntraAccountCopy: jest.fn((sourceId: string, targetId: string) => sourceId === targetId),
+}));
+
+jest.mock("./hooks/usePermissionsSection", () => ({
+ __esModule: true,
+ default: jest.fn((): any[] => []),
+}));
+
+jest.mock("../../../../../Common/ShimmerTree/ShimmerTree", () => {
+ const MockShimmerTree = (props: any) => {
+ return (
+
+ Loading...
+
+ );
+ };
+ MockShimmerTree.displayName = "MockShimmerTree";
+ return MockShimmerTree;
+});
+
+jest.mock("./AddManagedIdentity", () => {
+ const MockAddManagedIdentity = () => {
+ return Add Managed Identity Component
;
+ };
+ MockAddManagedIdentity.displayName = "MockAddManagedIdentity";
+ return MockAddManagedIdentity;
+});
+
+jest.mock("./AddReadPermissionToDefaultIdentity", () => {
+ const MockAddReadPermissionToDefaultIdentity = () => {
+ return Add Read Permission Component
;
+ };
+ MockAddReadPermissionToDefaultIdentity.displayName = "MockAddReadPermissionToDefaultIdentity";
+ return MockAddReadPermissionToDefaultIdentity;
+});
+
+jest.mock("./DefaultManagedIdentity", () => {
+ const MockDefaultManagedIdentity = () => {
+ return Default Managed Identity Component
;
+ };
+ MockDefaultManagedIdentity.displayName = "MockDefaultManagedIdentity";
+ return MockDefaultManagedIdentity;
+});
+
+jest.mock("./OnlineCopyEnabled", () => {
+ const MockOnlineCopyEnabled = () => {
+ return Online Copy Enabled Component
;
+ };
+ MockOnlineCopyEnabled.displayName = "MockOnlineCopyEnabled";
+ return MockOnlineCopyEnabled;
+});
+
+jest.mock("./PointInTimeRestore", () => {
+ const MockPointInTimeRestore = () => {
+ return Point In Time Restore Component
;
+ };
+ MockPointInTimeRestore.displayName = "MockPointInTimeRestore";
+ return MockPointInTimeRestore;
+});
+
+jest.mock("../../../../../../images/successfulPopup.svg", () => "checkmark-icon");
+jest.mock("../../../../../../images/warning.svg", () => "warning-icon");
+
+describe("AssignPermissions Component", () => {
+ const mockExplorer = {} as any;
+
+ const createMockCopyJobState = (overrides: Partial = {}): CopyJobContextState => ({
+ jobName: "test-job",
+ migrationType: CopyJobMigrationType.Offline,
+ source: {
+ subscription: { subscriptionId: "source-sub" } as any,
+ account: { id: "source-account", name: "Source Account" } as any,
+ databaseId: "source-db",
+ containerId: "source-container",
+ },
+ target: {
+ subscriptionId: "target-sub",
+ account: { id: "target-account", name: "Target Account" } as any,
+ databaseId: "target-db",
+ containerId: "target-container",
+ },
+ sourceReadAccessFromTarget: false,
+ ...overrides,
+ });
+
+ const createMockContextValue = (copyJobState: CopyJobContextState): CopyJobContextProviderType => ({
+ contextError: null,
+ setContextError: jest.fn(),
+ copyJobState,
+ setCopyJobState: jest.fn(),
+ flow: null,
+ setFlow: jest.fn(),
+ resetCopyJobState: jest.fn(),
+ explorer: mockExplorer,
+ });
+
+ const renderWithContext = (copyJobState: CopyJobContextState): RenderResult => {
+ const contextValue = createMockContextValue(copyJobState);
+ return render(
+
+
+ ,
+ );
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("Rendering", () => {
+ it("should render without crashing with offline migration", () => {
+ const copyJobState = createMockCopyJobState();
+ const { container } = renderWithContext(copyJobState);
+
+ expect(container.firstChild).toBeTruthy();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should render without crashing with online migration", () => {
+ const copyJobState = createMockCopyJobState({
+ migrationType: CopyJobMigrationType.Online,
+ });
+ const { container } = renderWithContext(copyJobState);
+
+ expect(container.firstChild).toBeTruthy();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should display shimmer tree when no permission groups are available", () => {
+ const copyJobState = createMockCopyJobState();
+ const { getByTestId } = renderWithContext(copyJobState);
+
+ expect(getByTestId("shimmer-tree")).toBeInTheDocument();
+ });
+
+ it("should display cross account description for different accounts", () => {
+ const copyJobState = createMockCopyJobState();
+ const { getByText } = renderWithContext(copyJobState);
+
+ expect(getByText(ContainerCopyMessages.assignPermissions.crossAccountDescription)).toBeInTheDocument();
+ });
+
+ it("should display intra account description for same accounts with online migration", async () => {
+ const { isIntraAccountCopy } = await import("../../../CopyJobUtils");
+ (isIntraAccountCopy as jest.Mock).mockReturnValue(true);
+
+ const copyJobState = createMockCopyJobState({
+ migrationType: CopyJobMigrationType.Online,
+ source: {
+ subscription: { subscriptionId: "same-sub" } as any,
+ account: { id: "same-account", name: "Same Account" } as any,
+ databaseId: "source-db",
+ containerId: "source-container",
+ },
+ target: {
+ subscriptionId: "same-sub",
+ account: { id: "same-account", name: "Same Account" } as any,
+ databaseId: "target-db",
+ containerId: "target-container",
+ },
+ });
+
+ const { getByText } = renderWithContext(copyJobState);
+ expect(
+ getByText(ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription("Same Account")),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("Permission Groups", () => {
+ it("should render permission groups when available", async () => {
+ const mockUsePermissionSections = (await import("./hooks/usePermissionsSection")).default as jest.Mock;
+ mockUsePermissionSections.mockReturnValue([
+ {
+ id: "crossAccountConfigs",
+ title: "Cross Account Configuration",
+ description: "Configure permissions for cross-account copy",
+ sections: [
+ {
+ id: "addManagedIdentity",
+ title: "Add Managed Identity",
+ Component: () => Add Managed Identity Component
,
+ disabled: false,
+ completed: true,
+ },
+ {
+ id: "readPermissionAssigned",
+ title: "Read Permission Assigned",
+ Component: () => Add Read Permission Component
,
+ disabled: false,
+ completed: false,
+ },
+ ],
+ },
+ ]);
+
+ const copyJobState = createMockCopyJobState();
+ const { container } = renderWithContext(copyJobState);
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should render online migration specific groups", async () => {
+ const mockUsePermissionSections = (await import("./hooks/usePermissionsSection")).default as jest.Mock;
+ mockUsePermissionSections.mockReturnValue([
+ {
+ id: "onlineConfigs",
+ title: "Online Configuration",
+ description: "Configure settings for online migration",
+ sections: [
+ {
+ id: "pointInTimeRestore",
+ title: "Point In Time Restore",
+ Component: () => Point In Time Restore Component
,
+ disabled: false,
+ completed: true,
+ },
+ {
+ id: "onlineCopyEnabled",
+ title: "Online Copy Enabled",
+ Component: () => Online Copy Enabled Component
,
+ disabled: false,
+ completed: false,
+ },
+ ],
+ },
+ ]);
+
+ const copyJobState = createMockCopyJobState({
+ migrationType: CopyJobMigrationType.Online,
+ });
+ const { container } = renderWithContext(copyJobState);
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should render multiple permission groups", async () => {
+ const mockUsePermissionSections = (await import("./hooks/usePermissionsSection")).default as jest.Mock;
+ mockUsePermissionSections.mockReturnValue([
+ {
+ id: "crossAccountConfigs",
+ title: "Cross Account Configuration",
+ description: "Configure permissions for cross-account copy",
+ sections: [
+ {
+ id: "addManagedIdentity",
+ title: "Add Managed Identity",
+ Component: () => Add Managed Identity Component
,
+ disabled: false,
+ completed: true,
+ },
+ ],
+ },
+ {
+ id: "onlineConfigs",
+ title: "Online Configuration",
+ description: "Configure settings for online migration",
+ sections: [
+ {
+ id: "onlineCopyEnabled",
+ title: "Online Copy Enabled",
+ Component: () => Online Copy Enabled Component
,
+ disabled: false,
+ completed: false,
+ },
+ ],
+ },
+ ]);
+
+ const copyJobState = createMockCopyJobState({
+ migrationType: CopyJobMigrationType.Online,
+ });
+ const { container, getByText } = renderWithContext(copyJobState);
+
+ expect(getByText("Cross Account Configuration")).toBeInTheDocument();
+ expect(getByText("Online Configuration")).toBeInTheDocument();
+ expect(container).toMatchSnapshot();
+ });
+ });
+
+ describe("Accordion Behavior", () => {
+ it("should render accordion sections with proper status icons", async () => {
+ const mockUsePermissionSections = (await import("./hooks/usePermissionsSection")).default as jest.Mock;
+ mockUsePermissionSections.mockReturnValue([
+ {
+ id: "testGroup",
+ title: "Test Group",
+ description: "Test Description",
+ sections: [
+ {
+ id: "completedSection",
+ title: "Completed Section",
+ Component: () => Completed Component
,
+ disabled: false,
+ completed: true,
+ },
+ {
+ id: "incompleteSection",
+ title: "Incomplete Section",
+ Component: () => Incomplete Component
,
+ disabled: false,
+ completed: false,
+ },
+ {
+ id: "disabledSection",
+ title: "Disabled Section",
+ Component: () => Disabled Component
,
+ disabled: true,
+ completed: false,
+ },
+ ],
+ },
+ ]);
+
+ const copyJobState = createMockCopyJobState();
+ const { container, getByText, getAllByRole } = renderWithContext(copyJobState);
+
+ expect(getByText("Completed Section")).toBeInTheDocument();
+ expect(getByText("Incomplete Section")).toBeInTheDocument();
+ expect(getByText("Disabled Section")).toBeInTheDocument();
+
+ const images = getAllByRole("img");
+ expect(images.length).toBeGreaterThan(0);
+
+ expect(container).toMatchSnapshot();
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("should handle missing account names", () => {
+ const copyJobState = createMockCopyJobState({
+ source: {
+ subscription: { subscriptionId: "source-sub" } as any,
+ account: { id: "source-account" } as any,
+ databaseId: "source-db",
+ containerId: "source-container",
+ },
+ });
+
+ const { container } = renderWithContext(copyJobState);
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should calculate correct indent levels for offline migration", () => {
+ const copyJobState = createMockCopyJobState({
+ migrationType: CopyJobMigrationType.Offline,
+ });
+
+ const { container } = renderWithContext(copyJobState);
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should calculate correct indent levels for online migration", () => {
+ const copyJobState = createMockCopyJobState({
+ migrationType: CopyJobMigrationType.Online,
+ });
+
+ const { container } = renderWithContext(copyJobState);
+ expect(container).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx
index 7b1f96241..40a657f59 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx
@@ -12,7 +12,7 @@ import { useCopyJobPrerequisitesCache } from "../../Utils/useCopyJobPrerequisite
import usePermissionSections, { PermissionGroupConfig, PermissionSectionConfig } from "./hooks/usePermissionsSection";
const PermissionSection: React.FC = ({ id, title, Component, completed, disabled }) => (
-
+
{title}
@@ -25,13 +25,13 @@ const PermissionSection: React.FC = ({ id, title, Compo
height={completed ? 20 : 24}
/>
-
+
);
-const PermissionGroup: React.FC = ({ title, description, sections }) => {
+const PermissionGroup: React.FC = ({ id, title, description, sections }) => {
const [openItems, setOpenItems] = React.useState([]);
useEffect(() => {
@@ -44,11 +44,12 @@ const PermissionGroup: React.FC = ({ title, description,
return (
= ({ title, description,
}}
>
-
+
{title}
{description && (
-
+
{description}
)}
@@ -99,8 +100,12 @@ const AssignPermissions = () => {
}, []);
return (
-
-
+
+
{isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online
? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription(
copyJobState?.source?.account?.name || "",
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.test.tsx
new file mode 100644
index 000000000..93418859f
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.test.tsx
@@ -0,0 +1,355 @@
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen } from "@testing-library/react";
+import React from "react";
+import { updateDefaultIdentity } from "../../../../../Utils/arm/identityUtils";
+import ContainerCopyMessages from "../../../ContainerCopyMessages";
+import { CopyJobContext } from "../../../Context/CopyJobContext";
+import DefaultManagedIdentity from "./DefaultManagedIdentity";
+
+jest.mock("./hooks/useManagedIdentity");
+jest.mock("./hooks/useToggle");
+
+jest.mock("../../../../../Utils/arm/identityUtils", () => ({
+ updateDefaultIdentity: jest.fn(),
+}));
+
+jest.mock("../Components/InfoTooltip", () => {
+ const MockInfoTooltip = ({ content }: { content: React.ReactNode }) => {
+ return {content}
;
+ };
+ MockInfoTooltip.displayName = "MockInfoTooltip";
+ return MockInfoTooltip;
+});
+
+jest.mock("../Components/PopoverContainer", () => {
+ const MockPopoverContainer = ({
+ children,
+ isLoading,
+ visible,
+ title,
+ onCancel,
+ onPrimary,
+ }: {
+ children: React.ReactNode;
+ isLoading: boolean;
+ visible: boolean;
+ title: string;
+ onCancel: () => void;
+ onPrimary: () => void;
+ }) => {
+ if (!visible) {
+ return null;
+ }
+ return (
+
+
{title}
+
{children}
+
{isLoading ? "Loading" : "Not Loading"}
+
+ Cancel
+
+
+ Primary
+
+
+ );
+ };
+ MockPopoverContainer.displayName = "MockPopoverContainer";
+ return MockPopoverContainer;
+});
+
+import { DatabaseAccount } from "Contracts/DataModels";
+import { CopyJobContextProviderType } from "Explorer/ContainerCopy/Types/CopyJobTypes";
+import useManagedIdentity from "./hooks/useManagedIdentity";
+import useToggle from "./hooks/useToggle";
+
+const mockUseManagedIdentity = useManagedIdentity as jest.MockedFunction;
+const mockUseToggle = useToggle as jest.MockedFunction;
+
+describe("DefaultManagedIdentity", () => {
+ const mockCopyJobContextValue = {
+ copyJobState: {
+ target: {
+ account: {
+ name: "test-cosmos-account",
+ id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-cosmos-account",
+ },
+ },
+ },
+ setCopyJobState: jest.fn(),
+ setContextError: jest.fn(),
+ contextError: "",
+ flow: {},
+ setFlow: jest.fn(),
+ resetCopyJobState: jest.fn(),
+ explorer: {} as any,
+ };
+
+ const mockHandleAddSystemIdentity = jest.fn();
+ const mockOnToggle = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockUseManagedIdentity.mockReturnValue({
+ loading: false,
+ handleAddSystemIdentity: mockHandleAddSystemIdentity,
+ });
+
+ mockUseToggle.mockReturnValue([false, mockOnToggle]);
+ });
+
+ const renderComponent = (contextValue = mockCopyJobContextValue) => {
+ return render(
+
+
+ ,
+ );
+ };
+
+ describe("Rendering", () => {
+ it("should render correctly with default state", () => {
+ const { container } = renderComponent();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should render the description with account name", () => {
+ renderComponent();
+
+ const description = screen.getByText(
+ /Set the system-assigned managed identity as default for "test-cosmos-account"/,
+ );
+ expect(description).toBeInTheDocument();
+ });
+
+ it("should render the info tooltip", () => {
+ renderComponent();
+
+ const tooltip = screen.getByTestId("info-tooltip");
+ expect(tooltip).toBeInTheDocument();
+ expect(tooltip).toHaveTextContent("Learn more about");
+ expect(tooltip).toHaveTextContent("Default Managed Identities.");
+ });
+
+ it("should render the toggle button with correct initial state", () => {
+ renderComponent();
+
+ const toggle = screen.getByRole("switch");
+ expect(toggle).toBeInTheDocument();
+ expect(toggle).not.toBeChecked();
+ });
+
+ it("should not show popover when toggle is false", () => {
+ renderComponent();
+
+ const popover = screen.queryByTestId("popover-message");
+ expect(popover).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Toggle Interactions", () => {
+ it("should call onToggle when toggle is clicked", () => {
+ renderComponent();
+
+ const toggle = screen.getByRole("switch");
+ fireEvent.click(toggle);
+
+ expect(mockOnToggle).toHaveBeenCalledTimes(1);
+ });
+
+ it("should show popover when toggle is true", () => {
+ mockUseToggle.mockReturnValue([true, mockOnToggle]);
+
+ renderComponent();
+
+ const popover = screen.getByTestId("popover-message");
+ expect(popover).toBeInTheDocument();
+
+ const title = screen.getByTestId("popover-title");
+ expect(title).toHaveTextContent(ContainerCopyMessages.defaultManagedIdentity.popoverTitle);
+
+ const content = screen.getByTestId("popover-content");
+ expect(content).toHaveTextContent(
+ /Assign the system-assigned managed identity as the default for "test-cosmos-account"/,
+ );
+ });
+
+ it("should render toggle with checked state when toggle is true", () => {
+ mockUseToggle.mockReturnValue([true, mockOnToggle]);
+
+ const { container } = renderComponent();
+ expect(container).toMatchSnapshot();
+ });
+ });
+
+ describe("Loading States", () => {
+ it("should show loading state in popover when loading is true", () => {
+ mockUseToggle.mockReturnValue([true, mockOnToggle]);
+ mockUseManagedIdentity.mockReturnValue({
+ loading: true,
+ handleAddSystemIdentity: mockHandleAddSystemIdentity,
+ });
+
+ renderComponent();
+
+ const loadingIndicator = screen.getByTestId("popover-loading");
+ expect(loadingIndicator).toHaveTextContent("Loading");
+ });
+
+ it("should not show loading state when loading is false", () => {
+ mockUseToggle.mockReturnValue([true, mockOnToggle]);
+
+ renderComponent();
+
+ const loadingIndicator = screen.getByTestId("popover-loading");
+ expect(loadingIndicator).toHaveTextContent("Not Loading");
+ });
+
+ it("should render loading state snapshot", () => {
+ mockUseToggle.mockReturnValue([true, mockOnToggle]);
+ mockUseManagedIdentity.mockReturnValue({
+ loading: true,
+ handleAddSystemIdentity: mockHandleAddSystemIdentity,
+ });
+
+ const { container } = renderComponent();
+ expect(container).toMatchSnapshot();
+ });
+ });
+
+ describe("Popover Interactions", () => {
+ beforeEach(() => {
+ mockUseToggle.mockReturnValue([true, mockOnToggle]);
+ });
+
+ it("should call onToggle with false when cancel button is clicked", () => {
+ renderComponent();
+
+ const cancelButton = screen.getByTestId("popover-cancel");
+ fireEvent.click(cancelButton);
+
+ expect(mockOnToggle).toHaveBeenCalledWith(null, false);
+ });
+
+ it("should call handleAddSystemIdentity when primary button is clicked", () => {
+ renderComponent();
+
+ const primaryButton = screen.getByTestId("popover-primary");
+ fireEvent.click(primaryButton);
+
+ expect(mockHandleAddSystemIdentity).toHaveBeenCalledTimes(1);
+ });
+
+ it("should handle primary button click correctly when loading", async () => {
+ mockUseManagedIdentity.mockReturnValue({
+ loading: true,
+ handleAddSystemIdentity: mockHandleAddSystemIdentity,
+ });
+
+ renderComponent();
+
+ const primaryButton = screen.getByTestId("popover-primary");
+ fireEvent.click(primaryButton);
+
+ expect(mockHandleAddSystemIdentity).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("should handle missing account name gracefully", () => {
+ const contextValueWithoutAccount = {
+ ...mockCopyJobContextValue,
+ copyJobState: {
+ target: {
+ account: {
+ name: "",
+ id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/",
+ },
+ },
+ },
+ };
+
+ const { container } = renderComponent(contextValueWithoutAccount);
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should handle null account", () => {
+ const contextValueWithNullAccount = {
+ ...mockCopyJobContextValue,
+ copyJobState: {
+ target: {
+ account: null as DatabaseAccount | null,
+ },
+ },
+ };
+
+ const { container } = renderComponent(contextValueWithNullAccount);
+ expect(container).toMatchSnapshot();
+ });
+ });
+
+ describe("Hook Integration", () => {
+ it("should pass updateDefaultIdentity to useManagedIdentity hook", () => {
+ renderComponent();
+
+ expect(mockUseManagedIdentity).toHaveBeenCalledWith(updateDefaultIdentity);
+ });
+
+ it("should initialize useToggle with false", () => {
+ renderComponent();
+
+ expect(mockUseToggle).toHaveBeenCalledWith(false);
+ });
+ });
+
+ describe("Accessibility", () => {
+ it("should have proper ARIA attributes", () => {
+ renderComponent();
+
+ const toggle = screen.getByRole("switch");
+ expect(toggle).toBeInTheDocument();
+ });
+
+ it("should have proper link accessibility", () => {
+ renderComponent();
+
+ const link = screen.getByRole("link");
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
+ });
+ });
+
+ describe("Component Structure", () => {
+ it("should have correct CSS class", () => {
+ const { container } = renderComponent();
+
+ const componentContainer = container.querySelector(".defaultManagedIdentityContainer");
+ expect(componentContainer).toBeInTheDocument();
+ });
+
+ it("should render all required FluentUI components", () => {
+ renderComponent();
+
+ expect(screen.getByRole("switch")).toBeInTheDocument();
+ expect(screen.getByRole("link")).toBeInTheDocument();
+ });
+ });
+
+ describe("Messages and Text Content", () => {
+ it("should display correct toggle button text", () => {
+ renderComponent();
+
+ const onText = screen.queryByText(ContainerCopyMessages.toggleBtn.onText);
+ const offText = screen.queryByText(ContainerCopyMessages.toggleBtn.offText);
+
+ expect(onText || offText).toBeTruthy();
+ });
+
+ it("should display correct link text in tooltip", () => {
+ renderComponent();
+
+ const linkText = screen.getByText(ContainerCopyMessages.defaultManagedIdentity.tooltip.hrefText);
+ expect(linkText).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx
index da6bd4815..92c752171 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx
@@ -12,7 +12,12 @@ import useToggle from "./hooks/useToggle";
const managedIdentityTooltip = (
{ContainerCopyMessages.defaultManagedIdentity.tooltip.content}
-
+
{ContainerCopyMessages.defaultManagedIdentity.tooltip.hrefText}
@@ -27,10 +32,11 @@ const DefaultManagedIdentity: React.FC = () => {
return (
- {ContainerCopyMessages.defaultManagedIdentity.description(copyJobState?.target?.account.name)}
+ {ContainerCopyMessages.defaultManagedIdentity.description(copyJobState?.target?.account?.name)}
= () => {
onCancel={() => onToggle(null, false)}
onPrimary={handleAddSystemIdentity}
>
- {ContainerCopyMessages.defaultManagedIdentity.popoverDescription(copyJobState?.target?.account.name)}
+ {ContainerCopyMessages.defaultManagedIdentity.popoverDescription(copyJobState?.target?.account?.name)}
);
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.test.tsx
new file mode 100644
index 000000000..8d28f2482
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.test.tsx
@@ -0,0 +1,579 @@
+import "@testing-library/jest-dom";
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { DatabaseAccount } from "Contracts/DataModels";
+import { CopyJobContextProviderType } from "Explorer/ContainerCopy/Types/CopyJobTypes";
+import React from "react";
+import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
+import { CapabilityNames } from "../../../../../Common/Constants";
+import { logError } from "../../../../../Common/Logger";
+import { update as updateDatabaseAccount } from "../../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
+import ContainerCopyMessages from "../../../ContainerCopyMessages";
+import { CopyJobContext } from "../../../Context/CopyJobContext";
+import OnlineCopyEnabled from "./OnlineCopyEnabled";
+
+jest.mock("Utils/arm/databaseAccountUtils", () => ({
+ fetchDatabaseAccount: jest.fn(),
+}));
+
+jest.mock("../../../../../Utils/arm/generatedClients/cosmos/databaseAccounts", () => ({
+ update: jest.fn(),
+}));
+
+jest.mock("../../../../../Common/Logger", () => ({
+ logError: jest.fn(),
+}));
+
+jest.mock("../../../../../Common/LoadingOverlay", () => {
+ const MockLoadingOverlay = ({ isLoading, label }: { isLoading: boolean; label: string }) => {
+ return isLoading ? {label}
: null;
+ };
+ MockLoadingOverlay.displayName = "MockLoadingOverlay";
+ return MockLoadingOverlay;
+});
+
+const mockFetchDatabaseAccount = fetchDatabaseAccount as jest.MockedFunction;
+const mockUpdateDatabaseAccount = updateDatabaseAccount as jest.MockedFunction;
+const mockLogError = logError as jest.MockedFunction;
+
+describe("OnlineCopyEnabled", () => {
+ const mockSetContextError = jest.fn();
+ const mockSetCopyJobState = jest.fn();
+
+ const mockSourceAccount: DatabaseAccount = {
+ id: "/subscriptions/test-sub-id/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
+ name: "test-account",
+ location: "East US",
+ type: "Microsoft.DocumentDB/databaseAccounts",
+ kind: "GlobalDocumentDB",
+ properties: {
+ capabilities: [],
+ enableAllVersionsAndDeletesChangeFeed: false,
+ locations: [],
+ writeLocations: [],
+ readLocations: [],
+ },
+ };
+
+ const mockCopyJobContextValue = {
+ copyJobState: {
+ source: {
+ account: mockSourceAccount,
+ },
+ },
+ setCopyJobState: mockSetCopyJobState,
+ setContextError: mockSetContextError,
+ contextError: "",
+ flow: { currentScreen: "" },
+ setFlow: jest.fn(),
+ resetCopyJobState: jest.fn(),
+ explorer: {} as any,
+ } as unknown as CopyJobContextProviderType;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.clearAllTimers();
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ });
+
+ const renderComponent = (contextValue = mockCopyJobContextValue) => {
+ return render(
+
+
+ ,
+ );
+ };
+
+ describe("Rendering", () => {
+ it("should render correctly with initial state", () => {
+ const { container } = renderComponent();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should render the description with account name", () => {
+ renderComponent();
+
+ const description = screen.getByText(ContainerCopyMessages.onlineCopyEnabled.description("test-account"));
+ expect(description).toBeInTheDocument();
+ });
+
+ it("should render the learn more link", () => {
+ renderComponent();
+
+ const link = screen.getByRole("link", {
+ name: ContainerCopyMessages.onlineCopyEnabled.hrefText,
+ });
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute("href", ContainerCopyMessages.onlineCopyEnabled.href);
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
+ });
+
+ it("should render the enable button with correct text when not loading", () => {
+ renderComponent();
+
+ const button = screen.getByRole("button", {
+ name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
+ });
+ expect(button).toBeInTheDocument();
+ expect(button).not.toBeDisabled();
+ });
+
+ it("should not show loading overlay initially", () => {
+ renderComponent();
+
+ const loadingOverlay = screen.queryByTestId("loading-overlay");
+ expect(loadingOverlay).not.toBeInTheDocument();
+ });
+
+ it("should not show refresh button initially", () => {
+ renderComponent();
+
+ const refreshButton = screen.queryByRole("button", {
+ name: ContainerCopyMessages.refreshButtonLabel,
+ });
+ expect(refreshButton).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Enable Online Copy Flow", () => {
+ it("should handle complete enable online copy flow successfully", async () => {
+ const accountAfterChangeFeedUpdate = {
+ ...mockSourceAccount,
+ properties: {
+ ...mockSourceAccount.properties,
+ enableAllVersionsAndDeletesChangeFeed: true,
+ },
+ };
+
+ const accountWithOnlineCopyEnabled: DatabaseAccount = {
+ ...accountAfterChangeFeedUpdate,
+ properties: {
+ ...accountAfterChangeFeedUpdate.properties,
+ capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature, description: "Enables online copy feature" }],
+ },
+ };
+
+ mockFetchDatabaseAccount
+ .mockResolvedValueOnce(mockSourceAccount)
+ .mockResolvedValueOnce(accountWithOnlineCopyEnabled);
+
+ mockUpdateDatabaseAccount.mockResolvedValue({} as any);
+
+ renderComponent();
+
+ const enableButton = screen.getByRole("button", {
+ name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
+ });
+
+ await act(async () => {
+ fireEvent.click(enableButton);
+ });
+
+ expect(screen.getByTestId("loading-overlay")).toBeInTheDocument();
+ await waitFor(() => {
+ expect(mockFetchDatabaseAccount).toHaveBeenCalledWith("test-sub-id", "test-rg", "test-account");
+ });
+
+ await waitFor(() => {
+ expect(mockUpdateDatabaseAccount).toHaveBeenCalledWith("test-sub-id", "test-rg", "test-account", {
+ properties: {
+ enableAllVersionsAndDeletesChangeFeed: true,
+ },
+ });
+ });
+
+ await waitFor(() => {
+ expect(mockUpdateDatabaseAccount).toHaveBeenCalledWith("test-sub-id", "test-rg", "test-account", {
+ properties: {
+ capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature }],
+ },
+ });
+ });
+ });
+
+ it("should skip change feed enablement if already enabled", async () => {
+ const accountWithChangeFeedEnabled = {
+ ...mockSourceAccount,
+ properties: {
+ ...mockSourceAccount.properties,
+ enableAllVersionsAndDeletesChangeFeed: true,
+ },
+ };
+
+ const accountWithOnlineCopyEnabled: DatabaseAccount = {
+ ...accountWithChangeFeedEnabled,
+ properties: {
+ ...accountWithChangeFeedEnabled.properties,
+ capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature, description: "Enables online copy feature" }],
+ },
+ };
+
+ mockFetchDatabaseAccount
+ .mockResolvedValueOnce(accountWithChangeFeedEnabled)
+ .mockResolvedValueOnce(accountWithOnlineCopyEnabled);
+
+ mockUpdateDatabaseAccount.mockResolvedValue({} as any);
+
+ renderComponent();
+
+ const enableButton = screen.getByRole("button", {
+ name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
+ });
+
+ await act(async () => {
+ fireEvent.click(enableButton);
+ });
+
+ await waitFor(() => {
+ expect(mockUpdateDatabaseAccount).toHaveBeenCalledTimes(1);
+ expect(mockUpdateDatabaseAccount).toHaveBeenCalledWith("test-sub-id", "test-rg", "test-account", {
+ properties: {
+ capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature }],
+ },
+ });
+ });
+ });
+
+ it("should show correct loading messages during the process", async () => {
+ mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount);
+ mockUpdateDatabaseAccount.mockImplementation(() => new Promise(() => {}));
+
+ renderComponent();
+
+ const enableButton = screen.getByRole("button", {
+ name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
+ });
+
+ await act(async () => {
+ fireEvent.click(enableButton);
+ });
+
+ await waitFor(() => {
+ expect(mockFetchDatabaseAccount).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(ContainerCopyMessages.onlineCopyEnabled.enablingOnlineCopySpinnerLabel("test-account")),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("should handle error during update operations", async () => {
+ const errorMessage = "Failed to update account";
+ mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount);
+ mockUpdateDatabaseAccount.mockRejectedValue(new Error(errorMessage));
+
+ renderComponent();
+
+ const enableButton = screen.getByRole("button", {
+ name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
+ });
+
+ await act(async () => {
+ fireEvent.click(enableButton);
+ });
+
+ await waitFor(() => {
+ expect(mockLogError).toHaveBeenCalledWith(errorMessage, "CopyJob/OnlineCopyEnabled.handleOnlineCopyEnable");
+ expect(mockSetContextError).toHaveBeenCalledWith(errorMessage);
+ });
+
+ expect(screen.queryByTestId("loading-overlay")).not.toBeInTheDocument();
+ });
+
+ it("should handle refresh button click", async () => {
+ const accountWithOnlineCopyEnabled: DatabaseAccount = {
+ ...mockSourceAccount,
+ properties: {
+ ...mockSourceAccount.properties,
+ capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature, description: "Enables online copy feature" }],
+ },
+ };
+
+ mockFetchDatabaseAccount
+ .mockResolvedValueOnce(mockSourceAccount)
+ .mockResolvedValueOnce(mockSourceAccount)
+ .mockResolvedValueOnce(accountWithOnlineCopyEnabled);
+
+ mockUpdateDatabaseAccount.mockResolvedValue({} as any);
+
+ renderComponent();
+
+ const enableButton = screen.getByRole("button", {
+ name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
+ });
+
+ await act(async () => {
+ fireEvent.click(enableButton);
+ });
+
+ await act(async () => {
+ jest.advanceTimersByTime(10 * 60 * 1000);
+ });
+
+ const refreshButton = screen.getByRole("button", {
+ name: ContainerCopyMessages.refreshButtonLabel,
+ });
+
+ await act(async () => {
+ fireEvent.click(refreshButton);
+ });
+
+ expect(screen.getByTestId("loading-overlay")).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(mockSetCopyJobState).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe("Account Validation and State Updates", () => {
+ it("should update state when account capabilities change", async () => {
+ const accountWithOnlineCopyEnabled: DatabaseAccount = {
+ ...mockSourceAccount,
+ properties: {
+ ...mockSourceAccount.properties,
+ capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature, description: "Enables online copy feature" }],
+ },
+ };
+
+ mockFetchDatabaseAccount.mockResolvedValue(accountWithOnlineCopyEnabled);
+ mockUpdateDatabaseAccount.mockResolvedValue({} as any);
+
+ renderComponent();
+
+ const enableButton = screen.getByRole("button", {
+ name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
+ });
+
+ await act(async () => {
+ fireEvent.click(enableButton);
+ });
+
+ await act(async () => {
+ jest.advanceTimersByTime(30000);
+ });
+
+ await waitFor(() => {
+ expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0];
+ const newState = stateUpdateFunction({
+ source: { account: mockSourceAccount },
+ });
+
+ expect(newState.source.account).toEqual(accountWithOnlineCopyEnabled);
+ });
+
+ it("should not update state when account capabilities remain unchanged", async () => {
+ mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount);
+ mockUpdateDatabaseAccount.mockResolvedValue({} as any);
+
+ renderComponent();
+
+ const enableButton = screen.getByRole("button", {
+ name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
+ });
+
+ await act(async () => {
+ fireEvent.click(enableButton);
+ });
+
+ await act(async () => {
+ jest.advanceTimersByTime(30000);
+ });
+
+ expect(mockSetCopyJobState).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("Button States and Interactions", () => {
+ it("should disable button during loading", async () => {
+ mockFetchDatabaseAccount.mockImplementation(() => new Promise(() => {}));
+
+ renderComponent();
+
+ const enableButton = screen.getByRole("button", {
+ name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
+ });
+
+ await act(async () => {
+ fireEvent.click(enableButton);
+ });
+
+ const loadingButton = screen.getByRole("button");
+ expect(loadingButton).toBeDisabled();
+ });
+
+ it("should show sync icon during loading", async () => {
+ mockFetchDatabaseAccount.mockImplementation(() => new Promise(() => {}));
+
+ renderComponent();
+
+ const enableButton = screen.getByRole("button", {
+ name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
+ });
+
+ await act(async () => {
+ fireEvent.click(enableButton);
+ });
+
+ const loadingButton = screen.getByRole("button");
+ expect(loadingButton.querySelector("[data-icon-name='SyncStatusSolid']")).toBeInTheDocument();
+ });
+
+ it("should disable refresh button during loading", async () => {
+ mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount);
+ mockUpdateDatabaseAccount.mockResolvedValue({} as any);
+
+ renderComponent();
+
+ const enableButton = screen.getByRole("button", {
+ name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
+ });
+
+ await act(async () => {
+ fireEvent.click(enableButton);
+ });
+
+ await act(async () => {
+ jest.advanceTimersByTime(10 * 60 * 1000);
+ });
+
+ mockFetchDatabaseAccount.mockImplementation(() => new Promise(() => {}));
+
+ const refreshButton = screen.getByRole("button", {
+ name: ContainerCopyMessages.refreshButtonLabel,
+ });
+
+ await act(async () => {
+ fireEvent.click(refreshButton);
+ });
+
+ expect(refreshButton).toBeDisabled();
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("should handle missing account name gracefully", () => {
+ const contextWithoutAccountName = {
+ ...mockCopyJobContextValue,
+ copyJobState: {
+ source: {
+ account: {
+ ...mockSourceAccount,
+ name: "",
+ },
+ },
+ },
+ } as CopyJobContextProviderType;
+
+ const { container } = renderComponent(contextWithoutAccountName);
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should handle null account", () => {
+ const contextWithNullAccount = {
+ ...mockCopyJobContextValue,
+ copyJobState: {
+ source: {
+ account: null as DatabaseAccount | null,
+ },
+ },
+ } as CopyJobContextProviderType;
+
+ const { container } = renderComponent(contextWithNullAccount);
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should handle account with existing online copy capability", () => {
+ const accountWithExistingCapability = {
+ ...mockSourceAccount,
+ properties: {
+ ...mockSourceAccount.properties,
+ capabilities: [{ name: CapabilityNames.EnableOnlineCopyFeature }, { name: "SomeOtherCapability" }],
+ },
+ };
+
+ const contextWithExistingCapability = {
+ ...mockCopyJobContextValue,
+ copyJobState: {
+ source: {
+ account: accountWithExistingCapability,
+ },
+ },
+ } as CopyJobContextProviderType;
+
+ const { container } = renderComponent(contextWithExistingCapability);
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should handle account with no capabilities array", () => {
+ const accountWithNoCapabilities = {
+ ...mockSourceAccount,
+ properties: {
+ ...mockSourceAccount.properties,
+ capabilities: undefined,
+ },
+ } as DatabaseAccount;
+
+ const contextWithNoCapabilities = {
+ ...mockCopyJobContextValue,
+ copyJobState: {
+ source: {
+ account: accountWithNoCapabilities,
+ },
+ },
+ } as CopyJobContextProviderType;
+
+ renderComponent(contextWithNoCapabilities);
+
+ const enableButton = screen.getByRole("button", {
+ name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
+ });
+ expect(enableButton).toBeInTheDocument();
+ });
+ });
+
+ describe("Accessibility", () => {
+ it("should have proper button role and accessibility attributes", () => {
+ renderComponent();
+
+ const button = screen.getByRole("button", {
+ name: ContainerCopyMessages.onlineCopyEnabled.buttonText,
+ });
+ expect(button).toBeInTheDocument();
+ });
+
+ it("should have proper link accessibility", () => {
+ renderComponent();
+
+ const link = screen.getByRole("link");
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
+ });
+ });
+
+ describe("CSS Classes and Styling", () => {
+ it("should apply correct CSS class to container", () => {
+ const { container } = renderComponent();
+
+ const onlineCopyContainer = container.querySelector(".onlineCopyContainer");
+ expect(onlineCopyContainer).toBeInTheDocument();
+ });
+
+ it("should apply fullWidth class to buttons", () => {
+ renderComponent();
+
+ const button = screen.getByRole("button");
+ expect(button).toHaveClass("fullWidth");
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx
index 1c1d6bfd5..a6f0918c3 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx
@@ -20,6 +20,7 @@ const validatorFn: AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAc
const OnlineCopyEnabled: React.FC = () => {
const [loading, setLoading] = React.useState(false);
+ const [loaderMessage, setLoaderMessage] = React.useState("");
const [showRefreshButton, setShowRefreshButton] = React.useState(false);
const intervalRef = React.useRef(null);
const timeoutRef = React.useRef(null);
@@ -31,7 +32,7 @@ const OnlineCopyEnabled: React.FC = () => {
subscriptionId: sourceSubscriptionId,
resourceGroup: sourceResourceGroup,
accountName: sourceAccountName,
- } = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
+ } = getAccountDetailsFromResourceId(selectedSourceAccount?.id) || {};
const handleFetchAccount = async () => {
try {
@@ -75,12 +76,21 @@ const OnlineCopyEnabled: React.FC = () => {
setShowRefreshButton(false);
try {
- await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
- properties: {
- enableAllVersionsAndDeletesChangeFeed: true,
- },
- });
-
+ setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.validateAllVersionsAndDeletesChangeFeedSpinnerLabel);
+ const sourAccountBeforeUpdate = await fetchDatabaseAccount(
+ sourceSubscriptionId,
+ sourceResourceGroup,
+ sourceAccountName,
+ );
+ if (!sourAccountBeforeUpdate?.properties.enableAllVersionsAndDeletesChangeFeed) {
+ setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.enablingAllVersionsAndDeletesChangeFeedSpinnerLabel);
+ await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
+ properties: {
+ enableAllVersionsAndDeletesChangeFeed: true,
+ },
+ });
+ }
+ setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.enablingOnlineCopySpinnerLabel(sourceAccountName));
await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
properties: {
capabilities: [...sourceAccountCapabilities, { name: CapabilityNames.EnableOnlineCopyFeature }],
@@ -120,7 +130,7 @@ const OnlineCopyEnabled: React.FC = () => {
return (
-
+
{ContainerCopyMessages.onlineCopyEnabled.description(source?.account?.name || "")}
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.test.tsx
new file mode 100644
index 000000000..de5ddcc49
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.test.tsx
@@ -0,0 +1,341 @@
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { logError } from "Common/Logger";
+import { DatabaseAccount } from "Contracts/DataModels";
+import React from "react";
+import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
+import { CopyJobContext } from "../../../Context/CopyJobContext";
+import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
+import { CopyJobContextProviderType, CopyJobContextState } from "../../../Types/CopyJobTypes";
+import PointInTimeRestore from "./PointInTimeRestore";
+
+jest.mock("Utils/arm/databaseAccountUtils");
+jest.mock("Common/Logger");
+
+const mockFetchDatabaseAccount = fetchDatabaseAccount as jest.MockedFunction;
+const mockLogError = logError as jest.MockedFunction;
+
+const mockWindowOpen = jest.fn();
+Object.defineProperty(window, "open", {
+ value: mockWindowOpen,
+ writable: true,
+});
+
+global.clearInterval = jest.fn();
+global.clearTimeout = jest.fn();
+
+describe("PointInTimeRestore", () => {
+ const mockSourceAccount: DatabaseAccount = {
+ id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
+ name: "test-account",
+ type: "Microsoft.DocumentDB/databaseAccounts",
+ location: "East US",
+ properties: {
+ backupPolicy: {
+ type: "Continuous",
+ },
+ },
+ } as DatabaseAccount;
+
+ const mockUpdatedAccount: DatabaseAccount = {
+ ...mockSourceAccount,
+ properties: {
+ backupPolicy: {
+ type: "Periodic",
+ },
+ },
+ } as DatabaseAccount;
+
+ const defaultCopyJobState = {
+ jobName: "test-job",
+ migrationType: CopyJobMigrationType.Offline,
+ source: {
+ subscription: { subscriptionId: "test-sub", displayName: "Test Subscription" },
+ account: mockSourceAccount,
+ databaseId: "test-db",
+ containerId: "test-container",
+ },
+ target: {
+ subscriptionId: "test-sub",
+ account: mockSourceAccount,
+ databaseId: "target-db",
+ containerId: "target-container",
+ },
+ sourceReadAccessFromTarget: false,
+ } as CopyJobContextState;
+
+ const mockSetCopyJobState = jest.fn();
+
+ const createMockContext = (overrides?: Partial): CopyJobContextProviderType => ({
+ copyJobState: defaultCopyJobState,
+ setCopyJobState: mockSetCopyJobState,
+ flow: null,
+ setFlow: jest.fn(),
+ contextError: null,
+ setContextError: jest.fn(),
+ resetCopyJobState: jest.fn(),
+ ...overrides,
+ });
+
+ const renderWithContext = (contextValue: CopyJobContextProviderType) => {
+ return render(
+
+
+ ,
+ );
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockFetchDatabaseAccount.mockClear();
+ mockLogError.mockClear();
+ mockWindowOpen.mockClear();
+ mockSetCopyJobState.mockClear();
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ });
+
+ describe("Initial Render", () => {
+ it("should render correctly with default props", () => {
+ const mockContext = createMockContext();
+ const { container } = renderWithContext(mockContext);
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should display the correct description with account name", () => {
+ const mockContext = createMockContext();
+ renderWithContext(mockContext);
+
+ expect(screen.getByText(/test-account/)).toBeInTheDocument();
+ });
+
+ it("should show the primary action button with correct text", () => {
+ const mockContext = createMockContext();
+ renderWithContext(mockContext);
+
+ const button = screen.getByRole("button");
+ expect(button).toBeInTheDocument();
+ expect(button).not.toBeDisabled();
+ });
+
+ it("should render with empty account name gracefully", () => {
+ const contextWithoutAccount = createMockContext({
+ copyJobState: {
+ ...defaultCopyJobState,
+ source: {
+ ...defaultCopyJobState.source,
+ account: { ...mockSourceAccount, name: "" },
+ },
+ },
+ });
+
+ const { container } = renderWithContext(contextWithoutAccount);
+ expect(container).toMatchSnapshot();
+ });
+ });
+
+ describe("Button Interactions", () => {
+ it("should open window and start monitoring when button is clicked", () => {
+ const mockContext = createMockContext();
+ renderWithContext(mockContext);
+
+ const button = screen.getByRole("button");
+ fireEvent.click(button);
+
+ expect(mockWindowOpen).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /#resource\/subscriptions\/test-sub\/resourceGroups\/test-rg\/providers\/Microsoft.DocumentDB\/databaseAccounts\/test-account\/backupRestore$/,
+ ),
+ "_blank",
+ );
+ });
+
+ it("should disable button and show loading state after click", () => {
+ const mockContext = createMockContext();
+ renderWithContext(mockContext);
+
+ const button = screen.getByRole("button");
+ fireEvent.click(button);
+
+ expect(button).toBeDisabled();
+ expect(screen.getByText(/Please wait while we process your request/)).toBeInTheDocument();
+ });
+
+ it("should show refresh button when timeout occurs", async () => {
+ jest.useFakeTimers();
+ const mockContext = createMockContext();
+ renderWithContext(mockContext);
+
+ const button = screen.getByRole("button");
+ fireEvent.click(button);
+
+ jest.advanceTimersByTime(10 * 60 * 1000 + 1000);
+
+ await waitFor(() => {
+ expect(screen.getByText(/Refresh/)).toBeInTheDocument();
+ });
+
+ jest.useRealTimers();
+ });
+
+ it("should fetch account periodically after button click", async () => {
+ jest.useFakeTimers();
+ mockFetchDatabaseAccount.mockResolvedValue(mockUpdatedAccount);
+
+ const mockContext = createMockContext();
+ renderWithContext(mockContext);
+
+ const button = screen.getByRole("button");
+ fireEvent.click(button);
+
+ jest.advanceTimersByTime(30 * 1000);
+
+ await waitFor(() => {
+ expect(mockFetchDatabaseAccount).toHaveBeenCalledWith("test-sub", "test-rg", "test-account");
+ });
+
+ jest.useRealTimers();
+ });
+
+ it("should not update context when account validation fails", async () => {
+ jest.useFakeTimers();
+ mockFetchDatabaseAccount.mockResolvedValue(mockSourceAccount);
+
+ const mockContext = createMockContext();
+ renderWithContext(mockContext);
+
+ const button = screen.getByRole("button");
+ fireEvent.click(button);
+
+ jest.advanceTimersByTime(30 * 1000);
+
+ await waitFor(() => {
+ expect(mockFetchDatabaseAccount).toHaveBeenCalled();
+ });
+
+ expect(mockSetCopyJobState).not.toHaveBeenCalled();
+
+ jest.useRealTimers();
+ });
+ });
+
+ describe("Refresh Button Functionality", () => {
+ it("should handle refresh button click", async () => {
+ jest.useFakeTimers();
+ mockFetchDatabaseAccount.mockResolvedValue(mockUpdatedAccount);
+
+ const mockContext = createMockContext();
+ renderWithContext(mockContext);
+
+ const button = screen.getByRole("button");
+ fireEvent.click(button);
+
+ jest.advanceTimersByTime(10 * 60 * 1000 + 1000);
+
+ await waitFor(() => {
+ const refreshButton = screen.getByText(/Refresh/);
+ expect(refreshButton).toBeInTheDocument();
+ });
+
+ const refreshButton = screen.getByText(/Refresh/);
+ fireEvent.click(refreshButton);
+
+ await waitFor(() => {
+ expect(mockFetchDatabaseAccount).toHaveBeenCalledWith("test-sub", "test-rg", "test-account");
+ });
+
+ jest.useRealTimers();
+ });
+
+ it("should show loading state during refresh", async () => {
+ jest.useFakeTimers();
+ mockFetchDatabaseAccount.mockImplementation(
+ () => new Promise((resolve) => setTimeout(() => resolve(mockUpdatedAccount), 1000)),
+ );
+
+ const mockContext = createMockContext();
+ renderWithContext(mockContext);
+
+ const button = screen.getByRole("button");
+ fireEvent.click(button);
+
+ jest.advanceTimersByTime(10 * 60 * 1000 + 1000);
+
+ await waitFor(() => {
+ expect(screen.getByText(/Refresh/)).toBeInTheDocument();
+ });
+
+ const refreshButton = screen.getByText(/Refresh/);
+ fireEvent.click(refreshButton);
+
+ expect(screen.getByText(/Please wait while we process your request/)).toBeInTheDocument();
+
+ jest.useRealTimers();
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("should handle missing source account gracefully", () => {
+ const contextWithoutSourceAccount = createMockContext({
+ copyJobState: {
+ ...defaultCopyJobState,
+ source: {
+ ...defaultCopyJobState.source,
+ account: null as any,
+ },
+ },
+ });
+
+ const { container } = renderWithContext(contextWithoutSourceAccount);
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should handle missing account ID gracefully", () => {
+ const contextWithoutAccountId = createMockContext({
+ copyJobState: {
+ ...defaultCopyJobState,
+ source: {
+ ...defaultCopyJobState.source,
+ account: { ...mockSourceAccount, id: undefined as any },
+ },
+ },
+ });
+
+ const { container } = renderWithContext(contextWithoutAccountId);
+ expect(container).toMatchSnapshot();
+ });
+ });
+
+ describe("Snapshots", () => {
+ it("should match snapshot in loading state", () => {
+ const mockContext = createMockContext();
+ const { container } = renderWithContext(mockContext);
+
+ const button = screen.getByRole("button");
+ fireEvent.click(button);
+
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should match snapshot with refresh button", async () => {
+ jest.useFakeTimers();
+ const mockContext = createMockContext();
+ const { container } = renderWithContext(mockContext);
+
+ const button = screen.getByRole("button");
+ fireEvent.click(button);
+
+ jest.advanceTimersByTime(10 * 60 * 1000 + 1000);
+
+ await waitFor(() => {
+ expect(screen.getByText(/Refresh/)).toBeInTheDocument();
+ });
+
+ expect(container).toMatchSnapshot();
+ jest.useRealTimers();
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx
index f62331677..6725f4981 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx
@@ -13,7 +13,12 @@ import InfoTooltip from "../Components/InfoTooltip";
const tooltipContent = (
{ContainerCopyMessages.pointInTimeRestore.tooltip.content}
-
+
{ContainerCopyMessages.pointInTimeRestore.tooltip.hrefText}
@@ -31,7 +36,11 @@ const PointInTimeRestore: React.FC = () => {
const [showRefreshButton, setShowRefreshButton] = useState(false);
const intervalRef = useRef(null);
const timeoutRef = useRef(null);
- const { copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext();
+ const { copyJobState: { source } = {}, setCopyJobState, setContextError } = useCopyJobContext();
+ if (!source?.account?.id) {
+ setContextError("Invalid source account. Please select a valid source account for Point-in-Time Restore.");
+ return null;
+ }
const sourceAccountLink = buildResourceLink(source?.account);
const featureUrl = `${sourceAccountLink}/backupRestore`;
const selectedSourceAccount = source?.account;
@@ -39,7 +48,7 @@ const PointInTimeRestore: React.FC = () => {
subscriptionId: sourceSubscriptionId,
resourceGroup: sourceResourceGroup,
accountName: sourceAccountName,
- } = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
+ } = getAccountDetailsFromResourceId(selectedSourceAccount?.id) || {};
useEffect(() => {
return () => {
@@ -123,6 +132,7 @@ const PointInTimeRestore: React.FC = () => {
{showRefreshButton ? (
{
/>
) : (
+
+ A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don’t have to store any credentials in code.
+
+
+ Learn more about Managed identities.
+
+
+
+
+
+
+
+`;
+
+exports[`AddManagedIdentity Snapshot Tests renders loading state 1`] = `
+
+
+ A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don’t have to store any credentials in code.
+
+
+ Learn more about Managed identities.
+
+
+
+
+
+
+
+
+
+
+
+ Please wait while we process your request...
+
+
+
+
+ Enable system assigned managed identity
+
+
+ Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button.
+
+
+
+
+
+
+ Yes
+
+
+
+
+
+
+
+
+ No
+
+
+
+
+
+
+
+`;
+
+exports[`AddManagedIdentity Snapshot Tests renders with toggle on and popover visible 1`] = `
+
+
+ A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don’t have to store any credentials in code.
+
+
+ Learn more about Managed identities.
+
+
+
+
+
+
+
+
+ Enable system assigned managed identity
+
+
+ Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button.
+
+
+
+
+
+
+ Yes
+
+
+
+
+
+
+
+
+ No
+
+
+
+
+
+
+
+`;
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddReadPermissionToDefaultIdentity.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddReadPermissionToDefaultIdentity.test.tsx.snap
new file mode 100644
index 000000000..e000b1285
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddReadPermissionToDefaultIdentity.test.tsx.snap
@@ -0,0 +1,404 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle missing source account 1`] = `
+
+
+
+ To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
+
+
+
+
+
+
+`;
+
+exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle missing target account identity 1`] = `
+
+
+
+ To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
+
+
+
+
+
+
+`;
+
+exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly when sourceReadAccessFromTarget is true 1`] = `
+
+
+
+ To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
+
+
+
+
+
+
+`;
+
+exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly when toggle is on 1`] = `
+
+
+
+ To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
+
+
+
+
+
+
+ Read permissions assigned to default identity.
+
+
+ Assign read permissions of the source account to the default identity of the destination account. To confirm click the “Yes” button.
+
+
+ Cancel
+
+
+ Primary
+
+
+
+
+`;
+
+exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly with default state 1`] = `
+
+
+
+ To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
+
+
+
+
+
+
+`;
+
+exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly with different context states 1`] = `
+
+
+
+ To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
+
+
+
+
+
+
+`;
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AssignPermissions.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AssignPermissions.test.tsx.snap
new file mode 100644
index 000000000..302ea4bf7
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AssignPermissions.test.tsx.snap
@@ -0,0 +1,1343 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AssignPermissions Component Accordion Behavior should render accordion sections with proper status icons 1`] = `
+
+
+
+ To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.
+
+
+
+
+
+ Test Group
+
+
+ Test Description
+
+
+
+
+
+
+
+
+
+
+ Incomplete Component
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`AssignPermissions Component Edge Cases should calculate correct indent levels for offline migration 1`] = `
+
+
+
+ To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.
+
+
+
+
+
+ Test Group
+
+
+ Test Description
+
+
+
+
+
+
+
+
+
+
+ Incomplete Component
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`AssignPermissions Component Edge Cases should calculate correct indent levels for online migration 1`] = `
+
+
+
+ Follow the steps below to enable online copy on your "Source Account" account.
+
+
+
+
+
+ Test Group
+
+
+ Test Description
+
+
+
+
+
+
+
+
+
+
+ Incomplete Component
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`AssignPermissions Component Edge Cases should handle missing account names 1`] = `
+
+
+
+ To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.
+
+
+
+
+
+ Test Group
+
+
+ Test Description
+
+
+
+
+
+
+
+
+
+
+ Incomplete Component
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`AssignPermissions Component Permission Groups should render multiple permission groups 1`] = `
+
+
+
+ Follow the steps below to enable online copy on your "Source Account" account.
+
+
+
+
+
+ Cross Account Configuration
+
+
+ Configure permissions for cross-account copy
+
+
+
+
+
+
+
+ Online Configuration
+
+
+ Configure settings for online migration
+
+
+
+
+
+
+
+ Online Copy Enabled Component
+
+
+
+
+
+
+
+
+`;
+
+exports[`AssignPermissions Component Permission Groups should render online migration specific groups 1`] = `
+
+
+
+ Follow the steps below to enable online copy on your "Source Account" account.
+
+
+
+
+
+ Online Configuration
+
+
+ Configure settings for online migration
+
+
+
+
+
+
+
+
+
+
+ Online Copy Enabled Component
+
+
+
+
+
+
+
+
+`;
+
+exports[`AssignPermissions Component Permission Groups should render permission groups when available 1`] = `
+
+
+
+ To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.
+
+
+
+
+
+ Cross Account Configuration
+
+
+ Configure permissions for cross-account copy
+
+
+
+
+
+
+
+
+
+
+ Add Read Permission Component
+
+
+
+
+
+
+
+
+`;
+
+exports[`AssignPermissions Component Rendering should render without crashing with offline migration 1`] = `
+
+
+
+ To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.
+
+
+ Loading...
+
+
+
+`;
+
+exports[`AssignPermissions Component Rendering should render without crashing with online migration 1`] = `
+
+
+
+ To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.
+
+
+ Loading...
+
+
+
+`;
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/DefaultManagedIdentity.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/DefaultManagedIdentity.test.tsx.snap
new file mode 100644
index 000000000..12a618c81
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/DefaultManagedIdentity.test.tsx.snap
@@ -0,0 +1,374 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DefaultManagedIdentity Edge Cases should handle missing account name gracefully 1`] = `
+
+
+
+ Set the system-assigned managed identity as default for "" by switching it on.
+
+
+
+
+
+
+`;
+
+exports[`DefaultManagedIdentity Edge Cases should handle null account 1`] = `
+
+
+
+ Set the system-assigned managed identity as default for "undefined" by switching it on.
+
+
+
+
+
+
+`;
+
+exports[`DefaultManagedIdentity Loading States should render loading state snapshot 1`] = `
+
+
+
+ Set the system-assigned managed identity as default for "test-cosmos-account" by switching it on.
+
+
+
+
+
+
+ System assigned managed identity set as default
+
+
+ Assign the system-assigned managed identity as the default for "test-cosmos-account". To confirm, click the "Yes" button.
+
+
+ Loading
+
+
+ Cancel
+
+
+ Primary
+
+
+
+
+`;
+
+exports[`DefaultManagedIdentity Rendering should render correctly with default state 1`] = `
+
+
+
+ Set the system-assigned managed identity as default for "test-cosmos-account" by switching it on.
+
+
+
+
+
+
+`;
+
+exports[`DefaultManagedIdentity Toggle Interactions should render toggle with checked state when toggle is true 1`] = `
+
+
+
+ Set the system-assigned managed identity as default for "test-cosmos-account" by switching it on.
+
+
+
+
+
+
+ System assigned managed identity set as default
+
+
+ Assign the system-assigned managed identity as the default for "test-cosmos-account". To confirm, click the "Yes" button.
+
+
+ Not Loading
+
+
+ Cancel
+
+
+ Primary
+
+
+
+
+`;
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/OnlineCopyEnabled.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/OnlineCopyEnabled.test.tsx.snap
new file mode 100644
index 000000000..bc2151ac8
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/OnlineCopyEnabled.test.tsx.snap
@@ -0,0 +1,193 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`OnlineCopyEnabled Edge Cases should handle account with existing online copy capability 1`] = `
+
+
+
+
+
+
+
+
+ Enable Online Copy
+
+
+
+
+
+
+
+`;
+
+exports[`OnlineCopyEnabled Edge Cases should handle missing account name gracefully 1`] = `
+
+
+
+
+
+
+
+
+ Enable Online Copy
+
+
+
+
+
+
+
+`;
+
+exports[`OnlineCopyEnabled Edge Cases should handle null account 1`] = `
+
+
+
+
+
+
+
+
+ Enable Online Copy
+
+
+
+
+
+
+
+`;
+
+exports[`OnlineCopyEnabled Rendering should render correctly with initial state 1`] = `
+
+
+
+
+
+
+
+
+ Enable Online Copy
+
+
+
+
+
+
+
+`;
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/PointInTimeRestore.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/PointInTimeRestore.test.tsx.snap
new file mode 100644
index 000000000..d3aa3c67a
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/PointInTimeRestore.test.tsx.snap
@@ -0,0 +1,338 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`PointInTimeRestore Edge Cases should handle missing account ID gracefully 1`] = `
`;
+
+exports[`PointInTimeRestore Edge Cases should handle missing source account gracefully 1`] = `
`;
+
+exports[`PointInTimeRestore Initial Render should render correctly with default props 1`] = `
+
+
+
+ To facilitate online container copy jobs, please update your "test-account" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality.
+
+
+
+
+
+
+
+
+ Enable Point In Time Restore
+
+
+
+
+
+
+
+`;
+
+exports[`PointInTimeRestore Initial Render should render with empty account name gracefully 1`] = `
+
+
+
+ To facilitate online container copy jobs, please update your "" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality.
+
+
+
+
+
+
+
+
+ Enable Point In Time Restore
+
+
+
+
+
+
+
+`;
+
+exports[`PointInTimeRestore Snapshots should match snapshot in loading state 1`] = `
+
+
+
+
+
+
+ Please wait while we process your request...
+
+
+
+
+ To facilitate online container copy jobs, please update your "test-account" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`PointInTimeRestore Snapshots should match snapshot with refresh button 1`] = `
+
+
+
+ To facilitate online container copy jobs, please update your "test-account" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality.
+
+
+
+
+
+
+
+
+
+
+
+ Refresh
+
+
+
+
+
+
+
+`;
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.test.tsx
new file mode 100644
index 000000000..2a91c8c69
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.test.tsx
@@ -0,0 +1,255 @@
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import React from "react";
+import { logError } from "../../../../../../Common/Logger";
+import { DatabaseAccount } from "../../../../../../Contracts/DataModels";
+import Explorer from "../../../../../Explorer";
+import CopyJobContextProvider, { useCopyJobContext } from "../../../../Context/CopyJobContext";
+import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils";
+import useManagedIdentity from "./useManagedIdentity";
+
+jest.mock("../../../../CopyJobUtils");
+jest.mock("../../../../../../Common/Logger");
+
+const mockGetAccountDetailsFromResourceId = getAccountDetailsFromResourceId as jest.MockedFunction<
+ typeof getAccountDetailsFromResourceId
+>;
+const mockLogError = logError as jest.MockedFunction
;
+
+const mockDatabaseAccount: DatabaseAccount = {
+ id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
+ name: "test-account",
+ location: "East US",
+ type: "Microsoft.DocumentDB/databaseAccounts",
+ kind: "GlobalDocumentDB",
+ properties: {
+ documentEndpoint: "https://test-account.documents.azure.com:443/",
+ },
+} as DatabaseAccount;
+
+interface TestComponentProps {
+ updateIdentityFn: (
+ subscriptionId: string,
+ resourceGroup?: string,
+ accountName?: string,
+ ) => Promise;
+ onError?: (error: string) => void;
+}
+
+const TestComponent: React.FC = ({ updateIdentityFn, onError }) => {
+ const { loading, handleAddSystemIdentity } = useManagedIdentity(updateIdentityFn);
+ const { contextError } = useCopyJobContext();
+
+ React.useEffect(() => {
+ if (contextError && onError) {
+ onError(contextError);
+ }
+ }, [contextError, onError]);
+
+ const handleClick = async () => {
+ await handleAddSystemIdentity();
+ };
+
+ return (
+
+
+ {loading ? "Loading..." : "Add System Identity"}
+
+
{loading ? "true" : "false"}
+ {contextError &&
{contextError}
}
+
+ );
+};
+
+const TestWrapper: React.FC = (props) => {
+ const mockExplorer = new Explorer();
+
+ return (
+
+
+
+ );
+};
+
+describe("useManagedIdentity", () => {
+ const mockUpdateIdentityFn = jest.fn();
+ const mockOnError = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetAccountDetailsFromResourceId.mockReturnValue({
+ subscriptionId: "test-subscription",
+ resourceGroup: "test-resource-group",
+ accountName: "test-account-name",
+ });
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it("should initialize with loading false", () => {
+ render( );
+
+ expect(screen.getByTestId("loading-status")).toHaveTextContent("false");
+ expect(screen.getByTestId("add-identity-button")).toHaveTextContent("Add System Identity");
+ expect(screen.getByTestId("add-identity-button")).not.toBeDisabled();
+ });
+
+ it("should show loading state when handleAddSystemIdentity is called", async () => {
+ mockUpdateIdentityFn.mockImplementation(
+ () => new Promise((resolve) => setTimeout(() => resolve(mockDatabaseAccount), 100)),
+ );
+
+ render( );
+
+ const button = screen.getByTestId("add-identity-button");
+ fireEvent.click(button);
+
+ expect(screen.getByTestId("loading-status")).toHaveTextContent("true");
+ expect(button).toHaveTextContent("Loading...");
+ expect(button).toBeDisabled();
+ });
+
+ it("should call updateIdentityFn with correct parameters", async () => {
+ mockUpdateIdentityFn.mockResolvedValue(mockDatabaseAccount);
+
+ render( );
+
+ const button = screen.getByTestId("add-identity-button");
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ expect(mockUpdateIdentityFn).toHaveBeenCalledWith(
+ "test-subscription",
+ "test-resource-group",
+ "test-account-name",
+ );
+ });
+ });
+
+ it("should handle successful identity update", async () => {
+ const updatedAccount = {
+ ...mockDatabaseAccount,
+ properties: {
+ ...mockDatabaseAccount.properties,
+ identity: { type: "SystemAssigned" },
+ },
+ };
+ mockUpdateIdentityFn.mockResolvedValue(updatedAccount);
+
+ render( );
+
+ const button = screen.getByTestId("add-identity-button");
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ expect(mockUpdateIdentityFn).toHaveBeenCalled();
+ });
+
+ expect(screen.queryByTestId("error-message")).toBeNull();
+ });
+
+ it("should handle error when updateIdentityFn fails", async () => {
+ const errorMessage = "Failed to update identity";
+ mockUpdateIdentityFn.mockRejectedValue(new Error(errorMessage));
+
+ render( );
+
+ const button = screen.getByTestId("add-identity-button");
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("error-message")).toHaveTextContent(errorMessage);
+ });
+
+ expect(mockLogError).toHaveBeenCalledWith(errorMessage, "CopyJob/useManagedIdentity.handleAddSystemIdentity");
+ expect(mockOnError).toHaveBeenCalledWith(errorMessage);
+ });
+
+ it("should handle error without message", async () => {
+ const errorWithoutMessage = {} as Error;
+ mockUpdateIdentityFn.mockRejectedValue(errorWithoutMessage);
+
+ render( );
+
+ const button = screen.getByTestId("add-identity-button");
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("error-message")).toHaveTextContent(
+ "Error enabling system-assigned managed identity. Please try again later.",
+ );
+ });
+
+ expect(mockLogError).toHaveBeenCalledWith(
+ "Error enabling system-assigned managed identity. Please try again later.",
+ "CopyJob/useManagedIdentity.handleAddSystemIdentity",
+ );
+ });
+
+ it("should handle case when getAccountDetailsFromResourceId returns null", async () => {
+ mockGetAccountDetailsFromResourceId.mockReturnValue(null);
+ mockUpdateIdentityFn.mockResolvedValue(undefined);
+
+ render( );
+
+ const button = screen.getByTestId("add-identity-button");
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ expect(mockUpdateIdentityFn).toHaveBeenCalledWith(undefined, undefined, undefined);
+ });
+ });
+
+ it("should handle case when updateIdentityFn returns undefined", async () => {
+ mockUpdateIdentityFn.mockResolvedValue(undefined);
+
+ render( );
+
+ const button = screen.getByTestId("add-identity-button");
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ expect(mockUpdateIdentityFn).toHaveBeenCalled();
+ });
+
+ expect(screen.queryByTestId("error-message")).toBeNull();
+ });
+
+ it("should call getAccountDetailsFromResourceId with target account id", async () => {
+ mockUpdateIdentityFn.mockResolvedValue(mockDatabaseAccount);
+
+ render( );
+
+ const button = screen.getByTestId("add-identity-button");
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalled();
+ });
+
+ const callArgs = mockGetAccountDetailsFromResourceId.mock.calls[0];
+ expect(callArgs).toBeDefined();
+ });
+
+ it("should reset loading state on error", async () => {
+ const errorMessage = "Network error";
+ mockUpdateIdentityFn.mockRejectedValue(new Error(errorMessage));
+
+ render( );
+
+ const button = screen.getByTestId("add-identity-button");
+ fireEvent.click(button);
+
+ expect(screen.getByTestId("loading-status")).toHaveTextContent("true");
+
+ await waitFor(() => {
+ expect(screen.getByTestId("error-message")).toHaveTextContent(errorMessage);
+ });
+
+ expect(screen.getByTestId("loading-status")).toHaveTextContent("false");
+ expect(button).not.toBeDisabled();
+ expect(button).toHaveTextContent("Add System Identity");
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx
index 9ac826e8c..571b1898f 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx
@@ -31,7 +31,7 @@ const useManagedIdentity = (
subscriptionId: targetSubscriptionId,
resourceGroup: targetResourceGroup,
accountName: targetAccountName,
- } = getAccountDetailsFromResourceId(selectedTargetAccount?.id);
+ } = getAccountDetailsFromResourceId(selectedTargetAccount?.id) || {};
const updatedAccount = await updateIdentityFn(targetSubscriptionId, targetResourceGroup, targetAccountName);
if (updatedAccount) {
@@ -44,10 +44,9 @@ const useManagedIdentity = (
const errorMessage = error.message || "Error enabling system-assigned managed identity. Please try again later.";
logError(errorMessage, "CopyJob/useManagedIdentity.handleAddSystemIdentity");
setContextError(errorMessage);
- } finally {
setLoading(false);
}
- }, [copyJobState, updateIdentityFn, setCopyJobState]);
+ }, [copyJobState?.target?.account?.id, updateIdentityFn, setCopyJobState]);
return { loading, handleAddSystemIdentity };
};
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.test.tsx
new file mode 100644
index 000000000..78935657d
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.test.tsx
@@ -0,0 +1,691 @@
+import "@testing-library/jest-dom";
+import { render, screen, waitFor } from "@testing-library/react";
+import React from "react";
+import { noop } from "underscore";
+import { CapabilityNames } from "../../../../../../Common/Constants";
+import * as RbacUtils from "../../../../../../Utils/arm/RbacUtils";
+import {
+ BackupPolicyType,
+ CopyJobMigrationType,
+ DefaultIdentityType,
+ IdentityType,
+} from "../../../../Enums/CopyJobEnums";
+import { CopyJobContextState } from "../../../../Types/CopyJobTypes";
+import * as CopyJobPrerequisitesCacheModule from "../../../Utils/useCopyJobPrerequisitesCache";
+import usePermissionSections, {
+ checkTargetHasReaderRoleOnSource,
+ PermissionGroupConfig,
+ SECTION_IDS,
+} from "./usePermissionsSection";
+
+jest.mock("../../../../../../Utils/arm/RbacUtils");
+jest.mock("../../../Utils/useCopyJobPrerequisitesCache");
+jest.mock("../../../../CopyJobUtils", () => ({
+ getAccountDetailsFromResourceId: jest.fn(() => ({
+ subscriptionId: "sub-123",
+ resourceGroup: "rg-test",
+ accountName: "account-test",
+ })),
+ getContainerIdentifiers: jest.fn((container: any) => ({
+ accountId: container?.account?.id || "default-account-id",
+ })),
+ isIntraAccountCopy: jest.fn((sourceId: string, targetId: string) => sourceId === targetId),
+}));
+
+jest.mock("../AddManagedIdentity", () => {
+ const MockAddManagedIdentity = () => {
+ return AddManagedIdentity
;
+ };
+ MockAddManagedIdentity.displayName = "MockAddManagedIdentity";
+ return MockAddManagedIdentity;
+});
+
+jest.mock("../AddReadPermissionToDefaultIdentity", () => {
+ const MockAddReadPermissionToDefaultIdentity = () => {
+ return AddReadPermissionToDefaultIdentity
;
+ };
+ MockAddReadPermissionToDefaultIdentity.displayName = "MockAddReadPermissionToDefaultIdentity";
+ return MockAddReadPermissionToDefaultIdentity;
+});
+
+jest.mock("../DefaultManagedIdentity", () => {
+ const MockDefaultManagedIdentity = () => {
+ return DefaultManagedIdentity
;
+ };
+ MockDefaultManagedIdentity.displayName = "MockDefaultManagedIdentity";
+ return MockDefaultManagedIdentity;
+});
+
+jest.mock("../OnlineCopyEnabled", () => {
+ const MockOnlineCopyEnabled = () => {
+ return OnlineCopyEnabled
;
+ };
+ MockOnlineCopyEnabled.displayName = "MockOnlineCopyEnabled";
+ return MockOnlineCopyEnabled;
+});
+
+jest.mock("../PointInTimeRestore", () => {
+ const MockPointInTimeRestore = () => {
+ return PointInTimeRestore
;
+ };
+ MockPointInTimeRestore.displayName = "MockPointInTimeRestore";
+ return MockPointInTimeRestore;
+});
+
+const mockedRbacUtils = RbacUtils as jest.Mocked;
+const mockedCopyJobPrerequisitesCache = CopyJobPrerequisitesCacheModule as jest.Mocked<
+ typeof CopyJobPrerequisitesCacheModule
+>;
+
+interface TestWrapperProps {
+ state: CopyJobContextState;
+ onResult?: (result: PermissionGroupConfig[]) => void;
+}
+
+const TestWrapper: React.FC = ({ state, onResult }) => {
+ const result = usePermissionSections(state);
+
+ React.useEffect(() => {
+ if (onResult) {
+ onResult(result);
+ }
+ }, [result, onResult]);
+
+ return (
+
+
{result.length}
+ {result.map((group) => (
+
+
{group.title}
+
{group.description}
+ {group.sections.map((section) => (
+
+
+ {section.completed?.toString() || "undefined"}
+
+ {section.disabled.toString()}
+
+ ))}
+
+ ))}
+
+ );
+};
+
+describe("usePermissionsSection", () => {
+ let mockValidationCache: Map;
+ let mockSetValidationCache: jest.Mock;
+
+ const createMockState = (overrides: Partial = {}): CopyJobContextState => ({
+ jobName: "test-job",
+ migrationType: CopyJobMigrationType.Offline,
+ source: {
+ account: {
+ id: "source-account-id",
+ name: "source-account",
+ properties: {
+ backupPolicy: {
+ type: BackupPolicyType.Periodic,
+ },
+ capabilities: [],
+ },
+ location: "",
+ type: "",
+ kind: "",
+ },
+ subscription: undefined,
+ databaseId: "",
+ containerId: "",
+ },
+ target: {
+ account: {
+ id: "target-account-id",
+ name: "target-account",
+ identity: {
+ type: IdentityType.None,
+ principalId: "principal-123",
+ },
+ properties: {
+ defaultIdentity: DefaultIdentityType.FirstPartyIdentity,
+ },
+ location: "",
+ type: "",
+ kind: "",
+ },
+ subscriptionId: "",
+ databaseId: "",
+ containerId: "",
+ },
+ ...overrides,
+ });
+
+ beforeEach(() => {
+ mockValidationCache = new Map();
+ mockSetValidationCache = jest.fn();
+
+ mockedCopyJobPrerequisitesCache.useCopyJobPrerequisitesCache.mockReturnValue({
+ validationCache: mockValidationCache,
+ setValidationCache: mockSetValidationCache,
+ });
+
+ mockedRbacUtils.fetchRoleAssignments.mockResolvedValue([]);
+ mockedRbacUtils.fetchRoleDefinitions.mockResolvedValue([]);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("Cross-account copy scenarios", () => {
+ it("should return cross-account configuration for different accounts", async () => {
+ const state = createMockState();
+ let capturedResult: PermissionGroupConfig[] = [];
+
+ render( (capturedResult = result)} />);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("groups-count")).toHaveTextContent("1");
+ });
+
+ expect(capturedResult).toHaveLength(1);
+ expect(capturedResult[0].id).toBe("crossAccountConfigs");
+ expect(capturedResult[0].sections).toHaveLength(3);
+ expect(capturedResult[0].sections.map((s) => s.id)).toEqual([
+ SECTION_IDS.addManagedIdentity,
+ SECTION_IDS.defaultManagedIdentity,
+ SECTION_IDS.readPermissionAssigned,
+ ]);
+ });
+
+ it("should not return cross-account configuration for same account (intra-account copy)", async () => {
+ const state = createMockState({
+ source: {
+ account: {
+ id: "same-account-id",
+ name: "same-account",
+ properties: undefined,
+ location: "",
+ type: "",
+ kind: "",
+ },
+ subscription: undefined,
+ databaseId: "",
+ containerId: "",
+ },
+ target: {
+ account: {
+ id: "same-account-id",
+ name: "same-account",
+ identity: { type: IdentityType.None, principalId: "principal-123" },
+ properties: { defaultIdentity: DefaultIdentityType.FirstPartyIdentity },
+ location: "",
+ type: "",
+ kind: "",
+ },
+ subscriptionId: "",
+ databaseId: "",
+ containerId: "",
+ },
+ });
+
+ let capturedResult: PermissionGroupConfig[] = [];
+
+ render( (capturedResult = result)} />);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("groups-count")).toHaveTextContent("0");
+ });
+
+ expect(capturedResult).toHaveLength(0);
+ });
+ });
+
+ describe("Online copy scenarios", () => {
+ it("should return online configuration for online migration", async () => {
+ const state = createMockState({
+ migrationType: CopyJobMigrationType.Online,
+ });
+
+ let capturedResult: PermissionGroupConfig[] = [];
+
+ render( (capturedResult = result)} />);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("groups-count")).toHaveTextContent("2");
+ });
+
+ const onlineGroup = capturedResult.find((g) => g.id === "onlineConfigs");
+ expect(onlineGroup).toBeDefined();
+ expect(onlineGroup?.sections).toHaveLength(2);
+ expect(onlineGroup?.sections.map((s) => s.id)).toEqual([
+ SECTION_IDS.pointInTimeRestore,
+ SECTION_IDS.onlineCopyEnabled,
+ ]);
+ });
+
+ it("should not return online configuration for offline migration", async () => {
+ const state = createMockState({
+ migrationType: CopyJobMigrationType.Offline,
+ });
+
+ let capturedResult: PermissionGroupConfig[] = [];
+
+ render( (capturedResult = result)} />);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("groups-count")).toHaveTextContent("1");
+ });
+
+ const onlineGroup = capturedResult.find((g) => g.id === "onlineConfigs");
+ expect(onlineGroup).toBeUndefined();
+ });
+ });
+
+ describe("Section validation", () => {
+ it("should validate addManagedIdentity section correctly", async () => {
+ const stateWithSystemAssigned = createMockState({
+ target: {
+ account: {
+ id: "target-account-id",
+ name: "target-account",
+ identity: {
+ type: IdentityType.SystemAssigned,
+ principalId: "principal-123",
+ },
+ properties: {
+ defaultIdentity: DefaultIdentityType.FirstPartyIdentity,
+ },
+ location: "",
+ type: "",
+ kind: "",
+ },
+ subscriptionId: "",
+ databaseId: "",
+ containerId: "",
+ },
+ });
+
+ let capturedResult: PermissionGroupConfig[] = [];
+
+ render( (capturedResult = result)} />);
+
+ await waitFor(() => {
+ expect(screen.getByTestId(`section-${SECTION_IDS.addManagedIdentity}-completed`)).toHaveTextContent("true");
+ });
+
+ const crossAccountGroup = capturedResult.find((g) => g.id === "crossAccountConfigs");
+ const addManagedIdentitySection = crossAccountGroup?.sections.find(
+ (s) => s.id === SECTION_IDS.addManagedIdentity,
+ );
+ expect(addManagedIdentitySection?.completed).toBe(true);
+ });
+
+ it("should validate defaultManagedIdentity section correctly", async () => {
+ const stateWithSystemAssignedIdentity = createMockState({
+ target: {
+ account: {
+ id: "target-account-id",
+ name: "target-account",
+ identity: {
+ type: IdentityType.SystemAssigned,
+ principalId: "principal-123",
+ },
+ properties: {
+ defaultIdentity: DefaultIdentityType.SystemAssignedIdentity,
+ },
+ location: "",
+ type: "",
+ kind: "",
+ },
+ subscriptionId: "",
+ databaseId: "",
+ containerId: "",
+ },
+ });
+
+ let capturedResult: PermissionGroupConfig[] = [];
+
+ render( (capturedResult = result)} />);
+
+ await waitFor(() => {
+ expect(screen.getByTestId(`section-${SECTION_IDS.defaultManagedIdentity}-completed`)).toHaveTextContent("true");
+ });
+
+ const crossAccountGroup = capturedResult.find((g) => g.id === "crossAccountConfigs");
+ const defaultManagedIdentitySection = crossAccountGroup?.sections.find(
+ (s) => s.id === SECTION_IDS.defaultManagedIdentity,
+ );
+ expect(defaultManagedIdentitySection?.completed).toBe(true);
+ });
+
+ it("should validate readPermissionAssigned section with reader role", async () => {
+ const mockRoleDefinitions: RbacUtils.RoleDefinitionType[] = [
+ {
+ id: "role-1",
+ name: "Custom Role",
+ permissions: [
+ {
+ dataActions: [
+ "Microsoft.DocumentDB/databaseAccounts/readMetadata",
+ "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
+ ],
+ },
+ ],
+ assignableScopes: [],
+ resourceGroup: "",
+ roleName: "",
+ type: "",
+ typePropertiesType: "",
+ },
+ ];
+
+ mockedRbacUtils.fetchRoleAssignments.mockResolvedValue([{ roleDefinitionId: "role-def-1" }] as any);
+ mockedRbacUtils.fetchRoleDefinitions.mockResolvedValue(mockRoleDefinitions);
+
+ const state = createMockState({
+ target: {
+ account: {
+ id: "target-account-id",
+ name: "target-account",
+ identity: {
+ type: IdentityType.SystemAssigned,
+ principalId: "principal-123",
+ },
+ properties: {
+ defaultIdentity: DefaultIdentityType.SystemAssignedIdentity,
+ },
+ location: "",
+ type: "",
+ kind: "",
+ },
+ subscriptionId: "",
+ databaseId: "",
+ containerId: "",
+ },
+ });
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId(`section-${SECTION_IDS.readPermissionAssigned}-completed`)).toHaveTextContent("true");
+ });
+
+ expect(mockedRbacUtils.fetchRoleAssignments).toHaveBeenCalledWith(
+ "sub-123",
+ "rg-test",
+ "account-test",
+ "principal-123",
+ );
+ });
+
+ it("should validate pointInTimeRestore section for continuous backup", async () => {
+ const state = createMockState({
+ migrationType: CopyJobMigrationType.Online,
+ source: {
+ account: {
+ id: "source-account-id",
+ name: "source-account",
+ properties: {
+ backupPolicy: {
+ type: BackupPolicyType.Continuous,
+ },
+ capabilities: [],
+ },
+ location: "",
+ type: "",
+ kind: "",
+ },
+ subscription: undefined,
+ databaseId: "",
+ containerId: "",
+ },
+ });
+
+ let capturedResult: PermissionGroupConfig[] = [];
+
+ render( (capturedResult = result)} />);
+
+ await waitFor(() => {
+ expect(screen.getByTestId(`section-${SECTION_IDS.pointInTimeRestore}-completed`)).toHaveTextContent("true");
+ });
+
+ const onlineGroup = capturedResult.find((g) => g.id === "onlineConfigs");
+ const pointInTimeSection = onlineGroup?.sections.find((s) => s.id === SECTION_IDS.pointInTimeRestore);
+ expect(pointInTimeSection?.completed).toBe(true);
+ });
+
+ it("should validate onlineCopyEnabled section with proper capability", async () => {
+ const state = createMockState({
+ migrationType: CopyJobMigrationType.Online,
+ source: {
+ account: {
+ id: "source-account-id",
+ name: "source-account",
+ properties: {
+ backupPolicy: {
+ type: BackupPolicyType.Continuous,
+ },
+ capabilities: [
+ {
+ name: CapabilityNames.EnableOnlineCopyFeature,
+ description: "",
+ },
+ ],
+ },
+ location: "",
+ type: "",
+ kind: "",
+ },
+ subscription: undefined,
+ databaseId: "",
+ containerId: "",
+ },
+ });
+
+ let capturedResult: PermissionGroupConfig[] = [];
+
+ render( (capturedResult = result)} />);
+ await waitFor(() => {
+ expect(screen.getByTestId(`section-${SECTION_IDS.onlineCopyEnabled}-completed`)).toHaveTextContent("true");
+ });
+
+ const onlineGroup = capturedResult.find((g) => g.id === "onlineConfigs");
+ const onlineCopySection = onlineGroup?.sections.find((s) => s.id === SECTION_IDS.onlineCopyEnabled);
+ expect(onlineCopySection?.completed).toBe(true);
+ });
+ });
+
+ describe("Validation caching", () => {
+ it("should use cached validation results", async () => {
+ mockValidationCache.set(SECTION_IDS.addManagedIdentity, true);
+ mockValidationCache.set(SECTION_IDS.defaultManagedIdentity, true);
+
+ const state = createMockState();
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId(`section-${SECTION_IDS.addManagedIdentity}-completed`)).toHaveTextContent("true");
+ });
+
+ expect(screen.getByTestId(`section-${SECTION_IDS.defaultManagedIdentity}-completed`)).toHaveTextContent("true");
+ });
+
+ it("should clear online job validation cache when migration type changes to offline", async () => {
+ mockValidationCache.set(SECTION_IDS.pointInTimeRestore, true);
+ mockValidationCache.set(SECTION_IDS.onlineCopyEnabled, true);
+
+ const state = createMockState({
+ migrationType: CopyJobMigrationType.Offline,
+ });
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("groups-count")).toHaveTextContent("1");
+ });
+
+ expect(mockSetValidationCache).toHaveBeenCalled();
+ });
+ });
+
+ describe("Sequential validation within groups", () => {
+ it("should stop validation at first failure within a group", async () => {
+ const state = createMockState({
+ target: {
+ account: {
+ id: "target-account-id",
+ name: "target-account",
+ identity: {
+ type: IdentityType.None,
+ principalId: "principal-123",
+ },
+ properties: {
+ defaultIdentity: DefaultIdentityType.FirstPartyIdentity,
+ },
+ location: "",
+ type: "",
+ kind: "",
+ },
+ subscriptionId: "",
+ databaseId: "",
+ containerId: "",
+ },
+ });
+
+ let capturedResult: PermissionGroupConfig[] = [];
+
+ render( (capturedResult = result)} />);
+
+ await waitFor(() => {
+ expect(screen.getByTestId(`section-${SECTION_IDS.addManagedIdentity}-completed`)).toHaveTextContent("false");
+ });
+
+ const crossAccountGroup = capturedResult.find((g) => g.id === "crossAccountConfigs");
+ expect(crossAccountGroup?.sections[0].completed).toBe(false);
+ expect(crossAccountGroup?.sections[1].completed).toBe(false);
+ expect(crossAccountGroup?.sections[2].completed).toBe(false);
+ });
+ });
+});
+
+describe("checkTargetHasReaderRoleOnSource", () => {
+ it("should return true for built-in Reader role", () => {
+ const roleDefinitions: RbacUtils.RoleDefinitionType[] = [
+ {
+ id: "role-1",
+ name: "00000000-0000-0000-0000-000000000001",
+ permissions: [],
+ assignableScopes: [],
+ resourceGroup: "",
+ roleName: "",
+ type: "",
+ typePropertiesType: "",
+ },
+ ];
+
+ const result = checkTargetHasReaderRoleOnSource(roleDefinitions);
+ expect(result).toBe(true);
+ });
+
+ it("should return true for custom role with required data actions", () => {
+ const roleDefinitions: RbacUtils.RoleDefinitionType[] = [
+ {
+ id: "role-1",
+ name: "Custom Reader Role",
+ permissions: [
+ {
+ dataActions: [
+ "Microsoft.DocumentDB/databaseAccounts/readMetadata",
+ "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
+ ],
+ },
+ ],
+ assignableScopes: [],
+ resourceGroup: "",
+ roleName: "",
+ type: "",
+ typePropertiesType: "",
+ },
+ ];
+
+ const result = checkTargetHasReaderRoleOnSource(roleDefinitions);
+ expect(result).toBe(true);
+ });
+
+ it("should return false for role without required permissions", () => {
+ const roleDefinitions: RbacUtils.RoleDefinitionType[] = [
+ {
+ id: "role-1",
+ name: "Insufficient Role",
+ permissions: [
+ {
+ dataActions: ["Microsoft.DocumentDB/databaseAccounts/readMetadata"],
+ },
+ ],
+ assignableScopes: [],
+ resourceGroup: "",
+ roleName: "",
+ type: "",
+ typePropertiesType: "",
+ },
+ ];
+
+ const result = checkTargetHasReaderRoleOnSource(roleDefinitions);
+ expect(result).toBe(false);
+ });
+
+ it("should return false for empty role definitions", () => {
+ const result = checkTargetHasReaderRoleOnSource([]);
+ expect(result).toBe(false);
+ });
+
+ it("should return false for role definitions without permissions", () => {
+ const roleDefinitions: RbacUtils.RoleDefinitionType[] = [
+ {
+ id: "role-1",
+ name: "No Permissions Role",
+ permissions: [],
+ assignableScopes: [],
+ resourceGroup: "",
+ roleName: "",
+ type: "",
+ typePropertiesType: "",
+ },
+ ];
+
+ const result = checkTargetHasReaderRoleOnSource(roleDefinitions);
+ expect(result).toBe(false);
+ });
+
+ it("should handle multiple roles and return true if any has sufficient permissions", () => {
+ const roleDefinitions: RbacUtils.RoleDefinitionType[] = [
+ {
+ id: "role-1",
+ name: "Insufficient Role",
+ permissions: [
+ {
+ dataActions: ["Microsoft.DocumentDB/databaseAccounts/readMetadata"],
+ },
+ ],
+ assignableScopes: [],
+ resourceGroup: "",
+ roleName: "",
+ type: "",
+ typePropertiesType: "",
+ },
+ {
+ id: "role-2",
+ name: "00000000-0000-0000-0000-000000000001",
+ permissions: [],
+ assignableScopes: [],
+ resourceGroup: "",
+ roleName: "",
+ type: "",
+ typePropertiesType: "",
+ },
+ ];
+
+ const result = checkTargetHasReaderRoleOnSource(roleDefinitions);
+ expect(result).toBe(true);
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx
index 8ee6d8355..747635e6d 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx
@@ -186,15 +186,20 @@ const usePermissionSections = (state: CopyJobContextState): PermissionGroupConfi
const groupsToValidate = useMemo(() => {
const isSameAccount = isIntraAccountCopy(sourceAccount.accountId, targetAccount.accountId);
- const commonSections = isSameAccount ? [] : [...PERMISSION_SECTIONS_CONFIG];
+ const crossAccountSections = isSameAccount ? [] : [...PERMISSION_SECTIONS_CONFIG];
const groups: PermissionGroupConfig[] = [];
+ const sourceAccountName = state.source?.account?.name || "";
+ const targetAccountName = state.target?.account?.name || "";
- if (commonSections.length > 0) {
+ if (crossAccountSections.length > 0) {
groups.push({
- id: "commonConfigs",
- title: ContainerCopyMessages.assignPermissions.commonConfiguration.title,
- description: ContainerCopyMessages.assignPermissions.commonConfiguration.description,
- sections: commonSections,
+ id: "crossAccountConfigs",
+ title: ContainerCopyMessages.assignPermissions.crossAccountConfiguration.title,
+ description: ContainerCopyMessages.assignPermissions.crossAccountConfiguration.description(
+ sourceAccountName,
+ targetAccountName,
+ ),
+ sections: crossAccountSections,
});
}
@@ -202,7 +207,7 @@ const usePermissionSections = (state: CopyJobContextState): PermissionGroupConfi
groups.push({
id: "onlineConfigs",
title: ContainerCopyMessages.assignPermissions.onlineConfiguration.title,
- description: ContainerCopyMessages.assignPermissions.onlineConfiguration.description,
+ description: ContainerCopyMessages.assignPermissions.onlineConfiguration.description(sourceAccountName),
sections: [...PERMISSION_SECTIONS_FOR_ONLINE_JOBS],
});
}
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useToggle.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useToggle.test.tsx
new file mode 100644
index 000000000..e74d334dc
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useToggle.test.tsx
@@ -0,0 +1,78 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import React from "react";
+import useToggle from "./useToggle";
+
+const TestToggleComponent: React.FC<{ initialState?: boolean }> = ({ initialState }) => {
+ const [state, onToggle] = useToggle(initialState);
+
+ return (
+
+ {state ? "true" : "false"}
+ onToggle(null, !state)}>
+ Toggle
+
+ onToggle(null, true)}>
+ Set True
+
+ onToggle(null, false)}>
+ Set False
+
+
+ );
+};
+
+describe("useToggle hook", () => {
+ it("should initialize with false as default", () => {
+ render( );
+
+ const stateElement = screen.getByTestId("toggle-state");
+ expect(stateElement.textContent).toBe("false");
+ });
+
+ it("should initialize with provided initial state", () => {
+ render( );
+
+ const stateElement = screen.getByTestId("toggle-state");
+ expect(stateElement.textContent).toBe("true");
+ });
+
+ it("should toggle state when onToggle is called with opposite value", () => {
+ render( );
+
+ const stateElement = screen.getByTestId("toggle-state");
+ const toggleButton = screen.getByTestId("toggle-button");
+
+ expect(stateElement.textContent).toBe("false");
+
+ fireEvent.click(toggleButton);
+ expect(stateElement.textContent).toBe("true");
+
+ fireEvent.click(toggleButton);
+ expect(stateElement.textContent).toBe("false");
+ });
+
+ it("should handle undefined checked parameter gracefully", () => {
+ const TestUndefinedComponent: React.FC = () => {
+ const [state, onToggle] = useToggle(false);
+
+ return (
+
+ {state ? "true" : "false"}
+ onToggle(null, undefined)}>
+ Set Undefined
+
+
+ );
+ };
+
+ render( );
+
+ const stateElement = screen.getByTestId("toggle-state");
+ const undefinedButton = screen.getByTestId("undefined-button");
+
+ expect(stateElement.textContent).toBe("false");
+
+ fireEvent.click(undefinedButton);
+ expect(stateElement.textContent).toBe("false");
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow.test.tsx
new file mode 100644
index 000000000..33fd7e0ce
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow.test.tsx
@@ -0,0 +1,251 @@
+import "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import React from "react";
+import FieldRow from "./FieldRow";
+
+describe("FieldRow", () => {
+ const mockChildContent = "Test Child Content";
+ const testLabel = "Test Label";
+ const customClassName = "custom-label-class";
+
+ describe("Component Rendering", () => {
+ it("renders the component with correct structure", () => {
+ const { container } = render(
+
+ {mockChildContent}
+ ,
+ );
+
+ expect(container.firstChild).toHaveClass("flex-row");
+ expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument();
+ expect(screen.getByText(mockChildContent)).toBeInTheDocument();
+ });
+
+ it("renders children content correctly", () => {
+ render(
+
+
+ Click me
+ ,
+ );
+
+ expect(screen.getByTestId("test-input")).toBeInTheDocument();
+ expect(screen.getByTestId("test-button")).toBeInTheDocument();
+ });
+
+ it("renders complex children components correctly", () => {
+ const ComplexChild = () => (
+
+ Nested content
+
+
+ );
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("Nested content")).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Enter value")).toBeInTheDocument();
+ });
+
+ it("does not render label when not provided", () => {
+ const { container } = render(
+
+ {mockChildContent}
+ ,
+ );
+
+ expect(container.querySelector("label")).not.toBeInTheDocument();
+ expect(screen.getByText(mockChildContent)).toBeInTheDocument();
+ });
+
+ it("applies custom label className when provided", () => {
+ render(
+
+ {mockChildContent}
+ ,
+ );
+
+ const label = screen.getByText(`${testLabel}:`);
+ expect(label).toHaveClass("field-label", customClassName);
+ });
+ });
+
+ describe("CSS Classes and Styling", () => {
+ it("applies default CSS classes correctly", () => {
+ const { container } = render(
+
+ {mockChildContent}
+ ,
+ );
+
+ const mainContainer = container.firstChild as Element;
+ expect(mainContainer).toHaveClass("flex-row");
+
+ const labelContainer = container.querySelector(".flex-fixed-width");
+ expect(labelContainer).toBeInTheDocument();
+
+ const childContainer = container.querySelector(".flex-grow-col");
+ expect(childContainer).toBeInTheDocument();
+
+ const label = screen.getByText(`${testLabel}:`);
+ expect(label).toHaveClass("field-label");
+ });
+ });
+
+ describe("Layout and Structure", () => {
+ it("uses horizontal Stack with space-between alignment", () => {
+ const { container } = render(
+
+ {mockChildContent}
+ ,
+ );
+
+ const mainContainer = container.firstChild as Element;
+ expect(mainContainer).toHaveClass("flex-row");
+ });
+
+ it("positions label in fixed-width container with center alignment", () => {
+ const { container } = render(
+
+ {mockChildContent}
+ ,
+ );
+
+ const labelContainer = container.querySelector(".flex-fixed-width");
+ expect(labelContainer).toBeInTheDocument();
+ expect(labelContainer).toContainElement(screen.getByText(`${testLabel}:`));
+ });
+
+ it("positions children in grow container with center alignment", () => {
+ const { container } = render(
+
+ {mockChildContent}
+ ,
+ );
+
+ const childContainer = container.querySelector(".flex-grow-col");
+ expect(childContainer).toBeInTheDocument();
+ expect(childContainer).toContainElement(screen.getByTestId("child-content"));
+ });
+
+ it("maintains layout when no label is provided", () => {
+ const { container } = render(
+
+ {mockChildContent}
+ ,
+ );
+
+ expect(container.firstChild).toHaveClass("flex-row");
+ expect(container.querySelector(".flex-fixed-width")).not.toBeInTheDocument();
+
+ const childContainer = container.querySelector(".flex-grow-col");
+ expect(childContainer).toBeInTheDocument();
+ expect(childContainer).toContainElement(screen.getByTestId("child-content"));
+ });
+ });
+
+ describe("Edge Cases and Error Handling", () => {
+ it("handles null children gracefully", () => {
+ render({null} );
+
+ expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument();
+ });
+
+ it("handles zero as children", () => {
+ render({0} );
+
+ expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument();
+ expect(screen.getByText("0")).toBeInTheDocument();
+ });
+
+ it("handles empty string as children", () => {
+ render({""} );
+
+ expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument();
+ });
+
+ it("handles array of children", () => {
+ render({[First , Second ]} );
+
+ expect(screen.getByText(`${testLabel}:`)).toBeInTheDocument();
+ expect(screen.getByText("First")).toBeInTheDocument();
+ expect(screen.getByText("Second")).toBeInTheDocument();
+ });
+ });
+
+ describe("Snapshot Testing", () => {
+ it("matches snapshot with minimal props", () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("matches snapshot with label only", () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("matches snapshot with custom className", () => {
+ const { container } = render(
+
+
+ Option 1
+ Option 2
+
+ ,
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("matches snapshot with complex children", () => {
+ const { container } = render(
+
+
+
+ Enable advanced feature
+ Configure
+
+ ,
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("matches snapshot with no label", () => {
+ const { container } = render(
+
+
+
Section Title
+
Section description goes here
+
+ ,
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("matches snapshot with empty label", () => {
+ const { container } = render(
+
+ Submit Form
+ ,
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/InfoTooltip.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/InfoTooltip.test.tsx
new file mode 100644
index 000000000..c1bb62372
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/InfoTooltip.test.tsx
@@ -0,0 +1,39 @@
+import "@testing-library/jest-dom";
+import { render } from "@testing-library/react";
+import React from "react";
+import InfoTooltip from "./InfoTooltip";
+
+describe("InfoTooltip", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("Component Rendering", () => {
+ it("should render null when no content is provided", () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("should render null when content is undefined", () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("should render tooltip with image when content is provided", () => {
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should render with JSX element content", () => {
+ const jsxContent = (
+
+ Important: This is a JSX tooltip
+
+ );
+
+ const { container } = render( );
+
+ expect(container).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.test.tsx
new file mode 100644
index 000000000..a8cd22e48
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.test.tsx
@@ -0,0 +1,112 @@
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen } from "@testing-library/react";
+import React from "react";
+import NavigationControls from "./NavigationControls";
+
+describe("NavigationControls", () => {
+ const defaultProps = {
+ primaryBtnText: "Next",
+ onPrimary: jest.fn(),
+ onPrevious: jest.fn(),
+ onCancel: jest.fn(),
+ isPrimaryDisabled: false,
+ isPreviousDisabled: false,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("renders all buttons with correct text", () => {
+ render( );
+
+ expect(screen.getByText("Next")).toBeInTheDocument();
+ expect(screen.getByText("Previous")).toBeInTheDocument();
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ });
+
+ it("renders primary button with custom text", () => {
+ const customProps = {
+ ...defaultProps,
+ primaryBtnText: "Complete",
+ };
+ render( );
+
+ expect(screen.getByText("Complete")).toBeInTheDocument();
+ expect(screen.queryByText("Next")).not.toBeInTheDocument();
+ });
+
+ it("calls onPrimary when primary button is clicked", () => {
+ render( );
+
+ fireEvent.click(screen.getByText("Next"));
+ expect(defaultProps.onPrimary).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onPrevious when previous button is clicked", () => {
+ render( );
+
+ fireEvent.click(screen.getByText("Previous"));
+ expect(defaultProps.onPrevious).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls onCancel when cancel button is clicked", () => {
+ render( );
+
+ fireEvent.click(screen.getByText("Cancel"));
+ expect(defaultProps.onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it("disables primary button when isPrimaryDisabled is true", () => {
+ const disabledProps = {
+ ...defaultProps,
+ isPrimaryDisabled: true,
+ };
+ render( );
+ const primaryButton = screen.getByText("Next").closest("button");
+ expect(primaryButton).toHaveAttribute("aria-disabled", "true");
+ expect(primaryButton).toHaveAttribute("data-is-focusable", "true");
+ });
+
+ it("disables previous button when isPreviousDisabled is true", () => {
+ const disabledProps = {
+ ...defaultProps,
+ isPreviousDisabled: true,
+ };
+ render( );
+
+ const previousButton = screen.getByText("Previous").closest("button");
+ expect(previousButton).toHaveAttribute("aria-disabled", "true");
+ expect(previousButton).toHaveAttribute("data-is-focusable", "true");
+ });
+
+ it("does not call onPrimary when disabled primary button is clicked", () => {
+ const disabledProps = {
+ ...defaultProps,
+ isPrimaryDisabled: true,
+ };
+ render( );
+
+ fireEvent.click(screen.getByText("Next"));
+ expect(defaultProps.onPrimary).not.toHaveBeenCalled();
+ });
+
+ it("does not call onPrevious when disabled previous button is clicked", () => {
+ const disabledProps = {
+ ...defaultProps,
+ isPreviousDisabled: true,
+ };
+ render( );
+
+ fireEvent.click(screen.getByText("Previous"));
+ expect(defaultProps.onPrevious).not.toHaveBeenCalled();
+ });
+
+ it("enables both buttons when neither is disabled", () => {
+ render( );
+
+ expect(screen.getByText("Next").closest("button")).not.toHaveAttribute("aria-disabled");
+ expect(screen.getByText("Previous").closest("button")).not.toHaveAttribute("aria-disabled");
+ expect(screen.getByText("Cancel").closest("button")).not.toHaveAttribute("aria-disabled");
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.tsx
index 188c7d352..65a841406 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.tsx
@@ -19,9 +19,21 @@ const NavigationControls: React.FC = ({
isPreviousDisabled,
}) => (
-
-
-
+
+
+
);
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.test.tsx
new file mode 100644
index 000000000..597159be8
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.test.tsx
@@ -0,0 +1,251 @@
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen } from "@testing-library/react";
+import React from "react";
+import ContainerCopyMessages from "../../../ContainerCopyMessages";
+import PopoverMessage from "./PopoverContainer";
+
+jest.mock("../../../../../Common/LoadingOverlay", () => {
+ const MockLoadingOverlay = ({ isLoading, label }: { isLoading: boolean; label: string }) => {
+ return isLoading ?
: null;
+ };
+ MockLoadingOverlay.displayName = "MockLoadingOverlay";
+ return MockLoadingOverlay;
+});
+
+describe("PopoverMessage Component", () => {
+ const defaultProps = {
+ visible: true,
+ title: "Test Title",
+ onCancel: jest.fn(),
+ onPrimary: jest.fn(),
+ children: Test content
,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("Rendering", () => {
+ it("should render correctly when visible", () => {
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should render correctly when not visible", () => {
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should render correctly with loading state", () => {
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should render correctly with different title", () => {
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should render correctly with different children content", () => {
+ const customChildren = (
+
+
First paragraph
+
Second paragraph
+
+ );
+ const { container } = render({customChildren} );
+ expect(container).toMatchSnapshot();
+ });
+ });
+
+ describe("Visibility", () => {
+ it("should not render anything when visible is false", () => {
+ render( );
+ expect(screen.queryByText("Test Title")).not.toBeInTheDocument();
+ expect(screen.queryByText("Test content")).not.toBeInTheDocument();
+ });
+
+ it("should render content when visible is true", () => {
+ render( );
+ expect(screen.getByText("Test Title")).toBeInTheDocument();
+ expect(screen.getByText("Test content")).toBeInTheDocument();
+ });
+ });
+
+ describe("Title Display", () => {
+ it("should display the provided title", () => {
+ render( );
+ expect(screen.getByText("Custom Popover Title")).toBeInTheDocument();
+ });
+
+ it("should handle empty title", () => {
+ render( );
+ expect(screen.queryByText("Test Title")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Children Content", () => {
+ it("should render children content", () => {
+ const customChildren = Custom child content ;
+ render({customChildren} );
+ expect(screen.getByText("Custom child content")).toBeInTheDocument();
+ });
+
+ it("should render complex children content", () => {
+ const complexChildren = (
+
+ );
+ render({complexChildren} );
+ expect(screen.getByText("Heading")).toBeInTheDocument();
+ expect(screen.getByText("Item 1")).toBeInTheDocument();
+ expect(screen.getByText("Item 2")).toBeInTheDocument();
+ });
+ });
+
+ describe("Button Interactions", () => {
+ it("should call onPrimary when Yes button is clicked", () => {
+ const onPrimaryMock = jest.fn();
+ render( );
+
+ const yesButton = screen.getByText("Yes");
+ fireEvent.click(yesButton);
+
+ expect(onPrimaryMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call onCancel when No button is clicked", () => {
+ const onCancelMock = jest.fn();
+ render( );
+
+ const noButton = screen.getByText("No");
+ fireEvent.click(noButton);
+
+ expect(onCancelMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("should not call handlers multiple times on rapid clicks", () => {
+ const onPrimaryMock = jest.fn();
+ const onCancelMock = jest.fn();
+ render( );
+
+ const yesButton = screen.getByText("Yes");
+ const noButton = screen.getByText("No");
+
+ fireEvent.click(yesButton);
+ fireEvent.click(yesButton);
+ fireEvent.click(noButton);
+ fireEvent.click(noButton);
+
+ expect(onPrimaryMock).toHaveBeenCalledTimes(2);
+ expect(onCancelMock).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe("Loading State", () => {
+ test("should show loading overlay when isLoading is true", () => {
+ render( );
+ expect(screen.getByTestId("loading-overlay")).toBeInTheDocument();
+ });
+
+ it("should not show loading overlay when isLoading is false", () => {
+ render( );
+ expect(screen.queryByTestId("loading-overlay")).not.toBeInTheDocument();
+ });
+
+ it("should disable buttons when loading", () => {
+ render( );
+
+ const yesButton = screen.getByText("Yes").closest("button");
+ const noButton = screen.getByText("No").closest("button");
+
+ expect(yesButton).toHaveAttribute("aria-disabled", "true");
+ expect(noButton).toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("should enable buttons when not loading", () => {
+ render( );
+
+ const yesButton = screen.getByText("Yes").closest("button");
+ const noButton = screen.getByText("No").closest("button");
+
+ expect(yesButton).not.toHaveAttribute("aria-disabled");
+ expect(noButton).not.toHaveAttribute("aria-disabled");
+ });
+
+ it("should use correct loading overlay label", () => {
+ render( );
+ const loadingOverlay = screen.getByTestId("loading-overlay");
+ expect(loadingOverlay).toHaveAttribute("aria-label", ContainerCopyMessages.popoverOverlaySpinnerLabel);
+ });
+ });
+
+ describe("Default Props", () => {
+ it("should handle missing isLoading prop (defaults to false)", () => {
+ const propsWithoutLoading = { ...defaultProps };
+ delete (propsWithoutLoading as any).isLoading;
+
+ render( );
+
+ expect(screen.queryByTestId("loading-overlay")).not.toBeInTheDocument();
+ expect(screen.getByText("Yes")).not.toBeDisabled();
+ expect(screen.getByText("No")).not.toBeDisabled();
+ });
+ });
+
+ describe("CSS Classes and Styling", () => {
+ it("should apply correct CSS classes", () => {
+ const { container } = render( );
+ const popoverContainer = container.querySelector(".popover-container");
+
+ expect(popoverContainer).toHaveClass("foreground");
+ });
+
+ it("should apply loading class when isLoading is true", () => {
+ const { container } = render( );
+ const popoverContainer = container.querySelector(".popover-container");
+
+ expect(popoverContainer).toHaveClass("loading");
+ });
+
+ it("should not apply loading class when isLoading is false", () => {
+ const { container } = render( );
+ const popoverContainer = container.querySelector(".popover-container");
+
+ expect(popoverContainer).not.toHaveClass("loading");
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("should handle undefined children", () => {
+ const propsWithUndefinedChildren = { ...defaultProps, children: undefined as React.ReactNode };
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should handle null children", () => {
+ const propsWithNullChildren = { ...defaultProps, children: null as React.ReactNode };
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should handle empty string title", () => {
+ const propsWithEmptyTitle = { ...defaultProps, title: "" };
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should handle very long title", () => {
+ const longTitle =
+ "This is a very long title that might cause layout issues or text wrapping in the popover component";
+ const propsWithLongTitle = { ...defaultProps, title: longTitle };
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx
index 5a76d66eb..8ec30dedc 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx
@@ -17,15 +17,16 @@ const PopoverContainer: React.FC = React.memo(
({ isLoading = false, title, children, onPrimary, onCancel }) => {
return (
-
+
{title}
- {children}
+ {children}
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/FieldRow.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/FieldRow.test.tsx.snap
new file mode 100644
index 000000000..697d1da47
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/FieldRow.test.tsx.snap
@@ -0,0 +1,145 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FieldRow Snapshot Testing matches snapshot with complex children 1`] = `
+
+
+
+ Advanced Settings
+ :
+
+
+
+
+
+
+ Enable advanced feature
+
+
+ Configure
+
+
+
+
+`;
+
+exports[`FieldRow Snapshot Testing matches snapshot with custom className 1`] = `
+
+
+
+ Container Name
+ :
+
+
+
+
+
+ Option 1
+
+
+ Option 2
+
+
+
+
+`;
+
+exports[`FieldRow Snapshot Testing matches snapshot with empty label 1`] = `
+
+
+
+
+ Submit Form
+
+
+
+`;
+
+exports[`FieldRow Snapshot Testing matches snapshot with label only 1`] = `
+
+
+
+ Database Name
+ :
+
+
+
+
+
+
+`;
+
+exports[`FieldRow Snapshot Testing matches snapshot with minimal props 1`] = `
+
+`;
+
+exports[`FieldRow Snapshot Testing matches snapshot with no label 1`] = `
+
+
+
+
+
+ Section Title
+
+
+ Section description goes here
+
+
+
+
+`;
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/InfoTooltip.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/InfoTooltip.test.tsx.snap
new file mode 100644
index 000000000..8b79c2b74
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/InfoTooltip.test.tsx.snap
@@ -0,0 +1,60 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`InfoTooltip Component Rendering should render tooltip with image when content is provided 1`] = `
+
+`;
+
+exports[`InfoTooltip Component Rendering should render with JSX element content 1`] = `
+
+`;
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/PopoverContainer.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/PopoverContainer.test.tsx.snap
new file mode 100644
index 000000000..7a6897fd6
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/PopoverContainer.test.tsx.snap
@@ -0,0 +1,560 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`PopoverMessage Component Edge Cases should handle empty string title 1`] = `
+
+
+
+
+
+ Test content
+
+
+
+
+
+
+
+ Yes
+
+
+
+
+
+
+
+
+ No
+
+
+
+
+
+
+
+`;
+
+exports[`PopoverMessage Component Edge Cases should handle null children 1`] = `
+
+
+
+ Test Title
+
+
+
+
+
+
+ Yes
+
+
+
+
+
+
+
+
+ No
+
+
+
+
+
+
+
+`;
+
+exports[`PopoverMessage Component Edge Cases should handle undefined children 1`] = `
+
+
+
+ Test Title
+
+
+
+
+
+
+ Yes
+
+
+
+
+
+
+
+
+ No
+
+
+
+
+
+
+
+`;
+
+exports[`PopoverMessage Component Edge Cases should handle very long title 1`] = `
+
+
+
+ This is a very long title that might cause layout issues or text wrapping in the popover component
+
+
+
+ Test content
+
+
+
+
+
+
+
+ Yes
+
+
+
+
+
+
+
+
+ No
+
+
+
+
+
+
+
+`;
+
+exports[`PopoverMessage Component Rendering should render correctly when not visible 1`] = `
`;
+
+exports[`PopoverMessage Component Rendering should render correctly when visible 1`] = `
+
+
+
+ Test Title
+
+
+
+ Test content
+
+
+
+
+
+
+
+ Yes
+
+
+
+
+
+
+
+
+ No
+
+
+
+
+
+
+
+`;
+
+exports[`PopoverMessage Component Rendering should render correctly with different children content 1`] = `
+
+
+
+ Test Title
+
+
+
+
+ First paragraph
+
+
+ Second paragraph
+
+
+
+
+
+
+
+
+ Yes
+
+
+
+
+
+
+
+
+ No
+
+
+
+
+
+
+
+`;
+
+exports[`PopoverMessage Component Rendering should render correctly with different title 1`] = `
+
+
+
+ Custom Title
+
+
+
+ Test content
+
+
+
+
+
+
+
+ Yes
+
+
+
+
+
+
+
+
+ No
+
+
+
+
+
+
+
+`;
+
+exports[`PopoverMessage Component Rendering should render correctly with loading state 1`] = `
+
+
+
+
+ Test Title
+
+
+
+ Test content
+
+
+
+
+
+
+
+ Yes
+
+
+
+
+
+
+
+
+ No
+
+
+
+
+
+
+
+`;
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.test.tsx
new file mode 100644
index 000000000..226ea15af
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.test.tsx
@@ -0,0 +1,261 @@
+import "@testing-library/jest-dom";
+import { render, screen, waitFor } from "@testing-library/react";
+import { CopyJobMigrationType } from "Explorer/ContainerCopy/Enums/CopyJobEnums";
+import { CopyJobContextProviderType } from "Explorer/ContainerCopy/Types/CopyJobTypes";
+import Explorer from "Explorer/Explorer";
+import { useSidePanel } from "hooks/useSidePanel";
+import React from "react";
+import ContainerCopyMessages from "../../../ContainerCopyMessages";
+import { useCopyJobContext } from "../../../Context/CopyJobContext";
+import AddCollectionPanelWrapper from "./AddCollectionPanelWrapper";
+
+jest.mock("hooks/useSidePanel");
+jest.mock("../../../Context/CopyJobContext");
+
+jest.mock("../../../../Panes/AddCollectionPanel/AddCollectionPanel", () => ({
+ AddCollectionPanel: ({
+ explorer,
+ isCopyJobFlow,
+ onSubmitSuccess,
+ }: {
+ explorer?: Explorer;
+ isCopyJobFlow: boolean;
+ onSubmitSuccess: (data: { databaseId: string; collectionId: string }) => void;
+ }) => (
+
+
{explorer ? "explorer-present" : "no-explorer"}
+
{isCopyJobFlow ? "true" : "false"}
+
onSubmitSuccess({ databaseId: "test-db", collectionId: "test-collection" })}
+ >
+ Submit
+
+
+ ),
+}));
+
+jest.mock("immer", () => ({
+ produce: jest.fn((updater) => (state: any) => {
+ const draft = { ...state };
+ updater(draft);
+ return draft;
+ }),
+}));
+
+const mockUseSidePanel = useSidePanel as jest.MockedFunction;
+const mockUseCopyJobContext = useCopyJobContext as jest.MockedFunction;
+
+describe("AddCollectionPanelWrapper", () => {
+ const mockSetCopyJobState = jest.fn();
+ const mockGoBack = jest.fn();
+ const mockSetHeaderText = jest.fn();
+ const mockExplorer = {} as Explorer;
+
+ const mockSidePanelState = {
+ isOpen: false,
+ panelWidth: "440px",
+ hasConsole: true,
+ headerText: "",
+ setHeaderText: mockSetHeaderText,
+ openSidePanel: jest.fn(),
+ closeSidePanel: jest.fn(),
+ setPanelHasConsole: jest.fn(),
+ };
+
+ const mockCopyJobContextValue = {
+ contextError: null,
+ setContextError: jest.fn(),
+ copyJobState: {
+ jobName: "",
+ migrationType: CopyJobMigrationType.Offline,
+ source: {
+ subscription: { subscriptionId: "" },
+ account: null,
+ databaseId: "",
+ containerId: "",
+ },
+ target: {
+ subscriptionId: "",
+ account: null,
+ databaseId: "",
+ containerId: "",
+ },
+ sourceReadAccessFromTarget: false,
+ },
+ setCopyJobState: mockSetCopyJobState,
+ flow: null,
+ setFlow: jest.fn(),
+ resetCopyJobState: jest.fn(),
+ explorer: mockExplorer,
+ } as unknown as CopyJobContextProviderType;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockUseSidePanel.mockReturnValue(mockSidePanelState);
+ mockUseSidePanel.getState = jest.fn().mockReturnValue(mockSidePanelState);
+ mockUseCopyJobContext.mockReturnValue(mockCopyJobContextValue);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe("Component Rendering", () => {
+ it("should render correctly with all required elements", () => {
+ const { container } = render( );
+
+ expect(container.querySelector(".addCollectionPanelWrapper")).toBeInTheDocument();
+ expect(container.querySelector(".addCollectionPanelHeader")).toBeInTheDocument();
+ expect(container.querySelector(".addCollectionPanelBody")).toBeInTheDocument();
+ expect(screen.getByText(ContainerCopyMessages.createNewContainerSubHeading)).toBeInTheDocument();
+ expect(screen.getByTestId("add-collection-panel")).toBeInTheDocument();
+ });
+
+ it("should match snapshot", () => {
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should match snapshot with explorer prop", () => {
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should match snapshot with goBack prop", () => {
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should match snapshot with both props", () => {
+ const { container } = render( );
+ expect(container).toMatchSnapshot();
+ });
+ });
+
+ describe("Side Panel Header Management", () => {
+ it("should set header text to create container heading on mount", () => {
+ render( );
+
+ expect(mockSetHeaderText).toHaveBeenCalledWith(ContainerCopyMessages.createContainerHeading);
+ });
+
+ it("should reset header text to create copy job panel title on unmount", () => {
+ const { unmount } = render( );
+
+ unmount();
+
+ expect(mockSetHeaderText).toHaveBeenCalledWith(ContainerCopyMessages.createCopyJobPanelTitle);
+ });
+
+ it("should not change header text if already set correctly", () => {
+ const modifiedSidePanelState = {
+ ...mockSidePanelState,
+ headerText: ContainerCopyMessages.createContainerHeading,
+ };
+
+ mockUseSidePanel.getState = jest.fn().mockReturnValue(modifiedSidePanelState);
+
+ render( );
+
+ expect(mockSetHeaderText).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("AddCollectionPanel Integration", () => {
+ it("should pass explorer prop to AddCollectionPanel", () => {
+ render( );
+
+ expect(screen.getByTestId("explorer-prop")).toHaveTextContent("explorer-present");
+ });
+
+ it("should pass undefined explorer to AddCollectionPanel when not provided", () => {
+ render( );
+
+ expect(screen.getByTestId("explorer-prop")).toHaveTextContent("no-explorer");
+ });
+
+ it("should pass isCopyJobFlow as true to AddCollectionPanel", () => {
+ render( );
+
+ expect(screen.getByTestId("copy-job-flow")).toHaveTextContent("true");
+ });
+ });
+
+ describe("Collection Success Handler", () => {
+ it("should update copy job state when handleAddCollectionSuccess is called", async () => {
+ render( );
+
+ const submitButton = screen.getByTestId("submit-button");
+ submitButton.click();
+
+ await waitFor(() => {
+ expect(mockSetCopyJobState).toHaveBeenCalledTimes(1);
+ });
+
+ const stateUpdater = mockSetCopyJobState.mock.calls[0][0];
+ const mockState = {
+ target: { databaseId: "", containerId: "" },
+ };
+
+ const updatedState = stateUpdater(mockState);
+ expect(updatedState.target.databaseId).toBe("test-db");
+ expect(updatedState.target.containerId).toBe("test-collection");
+ });
+
+ it("should call goBack when handleAddCollectionSuccess is called and goBack is provided", async () => {
+ render( );
+
+ const submitButton = screen.getByTestId("submit-button");
+ submitButton.click();
+
+ await waitFor(() => {
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it("should not call goBack when handleAddCollectionSuccess is called and goBack is not provided", async () => {
+ render( );
+
+ const submitButton = screen.getByTestId("submit-button");
+ submitButton.click();
+
+ await waitFor(() => {
+ expect(mockSetCopyJobState).toHaveBeenCalledTimes(1);
+ });
+
+ expect(mockGoBack).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("Error Handling", () => {
+ it("should handle missing setCopyJobState gracefully", () => {
+ const mockCopyJobContextValueWithoutSetState = {
+ ...mockCopyJobContextValue,
+ setCopyJobState: undefined as any,
+ };
+
+ mockUseCopyJobContext.mockReturnValue(mockCopyJobContextValueWithoutSetState);
+
+ expect(() => render( )).not.toThrow();
+ });
+ });
+
+ describe("Component Lifecycle", () => {
+ it("should properly cleanup on unmount", () => {
+ const { unmount } = render( );
+ expect(mockSetHeaderText).toHaveBeenCalledWith(ContainerCopyMessages.createContainerHeading);
+ mockSetHeaderText.mockClear();
+ unmount();
+ expect(mockSetHeaderText).toHaveBeenCalledWith(ContainerCopyMessages.createCopyJobPanelTitle);
+ });
+
+ it("should re-render correctly when props change", () => {
+ const { rerender } = render( );
+ expect(screen.getByTestId("explorer-prop")).toHaveTextContent("no-explorer");
+ rerender( );
+ expect(screen.getByTestId("explorer-prop")).toHaveTextContent("explorer-present");
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx
index 01a2db73f..a274c9bbc 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx
@@ -41,7 +41,7 @@ const AddCollectionPanelWrapper: React.FunctionComponent
- {ContainerCopyMessages.createNewContainerSubHeading}
+ {ContainerCopyMessages.createNewContainerSubHeading}
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap
new file mode 100644
index 000000000..ae6d7b4ec
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap
@@ -0,0 +1,165 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AddCollectionPanelWrapper Component Rendering should match snapshot 1`] = `
+
+
+
+
+
+
+ no-explorer
+
+
+ true
+
+
+ Submit
+
+
+
+
+
+`;
+
+exports[`AddCollectionPanelWrapper Component Rendering should match snapshot with both props 1`] = `
+
+
+
+
+
+
+ explorer-present
+
+
+ true
+
+
+ Submit
+
+
+
+
+
+`;
+
+exports[`AddCollectionPanelWrapper Component Rendering should match snapshot with explorer prop 1`] = `
+
+
+
+
+
+
+ explorer-present
+
+
+ true
+
+
+ Submit
+
+
+
+
+
+`;
+
+exports[`AddCollectionPanelWrapper Component Rendering should match snapshot with goBack prop 1`] = `
+
+
+
+
+
+
+ no-explorer
+
+
+ true
+
+
+ Submit
+
+
+
+
+
+`;
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.test.tsx
new file mode 100644
index 000000000..bbff5758e
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.test.tsx
@@ -0,0 +1,426 @@
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen } from "@testing-library/react";
+import React from "react";
+import CreateCopyJobScreens from "./CreateCopyJobScreens";
+
+jest.mock("../../Context/CopyJobContext", () => ({
+ useCopyJobContext: jest.fn(),
+}));
+
+jest.mock("../Utils/useCopyJobNavigation", () => ({
+ useCopyJobNavigation: jest.fn(),
+}));
+
+jest.mock("./Components/NavigationControls", () => {
+ const MockedNavigationControls = ({
+ primaryBtnText,
+ onPrimary,
+ onPrevious,
+ onCancel,
+ isPrimaryDisabled,
+ isPreviousDisabled,
+ }: {
+ primaryBtnText: string;
+ onPrimary: () => void;
+ onPrevious: () => void;
+ onCancel: () => void;
+ isPrimaryDisabled: boolean;
+ isPreviousDisabled: boolean;
+ }) => (
+
+
+ {primaryBtnText}
+
+
+ Previous
+
+
+ Cancel
+
+
+ );
+ return MockedNavigationControls;
+});
+
+import { useCopyJobContext } from "../../Context/CopyJobContext";
+import { useCopyJobNavigation } from "../Utils/useCopyJobNavigation";
+
+const createMockNavigationHook = (overrides = {}) => ({
+ currentScreen: {
+ key: "SelectAccount",
+ component: Mock Screen Component
,
+ },
+ isPrimaryDisabled: false,
+ isPreviousDisabled: true,
+ handlePrimary: jest.fn(),
+ handlePrevious: jest.fn(),
+ handleCancel: jest.fn(),
+ primaryBtnText: "Next",
+ showAddCollectionPanel: jest.fn(),
+ ...overrides,
+});
+
+const createMockContext = (overrides = {}) => ({
+ contextError: "",
+ setContextError: jest.fn(),
+ copyJobState: {},
+ setCopyJobState: jest.fn(),
+ flow: {},
+ setFlow: jest.fn(),
+ resetCopyJobState: jest.fn(),
+ explorer: {},
+ ...overrides,
+});
+
+describe("CreateCopyJobScreens", () => {
+ const mockNavigationHook = createMockNavigationHook();
+ const mockContext = createMockContext();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useCopyJobNavigation as jest.Mock).mockReturnValue(mockNavigationHook);
+ (useCopyJobContext as jest.Mock).mockReturnValue(mockContext);
+ });
+
+ describe("Rendering", () => {
+ test("should render without error", () => {
+ render( );
+ expect(screen.getByTestId("mock-screen")).toBeInTheDocument();
+ expect(screen.getByTestId("navigation-controls")).toBeInTheDocument();
+ });
+
+ test("should render current screen component", () => {
+ const customScreen = Custom Screen Content
;
+ (useCopyJobNavigation as jest.Mock).mockReturnValue(
+ createMockNavigationHook({
+ currentScreen: { component: customScreen },
+ }),
+ );
+
+ render( );
+ expect(screen.getByTestId("custom-screen")).toBeInTheDocument();
+ expect(screen.getByText("Custom Screen Content")).toBeInTheDocument();
+ });
+
+ test("should have correct CSS classes", () => {
+ const { container } = render( );
+ const mainContainer = container.querySelector(".createCopyJobScreensContainer");
+ const contentContainer = container.querySelector(".createCopyJobScreensContent");
+ const footerContainer = container.querySelector(".createCopyJobScreensFooter");
+
+ expect(mainContainer).toBeInTheDocument();
+ expect(contentContainer).toBeInTheDocument();
+ expect(footerContainer).toBeInTheDocument();
+ });
+ });
+
+ describe("Error Message Bar", () => {
+ test("should not show error message bar when no error", () => {
+ render( );
+ expect(screen.queryByRole("region")).not.toBeInTheDocument();
+ });
+
+ test("should show error message bar when context error exists", () => {
+ const errorMessage = "Something went wrong";
+ (useCopyJobContext as jest.Mock).mockReturnValue(
+ createMockContext({
+ contextError: errorMessage,
+ }),
+ );
+
+ render( );
+ const messageBar = screen.getByRole("region");
+ expect(messageBar).toBeInTheDocument();
+ expect(messageBar).toHaveClass("createCopyJobErrorMessageBar");
+ });
+
+ test("should have correct error message bar properties", () => {
+ const errorMessage = "Test error message";
+ (useCopyJobContext as jest.Mock).mockReturnValue(
+ createMockContext({
+ contextError: errorMessage,
+ }),
+ );
+
+ render( );
+ const messageBar = screen.getByRole("region");
+
+ expect(messageBar).toHaveClass("createCopyJobErrorMessageBar");
+ });
+
+ test("should call setContextError when dismiss button is clicked", () => {
+ const mockSetContextError = jest.fn();
+ (useCopyJobContext as jest.Mock).mockReturnValue(
+ createMockContext({
+ contextError: "Test error",
+ setContextError: mockSetContextError,
+ }),
+ );
+
+ render( );
+
+ const dismissButton = screen.getByLabelText("Close");
+ fireEvent.click(dismissButton);
+
+ expect(mockSetContextError).toHaveBeenCalledWith(null);
+ });
+
+ test("should show overflow button with correct aria label", () => {
+ (useCopyJobContext as jest.Mock).mockReturnValue(
+ createMockContext({
+ contextError: "A very long error message that should trigger overflow behavior",
+ }),
+ );
+
+ render( );
+ const overflowButton = screen.getByLabelText("See more");
+ expect(overflowButton).toBeInTheDocument();
+ });
+ });
+
+ describe("Navigation Controls Integration", () => {
+ test("should pass correct props to NavigationControls", () => {
+ const mockHook = createMockNavigationHook({
+ primaryBtnText: "Create",
+ isPrimaryDisabled: true,
+ isPreviousDisabled: false,
+ });
+ (useCopyJobNavigation as jest.Mock).mockReturnValue(mockHook);
+
+ render( );
+
+ const primaryButton = screen.getByTestId("primary-button");
+ const previousButton = screen.getByTestId("previous-button");
+
+ expect(primaryButton).toHaveTextContent("Create");
+ expect(primaryButton).toBeDisabled();
+ expect(previousButton).not.toBeDisabled();
+ });
+
+ test("should call navigation handlers when buttons are clicked", () => {
+ const mockHandlePrimary = jest.fn();
+ const mockHandlePrevious = jest.fn();
+ const mockHandleCancel = jest.fn();
+
+ (useCopyJobNavigation as jest.Mock).mockReturnValue(
+ createMockNavigationHook({
+ handlePrimary: mockHandlePrimary,
+ handlePrevious: mockHandlePrevious,
+ handleCancel: mockHandleCancel,
+ isPrimaryDisabled: false,
+ isPreviousDisabled: false,
+ }),
+ );
+
+ render( );
+
+ fireEvent.click(screen.getByTestId("primary-button"));
+ fireEvent.click(screen.getByTestId("previous-button"));
+ fireEvent.click(screen.getByTestId("cancel-button"));
+
+ expect(mockHandlePrimary).toHaveBeenCalledTimes(1);
+ expect(mockHandlePrevious).toHaveBeenCalledTimes(1);
+ expect(mockHandleCancel).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("Screen Component Props", () => {
+ test("should pass showAddCollectionPanel prop to screen component", () => {
+ const mockShowAddCollectionPanel = jest.fn();
+ const TestScreen = ({ showAddCollectionPanel }: { showAddCollectionPanel: () => void }) => (
+
+
+ Add Collection
+
+
+ );
+
+ (useCopyJobNavigation as jest.Mock).mockReturnValue(
+ createMockNavigationHook({
+ currentScreen: { component: {}} /> },
+ showAddCollectionPanel: mockShowAddCollectionPanel,
+ }),
+ );
+
+ render( );
+
+ const addButton = screen.getByTestId("add-collection-btn");
+ expect(addButton).toBeInTheDocument();
+ });
+
+ test("should handle screen component without props", () => {
+ const SimpleScreen = () => Simple Screen
;
+
+ (useCopyJobNavigation as jest.Mock).mockReturnValue(
+ createMockNavigationHook({
+ currentScreen: { component: },
+ }),
+ );
+
+ expect(() => render( )).not.toThrow();
+ expect(screen.getByTestId("simple-screen")).toBeInTheDocument();
+ });
+ });
+
+ describe("Layout and Structure", () => {
+ test("should maintain vertical layout with space-between alignment", () => {
+ const { container } = render( );
+ const stackContainer = container.querySelector(".createCopyJobScreensContainer");
+
+ expect(stackContainer).toBeInTheDocument();
+ });
+
+ test("should have content area above navigation controls", () => {
+ const { container } = render( );
+
+ const content = container.querySelector(".createCopyJobScreensContent");
+ const footer = container.querySelector(".createCopyJobScreensFooter");
+
+ expect(content).toBeInTheDocument();
+ expect(footer).toBeInTheDocument();
+
+ const contentIndex = Array.from(container.querySelectorAll("*")).indexOf(content!);
+ const footerIndex = Array.from(container.querySelectorAll("*")).indexOf(footer!);
+ expect(contentIndex).toBeLessThan(footerIndex);
+ });
+ });
+
+ describe("Error Scenarios", () => {
+ test("should handle missing current screen gracefully", () => {
+ (useCopyJobNavigation as jest.Mock).mockReturnValue(
+ createMockNavigationHook({
+ currentScreen: null,
+ }),
+ );
+ expect(() => render( )).toThrow();
+ });
+
+ test("should handle missing screen component", () => {
+ (useCopyJobNavigation as jest.Mock).mockReturnValue(
+ createMockNavigationHook({
+ currentScreen: { key: "test", component: null },
+ }),
+ );
+ expect(() => render( )).toThrow();
+ });
+
+ test("should render with valid screen component", () => {
+ (useCopyJobNavigation as jest.Mock).mockReturnValue(
+ createMockNavigationHook({
+ currentScreen: {
+ key: "test",
+ component: Valid Screen
,
+ },
+ }),
+ );
+
+ expect(() => render( )).not.toThrow();
+ expect(screen.getByTestId("valid-screen")).toBeInTheDocument();
+ });
+
+ test("should handle context hook throwing error", () => {
+ (useCopyJobContext as jest.Mock).mockImplementation(() => {
+ throw new Error("Context not available");
+ });
+
+ expect(() => render( )).toThrow("Context not available");
+ });
+
+ test("should handle navigation hook throwing error", () => {
+ (useCopyJobNavigation as jest.Mock).mockImplementation(() => {
+ throw new Error("Navigation not available");
+ });
+
+ expect(() => render( )).toThrow("Navigation not available");
+ });
+ });
+
+ describe("Multiple Error States", () => {
+ test("should handle error message changes", () => {
+ const mockSetContextError = jest.fn();
+ const { rerender } = render( );
+
+ expect(screen.queryByRole("region")).not.toBeInTheDocument();
+
+ (useCopyJobContext as jest.Mock).mockReturnValue(
+ createMockContext({
+ contextError: "First error",
+ setContextError: mockSetContextError,
+ }),
+ );
+ rerender( );
+ expect(screen.getByRole("region")).toBeInTheDocument();
+
+ (useCopyJobContext as jest.Mock).mockReturnValue(
+ createMockContext({
+ contextError: "Second error",
+ setContextError: mockSetContextError,
+ }),
+ );
+ rerender( );
+ expect(screen.getByRole("region")).toBeInTheDocument();
+
+ (useCopyJobContext as jest.Mock).mockReturnValue(
+ createMockContext({
+ contextError: null,
+ setContextError: mockSetContextError,
+ }),
+ );
+ rerender( );
+ expect(screen.queryByRole("region")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Accessibility", () => {
+ test("should have proper ARIA labels for message bar", () => {
+ (useCopyJobContext as jest.Mock).mockReturnValue(
+ createMockContext({
+ contextError: "Test error",
+ }),
+ );
+
+ render( );
+
+ const dismissButton = screen.getByLabelText("Close");
+ const overflowButton = screen.getByLabelText("See more");
+
+ expect(dismissButton).toBeInTheDocument();
+ expect(overflowButton).toBeInTheDocument();
+ });
+
+ test("should have proper region role for message bar", () => {
+ (useCopyJobContext as jest.Mock).mockReturnValue(
+ createMockContext({
+ contextError: "Test error",
+ }),
+ );
+
+ render( );
+ const messageRegion = screen.getByRole("region");
+ expect(messageRegion).toBeInTheDocument();
+
+ const alert = screen.getByRole("alert");
+ expect(alert).toBeInTheDocument();
+ });
+ });
+
+ describe("Component Integration", () => {
+ test("should integrate with both context and navigation hooks", () => {
+ const mockContext = createMockContext({
+ contextError: "Integration test error",
+ });
+ const mockNavigation = createMockNavigationHook({
+ primaryBtnText: "Integration Test",
+ isPrimaryDisabled: true,
+ });
+
+ (useCopyJobContext as jest.Mock).mockReturnValue(mockContext);
+ (useCopyJobNavigation as jest.Mock).mockReturnValue(mockNavigation);
+
+ render( );
+
+ expect(screen.getByRole("region")).toBeInTheDocument();
+ expect(screen.getByText("Integration Test")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx
index 55368cbf6..e56ef0090 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx
@@ -22,6 +22,7 @@ const CreateCopyJobScreens: React.FC = () => {
{contextError && (
({
+ __esModule: true,
+ default: ({ children, explorer }: { children: React.ReactNode; explorer: Explorer }) => (
+
+ {children}
+
+ ),
+}));
+
+jest.mock("./CreateCopyJobScreens", () => ({
+ __esModule: true,
+ default: () => CreateCopyJobScreens
,
+}));
+
+const mockExplorer = {
+ databaseAccount: {
+ id: "test-account",
+ name: "test-account-name",
+ location: "East US",
+ type: "DocumentDB",
+ kind: "GlobalDocumentDB",
+ properties: {
+ documentEndpoint: "https://test-account.documents.azure.com:443/",
+ gremlinEndpoint: "https://test-account.gremlin.cosmosdb.azure.com:443/",
+ tableEndpoint: "https://test-account.table.cosmosdb.azure.com:443/",
+ cassandraEndpoint: "https://test-account.cassandra.cosmosdb.azure.com:443/",
+ },
+ },
+ subscriptionId: "test-subscription-id",
+ resourceGroup: "test-resource-group",
+} as unknown as Explorer;
+
+describe("CreateCopyJobScreensProvider", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("should render with explorer prop", () => {
+ const wrapper = shallow( );
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("should render with null explorer", () => {
+ const wrapper = shallow( );
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("should render with undefined explorer", () => {
+ const wrapper = shallow( );
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ it("should not crash with minimal explorer object", () => {
+ const minimalExplorer = {} as Explorer;
+
+ expect(() => {
+ const wrapper = shallow( );
+ expect(wrapper).toBeDefined();
+ }).not.toThrow();
+ });
+
+ it("should match snapshot for default render", () => {
+ const wrapper = shallow( );
+ expect(wrapper).toMatchSnapshot("default-render");
+ });
+
+ it("should match snapshot for edge cases", () => {
+ const emptyExplorer = {} as Explorer;
+ const wrapperEmpty = shallow( );
+ expect(wrapperEmpty).toMatchSnapshot("empty-explorer");
+
+ const partialExplorer = {
+ databaseAccount: { id: "partial-account" },
+ } as unknown as Explorer;
+ const wrapperPartial = shallow( );
+ expect(wrapperPartial).toMatchSnapshot("partial-explorer");
+ });
+
+ describe("Error Boundaries and Edge Cases", () => {
+ it("should handle React rendering errors gracefully", () => {
+ const edgeCases = [null, undefined, {}, { invalidProperty: "test" }];
+
+ edgeCases.forEach((explorerCase) => {
+ expect(() => {
+ shallow( );
+ }).not.toThrow();
+ });
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.test.tsx
new file mode 100644
index 000000000..adaea1915
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.test.tsx
@@ -0,0 +1,366 @@
+import "@testing-library/jest-dom";
+import { fireEvent, render, waitFor } from "@testing-library/react";
+import { Subscription } from "Contracts/DataModels";
+import React from "react";
+import { CopyJobContext } from "../../../Context/CopyJobContext";
+import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
+import { CopyJobContextProviderType, CopyJobContextState } from "../../../Types/CopyJobTypes";
+import PreviewCopyJob from "./PreviewCopyJob";
+
+jest.mock("./Utils/PreviewCopyJobUtils", () => ({
+ getPreviewCopyJobDetailsListColumns: () => [
+ {
+ key: "sourcedbname",
+ name: "Source Database",
+ fieldName: "sourceDatabaseName",
+ minWidth: 130,
+ maxWidth: 140,
+ },
+ {
+ key: "sourcecolname",
+ name: "Source Container",
+ fieldName: "sourceContainerName",
+ minWidth: 130,
+ maxWidth: 140,
+ },
+ {
+ key: "targetdbname",
+ name: "Destination Database",
+ fieldName: "targetDatabaseName",
+ minWidth: 130,
+ maxWidth: 140,
+ },
+ {
+ key: "targetcolname",
+ name: "Destination Container",
+ fieldName: "targetContainerName",
+ minWidth: 130,
+ maxWidth: 140,
+ },
+ ],
+}));
+
+jest.mock("../../../CopyJobUtils", () => ({
+ getDefaultJobName: jest.fn((selectedDatabaseAndContainers) => {
+ if (selectedDatabaseAndContainers.length === 1) {
+ const { sourceDatabaseName, sourceContainerName, targetDatabaseName, targetContainerName } =
+ selectedDatabaseAndContainers[0];
+ return `${sourceDatabaseName}.${sourceContainerName}_${targetDatabaseName}.${targetContainerName}_123456789`;
+ }
+ return "";
+ }),
+}));
+
+describe("PreviewCopyJob", () => {
+ const mockSetCopyJobState = jest.fn();
+ const mockSetContextError = jest.fn();
+ const mockSetFlow = jest.fn();
+ const mockResetCopyJobState = jest.fn();
+
+ const mockSubscription: Subscription = {
+ subscriptionId: "test-subscription-id",
+ displayName: "Test Subscription",
+ state: "Enabled",
+ subscriptionPolicies: {
+ locationPlacementId: "test",
+ quotaId: "test",
+ },
+ authorizationSource: "test",
+ };
+
+ const mockDatabaseAccount = {
+ id: "/subscriptions/test-subscription-id/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
+ name: "test-account",
+ location: "East US",
+ type: "Microsoft.DocumentDB/databaseAccounts",
+ kind: "GlobalDocumentDB",
+ properties: {
+ documentEndpoint: "https://test-account.documents.azure.com:443/",
+ gremlinEndpoint: "https://test-account.gremlin.cosmosdb.azure.com:443/",
+ tableEndpoint: "https://test-account.table.cosmosdb.azure.com:443/",
+ cassandraEndpoint: "https://test-account.cassandra.cosmosdb.azure.com:443/",
+ },
+ };
+
+ const createMockContext = (overrides: Partial = {}): CopyJobContextProviderType => {
+ const defaultState: CopyJobContextState = {
+ jobName: "",
+ migrationType: CopyJobMigrationType.Offline,
+ source: {
+ subscription: mockSubscription,
+ account: mockDatabaseAccount,
+ databaseId: "source-database",
+ containerId: "source-container",
+ },
+ target: {
+ subscriptionId: "test-subscription-id",
+ account: mockDatabaseAccount,
+ databaseId: "target-database",
+ containerId: "target-container",
+ },
+ sourceReadAccessFromTarget: false,
+ ...overrides,
+ };
+
+ return {
+ contextError: null,
+ setContextError: mockSetContextError,
+ copyJobState: defaultState,
+ setCopyJobState: mockSetCopyJobState,
+ flow: null,
+ setFlow: mockSetFlow,
+ resetCopyJobState: mockResetCopyJobState,
+ explorer: {} as any,
+ };
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("should render with default state and empty job name", () => {
+ const mockContext = createMockContext();
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("should render with pre-filled job name", () => {
+ const mockContext = createMockContext({
+ jobName: "custom-job-name-123",
+ });
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("should render with missing source subscription information", () => {
+ const mockContext = createMockContext({
+ source: {
+ subscription: undefined,
+ account: mockDatabaseAccount,
+ databaseId: "source-database",
+ containerId: "source-container",
+ },
+ });
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("should render with missing source account information", () => {
+ const mockContext = createMockContext({
+ source: {
+ subscription: mockSubscription,
+ account: null,
+ databaseId: "source-database",
+ containerId: "source-container",
+ },
+ });
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("should render with undefined database and container names", () => {
+ const mockContext = createMockContext({
+ source: {
+ subscription: mockSubscription,
+ account: mockDatabaseAccount,
+ databaseId: "",
+ containerId: "",
+ },
+ target: {
+ subscriptionId: "test-subscription-id",
+ account: mockDatabaseAccount,
+ databaseId: "",
+ containerId: "",
+ },
+ });
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("should render with long subscription and account names", () => {
+ const longNameSubscription: Subscription = {
+ ...mockSubscription,
+ displayName: "This is a very long subscription name that might cause display issues if not handled properly",
+ };
+
+ const longNameAccount = {
+ ...mockDatabaseAccount,
+ name: "this-is-a-very-long-database-account-name-that-might-cause-display-issues",
+ };
+
+ const mockContext = createMockContext({
+ source: {
+ subscription: longNameSubscription,
+ account: longNameAccount,
+ databaseId: "long-database-name-for-testing-purposes",
+ containerId: "long-container-name-for-testing-purposes",
+ },
+ });
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("should render with online migration type", () => {
+ const mockContext = createMockContext({
+ migrationType: CopyJobMigrationType.Online,
+ jobName: "online-migration-job",
+ });
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("should handle special characters in database and container names", () => {
+ const mockContext = createMockContext({
+ source: {
+ subscription: mockSubscription,
+ account: mockDatabaseAccount,
+ databaseId: "test-db_with@special#chars",
+ containerId: "test-container_with@special#chars",
+ },
+ target: {
+ subscriptionId: "test-subscription-id",
+ account: mockDatabaseAccount,
+ databaseId: "target-db_with@special#chars",
+ containerId: "target-container_with@special#chars",
+ },
+ jobName: "job-with@special#chars_123",
+ });
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("should render component with cross-subscription setup", () => {
+ const targetAccount = {
+ ...mockDatabaseAccount,
+ id: "/subscriptions/target-subscription-id/resourceGroups/target-rg/providers/Microsoft.DocumentDB/databaseAccounts/target-account",
+ name: "target-account",
+ };
+
+ const mockContext = createMockContext({
+ target: {
+ subscriptionId: "target-subscription-id",
+ account: targetAccount,
+ databaseId: "target-database",
+ containerId: "target-container",
+ },
+ sourceReadAccessFromTarget: true,
+ });
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("should call setCopyJobState with default job name on mount", async () => {
+ const mockContext = createMockContext();
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
+ });
+ });
+
+ it("should update job name when text field is changed", async () => {
+ const mockContext = createMockContext({
+ jobName: "initial-job-name",
+ });
+
+ const { getByDisplayValue } = render(
+
+
+ ,
+ );
+
+ const jobNameInput = getByDisplayValue("initial-job-name");
+ fireEvent.change(jobNameInput, { target: { value: "updated-job-name" } });
+
+ expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ it("should handle empty job name input", () => {
+ const mockContext = createMockContext({
+ jobName: "existing-name",
+ });
+
+ const { getByDisplayValue } = render(
+
+
+ ,
+ );
+
+ const jobNameInput = getByDisplayValue("existing-name");
+ fireEvent.change(jobNameInput, { target: { value: "" } });
+
+ expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ it("should display proper field labels from ContainerCopyMessages", () => {
+ const mockContext = createMockContext();
+
+ const { getByText } = render(
+
+
+ ,
+ );
+
+ expect(getByText(/Job name/i)).toBeInTheDocument();
+ expect(getByText(/Source subscription/i)).toBeInTheDocument();
+ expect(getByText(/Source account/i)).toBeInTheDocument();
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx
index 050d696e9..84abc0ece 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx
@@ -31,17 +31,21 @@ const PreviewCopyJob: React.FC = () => {
}));
};
return (
-
+
-
+
- {ContainerCopyMessages.sourceSubscriptionLabel}
- {copyJobState.source?.subscription?.displayName}
+ {ContainerCopyMessages.sourceSubscriptionLabel}
+
+ {copyJobState.source?.subscription?.displayName}
+
- {ContainerCopyMessages.sourceAccountLabel}
- {copyJobState.source?.account?.name}
+ {ContainerCopyMessages.sourceAccountLabel}
+
+ {copyJobState.source?.account?.name}
+
{
+ it("should return correctly formatted columns for preview copy job details list", () => {
+ const columns = getPreviewCopyJobDetailsListColumns();
+ expect(columns).toMatchSnapshot();
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/__snapshots__/PreviewCopyJobUtils.test.ts.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/__snapshots__/PreviewCopyJobUtils.test.ts.snap
new file mode 100644
index 000000000..49c73a93d
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/Utils/__snapshots__/PreviewCopyJobUtils.test.ts.snap
@@ -0,0 +1,62 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`PreviewCopyJobUtils should return correctly formatted columns for preview copy job details list 1`] = `
+[
+ {
+ "fieldName": "sourceDatabaseName",
+ "key": "sourcedbname",
+ "maxWidth": 140,
+ "minWidth": 130,
+ "name": "Source database",
+ "styles": {
+ "root": {
+ "lineHeight": "1.2",
+ "whiteSpace": "normal",
+ "wordBreak": "break-word",
+ },
+ },
+ },
+ {
+ "fieldName": "sourceContainerName",
+ "key": "sourcecolname",
+ "maxWidth": 140,
+ "minWidth": 130,
+ "name": "Source container",
+ "styles": {
+ "root": {
+ "lineHeight": "1.2",
+ "whiteSpace": "normal",
+ "wordBreak": "break-word",
+ },
+ },
+ },
+ {
+ "fieldName": "targetDatabaseName",
+ "key": "targetdbname",
+ "maxWidth": 140,
+ "minWidth": 130,
+ "name": "Destination database",
+ "styles": {
+ "root": {
+ "lineHeight": "1.2",
+ "whiteSpace": "normal",
+ "wordBreak": "break-word",
+ },
+ },
+ },
+ {
+ "fieldName": "targetContainerName",
+ "key": "targetcolname",
+ "maxWidth": 140,
+ "minWidth": 130,
+ "name": "Destination container",
+ "styles": {
+ "root": {
+ "lineHeight": "1.2",
+ "whiteSpace": "normal",
+ "wordBreak": "break-word",
+ },
+ },
+ },
+]
+`;
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/__snapshots__/PreviewCopyJob.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/__snapshots__/PreviewCopyJob.test.tsx.snap
new file mode 100644
index 000000000..90bc05a8d
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/__snapshots__/PreviewCopyJob.test.tsx.snap
@@ -0,0 +1,2879 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`PreviewCopyJob should handle special characters in database and container names 1`] = `
+
+
+
+
+ Job name
+ :
+
+
+
+
+
+
+ Source subscription
+
+
+ Test Subscription
+
+
+
+
+ Source account
+
+
+ test-account
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ test-db_with@special#chars
+
+
+ test-container_with@special#chars
+
+
+ target-db_with@special#chars
+
+
+ target-container_with@special#chars
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`PreviewCopyJob should render component with cross-subscription setup 1`] = `
+
+
+
+
+ Job name
+ :
+
+
+
+
+
+
+ Source subscription
+
+
+ Test Subscription
+
+
+
+
+ Source account
+
+
+ test-account
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ source-database
+
+
+ source-container
+
+
+ target-database
+
+
+ target-container
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`PreviewCopyJob should render with default state and empty job name 1`] = `
+
+
+
+
+ Job name
+ :
+
+
+
+
+
+
+ Source subscription
+
+
+ Test Subscription
+
+
+
+
+ Source account
+
+
+ test-account
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ source-database
+
+
+ source-container
+
+
+ target-database
+
+
+ target-container
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`PreviewCopyJob should render with long subscription and account names 1`] = `
+
+
+
+
+ Job name
+ :
+
+
+
+
+
+
+ Source subscription
+
+
+ This is a very long subscription name that might cause display issues if not handled properly
+
+
+
+
+ Source account
+
+
+ this-is-a-very-long-database-account-name-that-might-cause-display-issues
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ long-database-name-for-testing-purposes
+
+
+ long-container-name-for-testing-purposes
+
+
+ target-database
+
+
+ target-container
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`PreviewCopyJob should render with missing source account information 1`] = `
+
+
+
+
+ Job name
+ :
+
+
+
+
+
+
+ Source subscription
+
+
+ Test Subscription
+
+
+
+
+ Source account
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ source-database
+
+
+ source-container
+
+
+ target-database
+
+
+ target-container
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`PreviewCopyJob should render with missing source subscription information 1`] = `
+
+
+
+
+ Job name
+ :
+
+
+
+
+
+
+ Source subscription
+
+
+
+
+ Source account
+
+
+ test-account
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ source-database
+
+
+ source-container
+
+
+ target-database
+
+
+ target-container
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`PreviewCopyJob should render with online migration type 1`] = `
+
+
+
+
+ Job name
+ :
+
+
+
+
+
+
+ Source subscription
+
+
+ Test Subscription
+
+
+
+
+ Source account
+
+
+ test-account
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ source-database
+
+
+ source-container
+
+
+ target-database
+
+
+ target-container
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`PreviewCopyJob should render with pre-filled job name 1`] = `
+
+
+
+
+ Job name
+ :
+
+
+
+
+
+
+ Source subscription
+
+
+ Test Subscription
+
+
+
+
+ Source account
+
+
+ test-account
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ source-database
+
+
+ source-container
+
+
+ target-database
+
+
+ target-container
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`PreviewCopyJob should render with undefined database and container names 1`] = `
+
+
+
+
+ Job name
+ :
+
+
+
+
+
+
+ Source subscription
+
+
+ Test Subscription
+
+
+
+
+ Source account
+
+
+ test-account
+
+
+
+
+`;
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx
new file mode 100644
index 000000000..d56031fed
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx
@@ -0,0 +1,424 @@
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import React from "react";
+import { configContext, Platform } from "../../../../../../ConfigContext";
+import { DatabaseAccount } from "../../../../../../Contracts/DataModels";
+import * as useDatabaseAccountsHook from "../../../../../../hooks/useDatabaseAccounts";
+import { apiType, userContext } from "../../../../../../UserContext";
+import ContainerCopyMessages from "../../../../ContainerCopyMessages";
+import { CopyJobContext } from "../../../../Context/CopyJobContext";
+import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
+import { CopyJobContextProviderType, CopyJobContextState } from "../../../../Types/CopyJobTypes";
+import { AccountDropdown, normalizeAccountId } from "./AccountDropdown";
+
+jest.mock("../../../../../../hooks/useDatabaseAccounts");
+jest.mock("../../../../../../UserContext", () => ({
+ userContext: {
+ databaseAccount: null as DatabaseAccount | null,
+ },
+ apiType: jest.fn(),
+}));
+jest.mock("../../../../../../ConfigContext", () => ({
+ configContext: {
+ platform: "Portal",
+ },
+ Platform: {
+ Portal: "Portal",
+ Hosted: "Hosted",
+ },
+}));
+
+const mockUseDatabaseAccounts = useDatabaseAccountsHook.useDatabaseAccounts as jest.MockedFunction<
+ typeof useDatabaseAccountsHook.useDatabaseAccounts
+>;
+
+describe("AccountDropdown", () => {
+ const mockSetCopyJobState = jest.fn();
+ const mockCopyJobState = {
+ jobName: "",
+ migrationType: CopyJobMigrationType.Offline,
+ source: {
+ subscription: {
+ subscriptionId: "test-subscription-id",
+ displayName: "Test Subscription",
+ },
+ account: null,
+ databaseId: "",
+ containerId: "",
+ },
+ target: {
+ subscriptionId: "",
+ account: null,
+ databaseId: "",
+ containerId: "",
+ },
+ sourceReadAccessFromTarget: false,
+ } as CopyJobContextState;
+
+ const mockCopyJobContextValue = {
+ copyJobState: mockCopyJobState,
+ setCopyJobState: mockSetCopyJobState,
+ flow: null,
+ setFlow: jest.fn(),
+ contextError: null,
+ setContextError: jest.fn(),
+ resetCopyJobState: jest.fn(),
+ } as CopyJobContextProviderType;
+
+ const mockDatabaseAccount1: DatabaseAccount = {
+ id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDb/databaseAccounts/account1",
+ name: "test-account-1",
+ kind: "GlobalDocumentDB",
+ location: "East US",
+ type: "Microsoft.DocumentDB/databaseAccounts",
+ tags: {},
+ properties: {
+ documentEndpoint: "https://account1.documents.azure.com:443/",
+ capabilities: [],
+ enableMultipleWriteLocations: false,
+ },
+ };
+
+ const mockDatabaseAccount2: DatabaseAccount = {
+ id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDb/databaseAccounts/account2",
+ name: "test-account-2",
+ kind: "GlobalDocumentDB",
+ location: "West US",
+ type: "Microsoft.DocumentDB/databaseAccounts",
+ tags: {},
+ properties: {
+ documentEndpoint: "https://account2.documents.azure.com:443/",
+ capabilities: [],
+ enableMultipleWriteLocations: false,
+ },
+ };
+
+ const mockNonSqlAccount: DatabaseAccount = {
+ id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDb/databaseAccounts/mongo-account",
+ name: "mongo-account",
+ kind: "MongoDB",
+ location: "Central US",
+ type: "Microsoft.DocumentDB/databaseAccounts",
+ tags: {},
+ properties: {
+ documentEndpoint: "https://mongo-account.documents.azure.com:443/",
+ capabilities: [],
+ enableMultipleWriteLocations: false,
+ },
+ };
+
+ const renderWithContext = (contextValue = mockCopyJobContextValue) => {
+ return render(
+
+
+ ,
+ );
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (apiType as jest.MockedFunction).mockImplementation((account: DatabaseAccount) => {
+ return account.kind === "MongoDB" ? "MongoDB" : "SQL";
+ });
+ });
+
+ describe("Rendering", () => {
+ it("should render dropdown with correct label and placeholder", () => {
+ mockUseDatabaseAccounts.mockReturnValue([]);
+
+ renderWithContext();
+
+ expect(
+ screen.getByText(`${ContainerCopyMessages.sourceAccountDropdownLabel}:`, { exact: true }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("combobox")).toHaveAttribute(
+ "aria-label",
+ ContainerCopyMessages.sourceAccountDropdownLabel,
+ );
+ });
+
+ it("should render disabled dropdown when no subscription is selected", () => {
+ mockUseDatabaseAccounts.mockReturnValue([]);
+ const contextWithoutSubscription = {
+ ...mockCopyJobContextValue,
+ copyJobState: {
+ ...mockCopyJobState,
+ source: {
+ ...mockCopyJobState.source,
+ subscription: null,
+ },
+ } as CopyJobContextState,
+ };
+
+ renderWithContext(contextWithoutSubscription);
+
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("should render disabled dropdown when no accounts are available", () => {
+ mockUseDatabaseAccounts.mockReturnValue([]);
+
+ renderWithContext();
+
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("should render enabled dropdown when accounts are available", () => {
+ mockUseDatabaseAccounts.mockReturnValue([mockDatabaseAccount1, mockDatabaseAccount2]);
+
+ renderWithContext();
+
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toHaveAttribute("aria-disabled", "false");
+ });
+ });
+
+ describe("Account filtering", () => {
+ it("should filter accounts to only show SQL API accounts", () => {
+ const allAccounts = [mockDatabaseAccount1, mockDatabaseAccount2, mockNonSqlAccount];
+ mockUseDatabaseAccounts.mockReturnValue(allAccounts);
+
+ renderWithContext();
+
+ expect(mockUseDatabaseAccounts).toHaveBeenCalledWith("test-subscription-id");
+
+ expect(apiType as jest.MockedFunction).toHaveBeenCalledWith(mockDatabaseAccount1);
+ expect(apiType as jest.MockedFunction).toHaveBeenCalledWith(mockDatabaseAccount2);
+ expect(apiType as jest.MockedFunction).toHaveBeenCalledWith(mockNonSqlAccount);
+ });
+ });
+
+ describe("Account selection", () => {
+ it("should auto-select the first SQL account when no account is currently selected", async () => {
+ mockUseDatabaseAccounts.mockReturnValue([mockDatabaseAccount1, mockDatabaseAccount2]);
+
+ renderWithContext();
+
+ await waitFor(() => {
+ expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0];
+ const newState = stateUpdateFunction(mockCopyJobState);
+ expect(newState.source.account).toEqual({
+ ...mockDatabaseAccount1,
+ id: normalizeAccountId(mockDatabaseAccount1.id),
+ });
+ });
+
+ it("should auto-select predefined account from userContext if available", async () => {
+ const userContextAccount = {
+ ...mockDatabaseAccount2,
+ id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/account2",
+ };
+
+ (userContext as any).databaseAccount = userContextAccount;
+
+ mockUseDatabaseAccounts.mockReturnValue([mockDatabaseAccount1, mockDatabaseAccount2]);
+
+ renderWithContext();
+
+ await waitFor(() => {
+ expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0];
+ const newState = stateUpdateFunction(mockCopyJobState);
+ expect(newState.source.account).toEqual({
+ ...mockDatabaseAccount2,
+ id: normalizeAccountId(mockDatabaseAccount2.id),
+ });
+ });
+
+ it("should keep current account if it exists in the filtered list", async () => {
+ const contextWithSelectedAccount = {
+ ...mockCopyJobContextValue,
+ copyJobState: {
+ ...mockCopyJobState,
+ source: {
+ ...mockCopyJobState.source,
+ account: mockDatabaseAccount1,
+ },
+ },
+ };
+
+ mockUseDatabaseAccounts.mockReturnValue([mockDatabaseAccount1, mockDatabaseAccount2]);
+
+ renderWithContext(contextWithSelectedAccount);
+
+ await waitFor(() => {
+ expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0];
+ const newState = stateUpdateFunction(contextWithSelectedAccount.copyJobState);
+ expect(newState).toEqual({
+ ...contextWithSelectedAccount.copyJobState,
+ source: {
+ ...contextWithSelectedAccount.copyJobState.source,
+ account: {
+ ...mockDatabaseAccount1,
+ id: normalizeAccountId(mockDatabaseAccount1.id),
+ },
+ },
+ });
+ });
+
+ it("should handle account change when user selects different account", async () => {
+ mockUseDatabaseAccounts.mockReturnValue([mockDatabaseAccount1, mockDatabaseAccount2]);
+
+ renderWithContext();
+
+ const dropdown = screen.getByRole("combobox");
+ fireEvent.click(dropdown);
+
+ await waitFor(() => {
+ const option = screen.getByText("test-account-2");
+ fireEvent.click(option);
+ });
+
+ expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
+ });
+ });
+
+ describe("ID normalization", () => {
+ it("should normalize account ID for Portal platform", () => {
+ const portalAccount = {
+ ...mockDatabaseAccount1,
+ id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/account1",
+ };
+
+ (configContext as any).platform = Platform.Portal;
+ mockUseDatabaseAccounts.mockReturnValue([portalAccount]);
+
+ const contextWithSelectedAccount = {
+ ...mockCopyJobContextValue,
+ copyJobState: {
+ ...mockCopyJobState,
+ source: {
+ ...mockCopyJobState.source,
+ account: portalAccount,
+ },
+ },
+ };
+
+ renderWithContext(contextWithSelectedAccount);
+
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toMatchSnapshot();
+ });
+
+ it("should normalize account ID for Hosted platform", () => {
+ const hostedAccount = {
+ ...mockDatabaseAccount1,
+ id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/account1",
+ };
+
+ (configContext as any).platform = Platform.Hosted;
+ mockUseDatabaseAccounts.mockReturnValue([hostedAccount]);
+
+ const contextWithSelectedAccount = {
+ ...mockCopyJobContextValue,
+ copyJobState: {
+ ...mockCopyJobState,
+ source: {
+ ...mockCopyJobState.source,
+ account: hostedAccount,
+ },
+ },
+ };
+
+ renderWithContext(contextWithSelectedAccount);
+
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toBeInTheDocument();
+ });
+ });
+
+ describe("Edge cases", () => {
+ it("should handle empty account list gracefully", () => {
+ mockUseDatabaseAccounts.mockReturnValue([]);
+
+ renderWithContext();
+
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("should handle null account list gracefully", () => {
+ mockUseDatabaseAccounts.mockReturnValue(null as any);
+
+ renderWithContext();
+
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("should handle undefined subscription ID", () => {
+ const contextWithoutSubscription = {
+ ...mockCopyJobContextValue,
+ copyJobState: {
+ ...mockCopyJobState,
+ source: {
+ ...mockCopyJobState.source,
+ subscription: null,
+ },
+ } as CopyJobContextState,
+ };
+
+ mockUseDatabaseAccounts.mockReturnValue([]);
+
+ renderWithContext(contextWithoutSubscription);
+
+ expect(mockUseDatabaseAccounts).toHaveBeenCalledWith(undefined);
+ });
+
+ it("should not update state if account is already selected and the same", async () => {
+ const selectedAccount = mockDatabaseAccount1;
+ const contextWithSelectedAccount = {
+ ...mockCopyJobContextValue,
+ copyJobState: {
+ ...mockCopyJobState,
+ source: {
+ ...mockCopyJobState.source,
+ account: selectedAccount,
+ },
+ },
+ };
+
+ mockUseDatabaseAccounts.mockReturnValue([mockDatabaseAccount1, mockDatabaseAccount2]);
+
+ renderWithContext(contextWithSelectedAccount);
+
+ await waitFor(() => {
+ expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0];
+ const newState = stateUpdateFunction(contextWithSelectedAccount.copyJobState);
+ expect(newState).toBe(contextWithSelectedAccount.copyJobState);
+ });
+ });
+
+ describe("Accessibility", () => {
+ it("should have proper aria-label", () => {
+ mockUseDatabaseAccounts.mockReturnValue([mockDatabaseAccount1]);
+
+ renderWithContext();
+
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toHaveAttribute("aria-label", ContainerCopyMessages.sourceAccountDropdownLabel);
+ });
+
+ it("should have required attribute", () => {
+ mockUseDatabaseAccounts.mockReturnValue([mockDatabaseAccount1]);
+
+ renderWithContext();
+
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toHaveAttribute("aria-required", "true");
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx
index b24aed7b3..7c19790e3 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx
@@ -1,31 +1,96 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/display-name */
import { Dropdown } from "@fluentui/react";
-import React from "react";
+import { configContext, Platform } from "ConfigContext";
+import React, { useEffect } from "react";
+import { DatabaseAccount } from "../../../../../../Contracts/DataModels";
+import { useDatabaseAccounts } from "../../../../../../hooks/useDatabaseAccounts";
+import { apiType, userContext } from "../../../../../../UserContext";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
-import { DropdownOptionType } from "../../../../Types/CopyJobTypes";
+import { useCopyJobContext } from "../../../../Context/CopyJobContext";
import FieldRow from "../../Components/FieldRow";
-interface AccountDropdownProps {
- options: DropdownOptionType[];
- selectedKey?: string;
- disabled: boolean;
- onChange: (_ev?: React.FormEvent, option?: DropdownOptionType) => void;
-}
+interface AccountDropdownProps {}
-export const AccountDropdown: React.FC = React.memo(
- ({ options, selectedKey, disabled, onChange }) => (
+export const normalizeAccountId = (id: string = "") => {
+ if (configContext.platform === Platform.Portal) {
+ return id.replace("/Microsoft.DocumentDb/", "/Microsoft.DocumentDB/");
+ } else if (configContext.platform === Platform.Hosted) {
+ return id.replace("/Microsoft.DocumentDB/", "/Microsoft.DocumentDb/");
+ } else {
+ return id;
+ }
+};
+
+export const AccountDropdown: React.FC = () => {
+ const { copyJobState, setCopyJobState } = useCopyJobContext();
+
+ const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId;
+ const allAccounts: DatabaseAccount[] = useDatabaseAccounts(selectedSubscriptionId);
+ const sqlApiOnlyAccounts = (allAccounts || [])
+ .filter((account) => apiType(account) === "SQL")
+ .map((account) => ({
+ ...account,
+ id: normalizeAccountId(account.id),
+ }));
+
+ const updateCopyJobState = (newAccount: DatabaseAccount) => {
+ setCopyJobState((prevState) => {
+ if (prevState.source?.account?.id !== newAccount.id) {
+ return {
+ ...prevState,
+ source: {
+ ...prevState.source,
+ account: newAccount,
+ },
+ };
+ }
+ return prevState;
+ });
+ };
+
+ useEffect(() => {
+ if (sqlApiOnlyAccounts && sqlApiOnlyAccounts.length > 0 && selectedSubscriptionId) {
+ const currentAccountId = copyJobState?.source?.account?.id;
+ const predefinedAccountId = normalizeAccountId(userContext.databaseAccount?.id);
+ const selectedAccountId = currentAccountId || predefinedAccountId;
+
+ const targetAccount: DatabaseAccount | null =
+ sqlApiOnlyAccounts.find((account) => account.id === selectedAccountId) || null;
+ updateCopyJobState(targetAccount || sqlApiOnlyAccounts[0]);
+ }
+ }, [sqlApiOnlyAccounts?.length, selectedSubscriptionId]);
+
+ const accountOptions =
+ sqlApiOnlyAccounts?.map((account) => ({
+ key: account.id,
+ text: account.name,
+ data: account,
+ })) || [];
+
+ const handleAccountChange = (_ev?: React.FormEvent, option?: (typeof accountOptions)[0]) => {
+ const selectedAccount = option?.data as DatabaseAccount;
+
+ if (selectedAccount) {
+ updateCopyJobState(selectedAccount);
+ }
+ };
+
+ const isAccountDropdownDisabled = !selectedSubscriptionId || accountOptions.length === 0;
+ const selectedAccountId = normalizeAccountId(copyJobState?.source?.account?.id ?? "");
+
+ return (
- ),
- (prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey,
-);
+ );
+};
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.test.tsx
new file mode 100644
index 000000000..50fff3f72
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.test.tsx
@@ -0,0 +1,241 @@
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen } from "@testing-library/react";
+import React from "react";
+import ContainerCopyMessages from "../../../../ContainerCopyMessages";
+import { useCopyJobContext } from "../../../../Context/CopyJobContext";
+import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
+import { MigrationType } from "./MigrationType";
+
+jest.mock("../../../../Context/CopyJobContext", () => ({
+ useCopyJobContext: jest.fn(),
+}));
+
+describe("MigrationType", () => {
+ const mockSetCopyJobState = jest.fn();
+
+ const defaultContextValue = {
+ copyJobState: {
+ jobName: "",
+ migrationType: CopyJobMigrationType.Online,
+ source: {
+ subscription: null as any,
+ account: null as any,
+ databaseId: "",
+ containerId: "",
+ },
+ target: {
+ subscriptionId: "",
+ account: null as any,
+ databaseId: "",
+ containerId: "",
+ },
+ sourceReadAccessFromTarget: false,
+ },
+ setCopyJobState: mockSetCopyJobState,
+ flow: { currentScreen: "selectAccount" },
+ setFlow: jest.fn(),
+ contextError: "",
+ setContextError: jest.fn(),
+ explorer: {} as any,
+ resetCopyJobState: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useCopyJobContext as jest.Mock).mockReturnValue(defaultContextValue);
+ });
+
+ describe("Component Rendering", () => {
+ it("should render migration type component with radio buttons", () => {
+ const { container } = render( );
+
+ expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
+ expect(screen.getByRole("radiogroup")).toBeInTheDocument();
+
+ const offlineRadio = screen.getByRole("radio", {
+ name: ContainerCopyMessages.migrationTypeOptions.offline.title,
+ });
+ const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
+
+ expect(offlineRadio).toBeInTheDocument();
+ expect(onlineRadio).toBeInTheDocument();
+ expect(container).toMatchSnapshot();
+ });
+
+ it("should render with online mode selected by default", () => {
+ render( );
+
+ const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
+ const offlineRadio = screen.getByRole("radio", {
+ name: ContainerCopyMessages.migrationTypeOptions.offline.title,
+ });
+
+ expect(onlineRadio).toBeChecked();
+ expect(offlineRadio).not.toBeChecked();
+ });
+
+ it("should render with offline mode selected when state is offline", () => {
+ (useCopyJobContext as jest.Mock).mockReturnValue({
+ ...defaultContextValue,
+ copyJobState: {
+ ...defaultContextValue.copyJobState,
+ migrationType: CopyJobMigrationType.Offline,
+ },
+ });
+
+ render( );
+
+ const offlineRadio = screen.getByRole("radio", {
+ name: ContainerCopyMessages.migrationTypeOptions.offline.title,
+ });
+ const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
+
+ expect(offlineRadio).toBeChecked();
+ expect(onlineRadio).not.toBeChecked();
+ });
+ });
+
+ describe("Descriptions and Learn More Links", () => {
+ it("should render online description and learn more link when online is selected", () => {
+ const { container } = render( );
+
+ expect(container.querySelector("[data-test='migration-type-description-online']")).toBeInTheDocument();
+
+ const learnMoreLink = screen.getByRole("link", {
+ name: "online copy jobs",
+ });
+ expect(learnMoreLink).toBeInTheDocument();
+ expect(learnMoreLink).toHaveAttribute(
+ "href",
+ "https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started",
+ );
+ expect(learnMoreLink).toHaveAttribute("target", "_blank");
+ });
+
+ it("should render offline description and learn more link when offline is selected", () => {
+ (useCopyJobContext as jest.Mock).mockReturnValue({
+ ...defaultContextValue,
+ copyJobState: {
+ ...defaultContextValue.copyJobState,
+ migrationType: CopyJobMigrationType.Offline,
+ },
+ });
+
+ const { container } = render( );
+
+ expect(container.querySelector("[data-test='migration-type-description-offline']")).toBeInTheDocument();
+
+ const learnMoreLink = screen.getByRole("link", {
+ name: "offline copy jobs",
+ });
+ expect(learnMoreLink).toBeInTheDocument();
+ expect(learnMoreLink).toHaveAttribute(
+ "href",
+ "https://learn.microsoft.com/azure/cosmos-db/how-to-container-copy?tabs=offline-copy&pivots=api-nosql",
+ );
+ });
+ });
+
+ describe("User Interactions", () => {
+ it("should call setCopyJobState when offline radio button is clicked", () => {
+ render( );
+
+ const offlineRadio = screen.getByRole("radio", {
+ name: ContainerCopyMessages.migrationTypeOptions.offline.title,
+ });
+ fireEvent.click(offlineRadio);
+
+ expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
+
+ const updateFunction = mockSetCopyJobState.mock.calls[0][0];
+ const result = updateFunction(defaultContextValue.copyJobState);
+
+ expect(result).toEqual({
+ ...defaultContextValue.copyJobState,
+ migrationType: CopyJobMigrationType.Offline,
+ });
+ });
+
+ it("should call setCopyJobState when online radio button is clicked", () => {
+ (useCopyJobContext as jest.Mock).mockReturnValue({
+ ...defaultContextValue,
+ copyJobState: {
+ ...defaultContextValue.copyJobState,
+ migrationType: CopyJobMigrationType.Offline,
+ },
+ });
+
+ render( );
+
+ const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
+ fireEvent.click(onlineRadio);
+
+ expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
+
+ const updateFunction = mockSetCopyJobState.mock.calls[0][0];
+ const result = updateFunction({
+ ...defaultContextValue.copyJobState,
+ migrationType: CopyJobMigrationType.Offline,
+ });
+
+ expect(result).toEqual({
+ ...defaultContextValue.copyJobState,
+ migrationType: CopyJobMigrationType.Online,
+ });
+ });
+ });
+
+ describe("Accessibility", () => {
+ it("should have proper ARIA attributes", () => {
+ render( );
+
+ const choiceGroup = screen.getByRole("radiogroup");
+ expect(choiceGroup).toBeInTheDocument();
+ expect(choiceGroup).toHaveAttribute("aria-labelledby", "migrationTypeChoiceGroup");
+ });
+
+ it("should have proper radio button labels", () => {
+ render( );
+
+ expect(
+ screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.offline.title }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("should handle undefined migration type gracefully", () => {
+ (useCopyJobContext as jest.Mock).mockReturnValue({
+ ...defaultContextValue,
+ copyJobState: {
+ ...defaultContextValue.copyJobState,
+ migrationType: undefined,
+ },
+ });
+
+ const { container } = render( );
+
+ expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
+ expect(
+ screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.offline.title }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }),
+ ).toBeInTheDocument();
+ });
+
+ it("should handle null copyJobState gracefully", () => {
+ (useCopyJobContext as jest.Mock).mockReturnValue({
+ ...defaultContextValue,
+ copyJobState: null,
+ });
+
+ const { container } = render( );
+
+ expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.tsx
new file mode 100644
index 000000000..35c2d6a63
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.tsx
@@ -0,0 +1,77 @@
+/* eslint-disable react/prop-types */
+/* eslint-disable react/display-name */
+import { ChoiceGroup, IChoiceGroupOption, Stack, Text } from "@fluentui/react";
+import MarkdownRender from "@nteract/markdown";
+import { useCopyJobContext } from "Explorer/ContainerCopy/Context/CopyJobContext";
+import React from "react";
+import ContainerCopyMessages from "../../../../ContainerCopyMessages";
+import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
+
+interface MigrationTypeProps {}
+const options: 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)",
+ },
+ },
+ },
+};
+
+export const MigrationType: React.FC = React.memo(() => {
+ const { copyJobState, setCopyJobState } = useCopyJobContext();
+ const handleChange = (_ev?: React.FormEvent, option?: IChoiceGroupOption) => {
+ if (option) {
+ setCopyJobState((prevState) => ({
+ ...prevState,
+ migrationType: option.key as CopyJobMigrationType,
+ }));
+ }
+ };
+
+ const selectedKey = copyJobState?.migrationType ?? "";
+ const selectedKeyLowercase = selectedKey.toLowerCase() as keyof typeof ContainerCopyMessages.migrationTypeOptions;
+ const selectedKeyContent = ContainerCopyMessages.migrationTypeOptions[selectedKeyLowercase];
+
+ return (
+
+
+
+
+ {selectedKeyContent && (
+
+
+
+
+
+ )}
+
+ );
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx
deleted file mode 100644
index a72965fc6..000000000
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-/* eslint-disable react/prop-types */
-/* eslint-disable react/display-name */
-import { Checkbox, Stack } from "@fluentui/react";
-import React from "react";
-import ContainerCopyMessages from "../../../../ContainerCopyMessages";
-
-interface MigrationTypeCheckboxProps {
- checked: boolean;
- onChange: (_ev?: React.FormEvent, checked?: boolean) => void;
-}
-
-export const MigrationTypeCheckbox: React.FC = React.memo(({ checked, onChange }) => (
-
-
-
-));
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.test.tsx
new file mode 100644
index 000000000..c9356b8e5
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.test.tsx
@@ -0,0 +1,295 @@
+import "@testing-library/jest-dom";
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import React from "react";
+import { Subscription } from "../../../../../../Contracts/DataModels";
+import Explorer from "../../../../../Explorer";
+import CopyJobContextProvider from "../../../../Context/CopyJobContext";
+import { SubscriptionDropdown } from "./SubscriptionDropdown";
+
+jest.mock("../../../../../../hooks/useSubscriptions");
+jest.mock("../../../../../../UserContext");
+jest.mock("../../../../ContainerCopyMessages");
+
+const mockUseSubscriptions = jest.requireMock("../../../../../../hooks/useSubscriptions").useSubscriptions;
+const mockUserContext = jest.requireMock("../../../../../../UserContext").userContext;
+const mockContainerCopyMessages = jest.requireMock("../../../../ContainerCopyMessages").default;
+
+mockContainerCopyMessages.subscriptionDropdownLabel = "Subscription";
+mockContainerCopyMessages.subscriptionDropdownPlaceholder = "Select a subscription";
+
+describe("SubscriptionDropdown", () => {
+ let mockExplorer: Explorer;
+ const mockSubscriptions: Subscription[] = [
+ {
+ subscriptionId: "sub-1",
+ displayName: "Subscription One",
+ state: "Enabled",
+ tenantId: "tenant-1",
+ },
+ {
+ subscriptionId: "sub-2",
+ displayName: "Subscription Two",
+ state: "Enabled",
+ tenantId: "tenant-1",
+ },
+ {
+ subscriptionId: "sub-3",
+ displayName: "Another Subscription",
+ state: "Enabled",
+ tenantId: "tenant-1",
+ },
+ ];
+
+ const renderWithProvider = (children: React.ReactNode) => {
+ return render({children} );
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockExplorer = {} as Explorer;
+
+ mockUseSubscriptions.mockReturnValue(mockSubscriptions);
+ mockUserContext.subscriptionId = "sub-1";
+ });
+
+ describe("Rendering", () => {
+ it("should render subscription dropdown with correct attributes", () => {
+ renderWithProvider( );
+
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toBeInTheDocument();
+ expect(dropdown).toHaveAttribute("aria-label", "Subscription");
+ expect(dropdown).toHaveAttribute("data-test", "subscription-dropdown");
+ expect(dropdown).toBeRequired();
+ });
+
+ it("should render field label correctly", () => {
+ renderWithProvider( );
+
+ expect(screen.getByText("Subscription:")).toBeInTheDocument();
+ });
+
+ it("should show placeholder when no subscription is selected", async () => {
+ mockUserContext.subscriptionId = "";
+ mockUseSubscriptions.mockReturnValue([]);
+
+ renderWithProvider( );
+
+ await waitFor(() => {
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toHaveTextContent("Select a subscription");
+ });
+ });
+ });
+
+ describe("Subscription Options", () => {
+ it("should populate dropdown with available subscriptions", async () => {
+ renderWithProvider( );
+
+ const dropdown = screen.getByRole("combobox");
+ fireEvent.click(dropdown);
+
+ await waitFor(() => {
+ expect(screen.getByText("Subscription One", { selector: ".ms-Dropdown-optionText" })).toBeInTheDocument();
+ expect(screen.getByText("Subscription Two", { selector: ".ms-Dropdown-optionText" })).toBeInTheDocument();
+ expect(screen.getByText("Another Subscription", { selector: ".ms-Dropdown-optionText" })).toBeInTheDocument();
+ });
+ });
+
+ it("should handle empty subscriptions list", () => {
+ mockUseSubscriptions.mockReturnValue([]);
+
+ renderWithProvider( );
+
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toBeInTheDocument();
+ expect(dropdown).toHaveTextContent("Select a subscription");
+ });
+
+ it("should handle undefined subscriptions", () => {
+ mockUseSubscriptions.mockReturnValue(undefined);
+
+ renderWithProvider( );
+
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toBeInTheDocument();
+ expect(dropdown).toHaveTextContent("Select a subscription");
+ });
+ });
+
+ describe("Selection Logic", () => {
+ it("should auto-select subscription based on userContext.subscriptionId on mount", async () => {
+ mockUserContext.subscriptionId = "sub-2";
+
+ renderWithProvider( );
+
+ await waitFor(() => {
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toHaveTextContent("Subscription Two");
+ });
+ });
+
+ it("should maintain current selection when subscriptions list updates with same subscription", async () => {
+ renderWithProvider( );
+
+ await waitFor(() => {
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toHaveTextContent("Subscription One");
+ });
+
+ act(() => {
+ mockUseSubscriptions.mockReturnValue([...mockSubscriptions]);
+ });
+
+ await waitFor(() => {
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toHaveTextContent("Subscription One");
+ });
+ });
+
+ it("should prioritize current copyJobState subscription over userContext subscription", async () => {
+ mockUserContext.subscriptionId = "sub-2";
+
+ const { rerender } = renderWithProvider( );
+
+ await waitFor(() => {
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toHaveTextContent("Subscription Two");
+ });
+
+ const dropdown = screen.getByRole("combobox");
+ fireEvent.click(dropdown);
+
+ await waitFor(() => {
+ const option = screen.getByText("Another Subscription");
+ fireEvent.click(option);
+ });
+
+ rerender(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toHaveTextContent("Another Subscription");
+ });
+ });
+
+ it("should handle subscription selection change", async () => {
+ renderWithProvider( );
+
+ const dropdown = screen.getByRole("combobox");
+ fireEvent.click(dropdown);
+
+ await waitFor(() => {
+ const option = screen.getByText("Subscription Two");
+ fireEvent.click(option);
+ });
+
+ await waitFor(() => {
+ expect(dropdown).toHaveTextContent("Subscription Two");
+ });
+ });
+
+ it("should not auto-select if target subscription not found in list", async () => {
+ mockUserContext.subscriptionId = "non-existent-sub";
+
+ renderWithProvider( );
+
+ await waitFor(() => {
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toHaveTextContent("Select a subscription");
+ });
+ });
+ });
+
+ describe("Context State Management", () => {
+ it("should update copyJobState when subscription is selected", async () => {
+ renderWithProvider( );
+
+ const dropdown = screen.getByRole("combobox");
+ fireEvent.click(dropdown);
+
+ await waitFor(() => {
+ const option = screen.getByText("Subscription Two");
+ fireEvent.click(option);
+ });
+ await waitFor(() => {
+ expect(dropdown).toHaveTextContent("Subscription Two");
+ });
+ });
+
+ it("should reset account when subscription changes", async () => {
+ renderWithProvider( );
+
+ await waitFor(() => {
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toHaveTextContent("Subscription One");
+ });
+ const dropdown = screen.getByRole("combobox");
+ fireEvent.click(dropdown);
+
+ await waitFor(() => {
+ const option = screen.getByText("Subscription Two");
+ fireEvent.click(option);
+ });
+
+ await waitFor(() => {
+ expect(dropdown).toHaveTextContent("Subscription Two");
+ });
+ });
+
+ it("should not update state if same subscription is selected", async () => {
+ renderWithProvider( );
+
+ await waitFor(() => {
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toHaveTextContent("Subscription One");
+ });
+
+ const dropdown = screen.getByRole("combobox");
+ fireEvent.click(dropdown);
+
+ await waitFor(() => {
+ const option = screen.getByText("Subscription One", { selector: ".ms-Dropdown-optionText" });
+ fireEvent.click(option);
+ });
+
+ await waitFor(() => {
+ expect(dropdown).toHaveTextContent("Subscription One");
+ });
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("should handle subscription change event with option missing data", async () => {
+ renderWithProvider( );
+
+ const dropdown = screen.getByRole("combobox");
+ fireEvent.click(dropdown);
+ expect(dropdown).toBeInTheDocument();
+ });
+
+ it("should handle subscriptions loading state", () => {
+ mockUseSubscriptions.mockReturnValue(undefined);
+
+ renderWithProvider( );
+
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toBeInTheDocument();
+ expect(dropdown).toHaveTextContent("Select a subscription");
+ });
+
+ it("should work when both userContext.subscriptionId and copyJobState subscription are null", () => {
+ mockUserContext.subscriptionId = "";
+
+ renderWithProvider( );
+
+ const dropdown = screen.getByRole("combobox");
+ expect(dropdown).toBeInTheDocument();
+ expect(dropdown).toHaveTextContent("Select a subscription");
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx
index 2627918a6..9d38c2f57 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx
@@ -1,29 +1,79 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/display-name */
import { Dropdown } from "@fluentui/react";
-import React from "react";
+import React, { useEffect } from "react";
+import { Subscription } from "../../../../../../Contracts/DataModels";
+import { useSubscriptions } from "../../../../../../hooks/useSubscriptions";
+import { userContext } from "../../../../../../UserContext";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
-import { DropdownOptionType } from "../../../../Types/CopyJobTypes";
+import { useCopyJobContext } from "../../../../Context/CopyJobContext";
import FieldRow from "../../Components/FieldRow";
-interface SubscriptionDropdownProps {
- options: DropdownOptionType[];
- selectedKey?: string;
- onChange: (_ev?: React.FormEvent, option?: DropdownOptionType) => void;
-}
+interface SubscriptionDropdownProps {}
-export const SubscriptionDropdown: React.FC = React.memo(
- ({ options, selectedKey, onChange }) => (
+export const SubscriptionDropdown: React.FC = React.memo(() => {
+ const { copyJobState, setCopyJobState } = useCopyJobContext();
+ const subscriptions: Subscription[] = useSubscriptions();
+
+ const updateCopyJobState = (newSubscription: Subscription) => {
+ setCopyJobState((prevState) => {
+ if (prevState.source?.subscription?.subscriptionId !== newSubscription.subscriptionId) {
+ return {
+ ...prevState,
+ source: {
+ ...prevState.source,
+ subscription: newSubscription,
+ account: null,
+ },
+ };
+ }
+ return prevState;
+ });
+ };
+
+ useEffect(() => {
+ if (subscriptions && subscriptions.length > 0) {
+ const currentSubscriptionId = copyJobState?.source?.subscription?.subscriptionId;
+ const predefinedSubscriptionId = userContext.subscriptionId;
+ const selectedSubscriptionId = currentSubscriptionId || predefinedSubscriptionId;
+
+ const targetSubscription: Subscription | null =
+ subscriptions.find((sub) => sub.subscriptionId === selectedSubscriptionId) || null;
+
+ if (targetSubscription) {
+ updateCopyJobState(targetSubscription);
+ }
+ }
+ }, [subscriptions?.length]);
+
+ const subscriptionOptions =
+ subscriptions?.map((sub) => ({
+ key: sub.subscriptionId,
+ text: sub.displayName,
+ data: sub,
+ })) || [];
+
+ const handleSubscriptionChange = (_ev?: React.FormEvent, option?: (typeof subscriptionOptions)[0]) => {
+ const selectedSubscription = option?.data as Subscription;
+
+ if (selectedSubscription) {
+ updateCopyJobState(selectedSubscription);
+ }
+ };
+
+ const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId;
+
+ return (
- ),
- (prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey,
-);
+ );
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/AccountDropdown.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/AccountDropdown.test.tsx.snap
new file mode 100644
index 000000000..6c33d4711
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/AccountDropdown.test.tsx.snap
@@ -0,0 +1,37 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AccountDropdown ID normalization should normalize account ID for Portal platform 1`] = `
+
+
+ test-account-1
+
+
+
+
+
+
+
+`;
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationType.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationType.test.tsx.snap
new file mode 100644
index 000000000..1986f7540
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationType.test.tsx.snap
@@ -0,0 +1,109 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`MigrationType Component Rendering should render migration type component with radio buttons 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+ Offline mode
+
+
+
+
+
+
+
+
+
+ Online mode
+
+
+
+
+
+
+
+
+
+
+
+
+ Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the
+
+ All Versions and Delete
+
+ change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about
+
+ online copy jobs
+
+ .
+
+
+
+
+
+
+`;
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx
new file mode 100644
index 000000000..65529338e
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx
@@ -0,0 +1,101 @@
+import "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import React from "react";
+import { useCopyJobContext } from "../../../Context/CopyJobContext";
+import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
+import { CopyJobContextProviderType } from "../../../Types/CopyJobTypes";
+import SelectAccount from "./SelectAccount";
+
+jest.mock("../../../Context/CopyJobContext", () => ({
+ useCopyJobContext: jest.fn(),
+}));
+
+jest.mock("./Components/SubscriptionDropdown", () => ({
+ SubscriptionDropdown: jest.fn(() => Subscription Dropdown
),
+}));
+
+jest.mock("./Components/AccountDropdown", () => ({
+ AccountDropdown: jest.fn(() => Account Dropdown
),
+}));
+
+jest.mock("./Components/MigrationType", () => ({
+ MigrationType: jest.fn(() => Migration Type
),
+}));
+
+describe("SelectAccount", () => {
+ const mockSetCopyJobState = jest.fn();
+
+ const defaultContextValue: CopyJobContextProviderType = {
+ copyJobState: {
+ jobName: "",
+ migrationType: CopyJobMigrationType.Online,
+ source: {
+ subscription: null as any,
+ account: null as any,
+ databaseId: "",
+ containerId: "",
+ },
+ target: {
+ subscriptionId: "",
+ account: null as any,
+ databaseId: "",
+ containerId: "",
+ },
+ sourceReadAccessFromTarget: false,
+ },
+ setCopyJobState: mockSetCopyJobState,
+ flow: { currentScreen: "selectAccount" },
+ setFlow: jest.fn(),
+ contextError: null,
+ setContextError: jest.fn(),
+ explorer: {} as any,
+ resetCopyJobState: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useCopyJobContext as jest.Mock).mockReturnValue(defaultContextValue);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("Component Rendering", () => {
+ it("should render the component with all required elements", () => {
+ const { container } = render( );
+
+ expect(container.firstChild).toHaveAttribute("data-test", "Panel:SelectAccountContainer");
+ expect(container.firstChild).toHaveClass("selectAccountContainer");
+
+ expect(screen.getByText(/Please select a source account from which to copy/i)).toBeInTheDocument();
+
+ expect(screen.getByTestId("subscription-dropdown")).toBeInTheDocument();
+ expect(screen.getByTestId("account-dropdown")).toBeInTheDocument();
+ expect(screen.getByTestId("migration-type")).toBeInTheDocument();
+ });
+
+ it("should render correctly with snapshot", () => {
+ const { container } = render( );
+ expect(container.firstChild).toMatchSnapshot();
+ });
+ });
+
+ describe("Migration Type Functionality", () => {
+ it("should render migration type component", () => {
+ render( );
+
+ const migrationTypeComponent = screen.getByTestId("migration-type");
+ expect(migrationTypeComponent).toBeInTheDocument();
+ });
+ });
+
+ describe("Performance and Optimization", () => {
+ it("should render without performance issues", () => {
+ const { rerender } = render( );
+ rerender( );
+
+ expect(screen.getByTestId("migration-type")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx
index 17f323413..f4a0dcee3 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx
@@ -1,52 +1,24 @@
-/* eslint-disable react/display-name */
-import { Stack } from "@fluentui/react";
+import { Stack, Text } from "@fluentui/react";
import React from "react";
-import { apiType } from "UserContext";
-import { DatabaseAccount, Subscription } from "../../../../../Contracts/DataModels";
-import { useDatabaseAccounts } from "../../../../../hooks/useDatabaseAccounts";
-import { useSubscriptions } from "../../../../../hooks/useSubscriptions";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
-import { useCopyJobContext } from "../../../Context/CopyJobContext";
-import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
import { AccountDropdown } from "./Components/AccountDropdown";
-import { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox";
+import { MigrationType } from "./Components/MigrationType";
import { SubscriptionDropdown } from "./Components/SubscriptionDropdown";
-import { useDropdownOptions, useEventHandlers } from "./Utils/selectAccountUtils";
const SelectAccount = React.memo(() => {
- const { copyJobState, setCopyJobState } = useCopyJobContext();
- const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId;
- const selectedSourceAccountId = copyJobState?.source?.account?.id;
-
- const subscriptions: Subscription[] = useSubscriptions();
- const allAccounts: DatabaseAccount[] = useDatabaseAccounts(selectedSubscriptionId);
- const sqlApiOnlyAccounts: DatabaseAccount[] = allAccounts?.filter((account) => apiType(account) === "SQL");
-
- const { subscriptionOptions, accountOptions } = useDropdownOptions(subscriptions, sqlApiOnlyAccounts);
- const { handleSelectSourceAccount, handleMigrationTypeChange } = useEventHandlers(setCopyJobState);
-
- const migrationTypeChecked = copyJobState?.migrationType === CopyJobMigrationType.Offline;
-
return (
-
- {ContainerCopyMessages.selectAccountDescription}
+
+ {ContainerCopyMessages.selectAccountDescription}
- handleSelectSourceAccount("subscription", option?.data)}
- />
+
- handleSelectSourceAccount("account", option?.data)}
- />
+
-
+
);
});
+SelectAccount.displayName = "SelectAccount";
+
export default SelectAccount;
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx
deleted file mode 100644
index adb36b3a1..000000000
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import React from "react";
-import { DatabaseAccount, Subscription } from "../../../../../../Contracts/DataModels";
-import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
-import { CopyJobContextProviderType, CopyJobContextState, DropdownOptionType } from "../../../../Types/CopyJobTypes";
-import { useCopyJobPrerequisitesCache } from "../../../Utils/useCopyJobPrerequisitesCache";
-
-export function useDropdownOptions(
- subscriptions: Subscription[],
- accounts: DatabaseAccount[],
-): {
- subscriptionOptions: DropdownOptionType[];
- accountOptions: DropdownOptionType[];
-} {
- const subscriptionOptions = React.useMemo(
- () =>
- subscriptions?.map((sub) => ({
- key: sub.subscriptionId,
- text: sub.displayName,
- data: sub,
- })) || [],
- [subscriptions],
- );
-
- const accountOptions = React.useMemo(
- () =>
- accounts?.map((account) => ({
- key: account.id,
- text: account.name,
- data: account,
- })) || [],
- [accounts],
- );
-
- return { subscriptionOptions, accountOptions };
-}
-
-type setCopyJobStateType = CopyJobContextProviderType["setCopyJobState"];
-
-export function useEventHandlers(setCopyJobState: setCopyJobStateType) {
- const { setValidationCache } = useCopyJobPrerequisitesCache();
- const handleSelectSourceAccount = React.useCallback(
- (type: "subscription" | "account", data: (Subscription & DatabaseAccount) | undefined) => {
- setCopyJobState((prevState: CopyJobContextState) => {
- if (type === "subscription") {
- return {
- ...prevState,
- source: {
- ...prevState.source,
- subscription: data || null,
- account: null,
- },
- };
- }
- if (type === "account") {
- return {
- ...prevState,
- source: {
- ...prevState.source,
- account: data || null,
- },
- };
- }
- return prevState;
- });
- setValidationCache(new Map());
- },
- [setCopyJobState, setValidationCache],
- );
-
- const handleMigrationTypeChange = React.useCallback(
- (_ev?: React.FormEvent, checked?: boolean) => {
- setCopyJobState((prevState: CopyJobContextState) => ({
- ...prevState,
- migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
- }));
- setValidationCache(new Map());
- },
- [setCopyJobState, setValidationCache],
- );
-
- return { handleSelectSourceAccount, handleMigrationTypeChange };
-}
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap
new file mode 100644
index 000000000..0b540eba6
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap
@@ -0,0 +1,29 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SelectAccount Component Rendering should render correctly with snapshot 1`] = `
+
+
+ Please select a source account from which to copy.
+
+
+ Subscription Dropdown
+
+
+ Account Dropdown
+
+
+ Migration Type
+
+
+`;
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/Events/DropDownChangeHandler.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/Events/DropDownChangeHandler.test.tsx
new file mode 100644
index 000000000..4cf137ea4
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/Events/DropDownChangeHandler.test.tsx
@@ -0,0 +1,330 @@
+import { fireEvent, render } from "@testing-library/react";
+import React from "react";
+import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
+import { CopyJobContextState, DropdownOptionType } from "../../../../Types/CopyJobTypes";
+import { dropDownChangeHandler } from "./DropDownChangeHandler";
+
+const createMockInitialState = (): CopyJobContextState => ({
+ jobName: "test-job",
+ migrationType: CopyJobMigrationType.Offline,
+ sourceReadAccessFromTarget: false,
+ source: {
+ subscription: {
+ subscriptionId: "source-sub-id",
+ displayName: "Source Subscription",
+ state: "Enabled",
+ subscriptionPolicies: {
+ locationPlacementId: "test",
+ quotaId: "test",
+ spendingLimit: "Off",
+ },
+ authorizationSource: "test",
+ },
+ account: {
+ id: "source-account-id",
+ name: "source-account",
+ location: "East US",
+ type: "Microsoft.DocumentDB/databaseAccounts",
+ kind: "DocumentDB",
+ properties: {
+ documentEndpoint: "https://source.documents.azure.com:443/",
+ cassandraEndpoint: undefined,
+ gremlinEndpoint: undefined,
+ tableEndpoint: undefined,
+ writeLocations: [],
+ readLocations: [],
+ enableMultipleWriteLocations: false,
+ isVirtualNetworkFilterEnabled: false,
+ enableFreeTier: false,
+ enableAnalyticalStorage: false,
+ backupPolicy: undefined,
+ disableLocalAuth: false,
+ capacity: undefined,
+ enablePriorityBasedExecution: false,
+ publicNetworkAccess: "Enabled",
+ enableMaterializedViews: false,
+ },
+ systemData: undefined,
+ },
+ databaseId: "source-db",
+ containerId: "source-container",
+ },
+ target: {
+ subscriptionId: "target-sub-id",
+ account: {
+ id: "target-account-id",
+ name: "target-account",
+ location: "West US",
+ type: "Microsoft.DocumentDB/databaseAccounts",
+ kind: "DocumentDB",
+ properties: {
+ documentEndpoint: "https://target.documents.azure.com:443/",
+ cassandraEndpoint: undefined,
+ gremlinEndpoint: undefined,
+ tableEndpoint: undefined,
+ writeLocations: [],
+ readLocations: [],
+ enableMultipleWriteLocations: false,
+ isVirtualNetworkFilterEnabled: false,
+ enableFreeTier: false,
+ enableAnalyticalStorage: false,
+ backupPolicy: undefined,
+ disableLocalAuth: false,
+ capacity: undefined,
+ enablePriorityBasedExecution: false,
+ publicNetworkAccess: "Enabled",
+ enableMaterializedViews: false,
+ },
+ systemData: undefined,
+ },
+ databaseId: "target-db",
+ containerId: "target-container",
+ },
+});
+
+interface TestComponentProps {
+ initialState: CopyJobContextState;
+ onStateChange: (state: CopyJobContextState) => void;
+}
+
+const TestComponent: React.FC = ({ initialState, onStateChange }) => {
+ const [state, setState] = React.useState(initialState);
+ const handler = dropDownChangeHandler(setState);
+
+ React.useEffect(() => {
+ onStateChange(state);
+ }, [state, onStateChange]);
+
+ return (
+
+
+ handler("sourceDatabase")({} as React.FormEvent, { key: "new-source-db", text: "New Source DB", data: {} })
+ }
+ >
+ Source Database
+
+
+ handler("sourceContainer")({} as React.FormEvent, {
+ key: "new-source-container",
+ text: "New Source Container",
+ data: {},
+ })
+ }
+ >
+ Source Container
+
+
+ handler("targetDatabase")({} as React.FormEvent, { key: "new-target-db", text: "New Target DB", data: {} })
+ }
+ >
+ Target Database
+
+
+ handler("targetContainer")({} as React.FormEvent, {
+ key: "new-target-container",
+ text: "New Target Container",
+ data: {},
+ })
+ }
+ >
+ Target Container
+
+
+ );
+};
+
+describe("dropDownChangeHandler", () => {
+ let capturedState: CopyJobContextState;
+ let initialState: CopyJobContextState;
+
+ beforeEach(() => {
+ initialState = createMockInitialState();
+ capturedState = initialState;
+ });
+
+ const renderTestComponent = () => {
+ return render(
+ {
+ capturedState = state;
+ }}
+ />,
+ );
+ };
+
+ describe("sourceDatabase dropdown change", () => {
+ it("should update source database and reset source container", () => {
+ const { getByTestId } = renderTestComponent();
+
+ fireEvent.click(getByTestId("source-database-btn"));
+
+ expect(capturedState.source.databaseId).toBe("new-source-db");
+ expect(capturedState.source.containerId).toBeUndefined();
+ expect(capturedState.source.subscription).toEqual(initialState.source.subscription);
+ expect(capturedState.source.account).toEqual(initialState.source.account);
+ expect(capturedState.target).toEqual(initialState.target);
+ });
+
+ it("should maintain other state properties when updating source database", () => {
+ const { getByTestId } = renderTestComponent();
+
+ fireEvent.click(getByTestId("source-database-btn"));
+
+ expect(capturedState.jobName).toBe(initialState.jobName);
+ expect(capturedState.migrationType).toBe(initialState.migrationType);
+ expect(capturedState.sourceReadAccessFromTarget).toBe(initialState.sourceReadAccessFromTarget);
+ });
+ });
+
+ describe("sourceContainer dropdown change", () => {
+ it("should update source container only", () => {
+ const { getByTestId } = renderTestComponent();
+
+ fireEvent.click(getByTestId("source-container-btn"));
+
+ expect(capturedState.source.containerId).toBe("new-source-container");
+ expect(capturedState.source.databaseId).toBe(initialState.source.databaseId);
+ expect(capturedState.source.subscription).toEqual(initialState.source.subscription);
+ expect(capturedState.source.account).toEqual(initialState.source.account);
+ expect(capturedState.target).toEqual(initialState.target);
+ });
+
+ it("should not affect database selection when updating container", () => {
+ const { getByTestId } = renderTestComponent();
+
+ fireEvent.click(getByTestId("source-container-btn"));
+
+ expect(capturedState.source.databaseId).toBe("source-db");
+ });
+ });
+
+ describe("targetDatabase dropdown change", () => {
+ it("should update target database and reset target container", () => {
+ const { getByTestId } = renderTestComponent();
+
+ fireEvent.click(getByTestId("target-database-btn"));
+
+ expect(capturedState.target.databaseId).toBe("new-target-db");
+ expect(capturedState.target.containerId).toBeUndefined();
+ expect(capturedState.target.subscriptionId).toBe(initialState.target.subscriptionId);
+ expect(capturedState.target.account).toEqual(initialState.target.account);
+ expect(capturedState.source).toEqual(initialState.source);
+ });
+
+ it("should maintain other state properties when updating target database", () => {
+ const { getByTestId } = renderTestComponent();
+
+ fireEvent.click(getByTestId("target-database-btn"));
+
+ expect(capturedState.jobName).toBe(initialState.jobName);
+ expect(capturedState.migrationType).toBe(initialState.migrationType);
+ expect(capturedState.sourceReadAccessFromTarget).toBe(initialState.sourceReadAccessFromTarget);
+ });
+ });
+
+ describe("targetContainer dropdown change", () => {
+ it("should update target container only", () => {
+ const { getByTestId } = renderTestComponent();
+
+ fireEvent.click(getByTestId("target-container-btn"));
+
+ expect(capturedState.target.containerId).toBe("new-target-container");
+ expect(capturedState.target.databaseId).toBe(initialState.target.databaseId);
+ expect(capturedState.target.subscriptionId).toBe(initialState.target.subscriptionId);
+ expect(capturedState.target.account).toEqual(initialState.target.account);
+ expect(capturedState.source).toEqual(initialState.source);
+ });
+
+ it("should not affect database selection when updating container", () => {
+ const { getByTestId } = renderTestComponent();
+
+ fireEvent.click(getByTestId("target-container-btn"));
+
+ expect(capturedState.target.databaseId).toBe("target-db");
+ });
+ });
+
+ describe("edge cases and error scenarios", () => {
+ it("should handle empty string keys", () => {
+ renderTestComponent();
+
+ const handler = dropDownChangeHandler((updater) => {
+ const newState = typeof updater === "function" ? updater(capturedState) : updater;
+ capturedState = newState;
+ return capturedState;
+ });
+
+ const mockEvent = {} as React.FormEvent;
+ const mockOption: DropdownOptionType = { key: "", text: "Empty Option", data: {} };
+
+ handler("sourceDatabase")(mockEvent, mockOption);
+
+ expect(capturedState.source.databaseId).toBe("");
+ expect(capturedState.source.containerId).toBeUndefined();
+ });
+
+ it("should handle special characters in keys", () => {
+ renderTestComponent();
+
+ const handler = dropDownChangeHandler((updater) => {
+ const newState = typeof updater === "function" ? updater(capturedState) : updater;
+ capturedState = newState;
+ return capturedState;
+ });
+
+ const mockEvent = {} as React.FormEvent;
+ const mockOption: DropdownOptionType = {
+ key: "test-db-with-special-chars-@#$%",
+ text: "Special DB",
+ data: {},
+ };
+
+ handler("sourceDatabase")(mockEvent, mockOption);
+
+ expect(capturedState.source.databaseId).toBe("test-db-with-special-chars-@#$%");
+ expect(capturedState.source.containerId).toBeUndefined();
+ });
+
+ it("should handle numeric keys", () => {
+ renderTestComponent();
+
+ const handler = dropDownChangeHandler((updater) => {
+ const newState = typeof updater === "function" ? updater(capturedState) : updater;
+ capturedState = newState;
+ return capturedState;
+ });
+
+ const mockEvent = {} as React.FormEvent;
+ const mockOption: DropdownOptionType = { key: "12345", text: "Numeric Option", data: {} };
+
+ handler("targetContainer")(mockEvent, mockOption);
+
+ expect(capturedState.target.containerId).toBe("12345");
+ });
+
+ it.skip("should handle invalid dropdown type gracefully", () => {
+ const handler = dropDownChangeHandler((updater) => {
+ const newState = typeof updater === "function" ? updater(capturedState) : updater;
+ capturedState = newState;
+ return capturedState;
+ });
+
+ const mockEvent = {} as React.FormEvent;
+ const mockOption: DropdownOptionType = { key: "test-value", text: "Test Option", data: {} };
+
+ const invalidHandler = handler as any;
+ invalidHandler("invalidType")(mockEvent, mockOption);
+
+ expect(capturedState).toEqual(initialState);
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.test.tsx
new file mode 100644
index 000000000..5b7a6b13f
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.test.tsx
@@ -0,0 +1,484 @@
+import "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import { DatabaseModel } from "Contracts/DataModels";
+import React from "react";
+import Explorer from "../../../../../Explorer/Explorer";
+import CopyJobContextProvider from "../../../Context/CopyJobContext";
+import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
+import SelectSourceAndTargetContainers from "./SelectSourceAndTargetContainers";
+
+jest.mock("../../../../../hooks/useDatabases", () => ({
+ useDatabases: jest.fn(),
+}));
+
+jest.mock("../../../../../hooks/useDataContainers", () => ({
+ useDataContainers: jest.fn(),
+}));
+
+jest.mock("../../../ContainerCopyMessages", () => ({
+ __esModule: true,
+ default: {
+ selectSourceAndTargetContainersDescription: "Select source and target containers for migration",
+ sourceContainerSubHeading: "Source Container",
+ targetContainerSubHeading: "Target Container",
+ },
+}));
+
+jest.mock("./Events/DropDownChangeHandler", () => ({
+ dropDownChangeHandler: jest.fn(() => () => jest.fn()),
+}));
+
+jest.mock("./memoizedData", () => ({
+ useSourceAndTargetData: jest.fn(),
+}));
+
+jest.mock("UserContext", () => ({
+ userContext: {
+ subscriptionId: "test-subscription-id",
+ databaseAccount: {
+ id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
+ name: "test-account",
+ location: "East US",
+ kind: "GlobalDocumentDB",
+ },
+ },
+}));
+
+import { useDatabases } from "../../../../../hooks/useDatabases";
+import { useDataContainers } from "../../../../../hooks/useDataContainers";
+import { dropDownChangeHandler } from "./Events/DropDownChangeHandler";
+import { useSourceAndTargetData } from "./memoizedData";
+
+const mockUseDatabases = useDatabases as jest.MockedFunction;
+const mockUseDataContainers = useDataContainers as jest.MockedFunction;
+const mockDropDownChangeHandler = dropDownChangeHandler as jest.MockedFunction;
+const mockUseSourceAndTargetData = useSourceAndTargetData as jest.MockedFunction;
+
+describe("SelectSourceAndTargetContainers", () => {
+ let mockExplorer: Explorer;
+ let mockShowAddCollectionPanel: jest.Mock;
+ let mockOnDropdownChange: jest.Mock;
+
+ const mockDatabases: DatabaseModel[] = [
+ { id: "db1", name: "Database1" } as DatabaseModel,
+ { id: "db2", name: "Database2" } as DatabaseModel,
+ ];
+
+ const mockContainers: DatabaseModel[] = [
+ { id: "container1", name: "Container1" } as DatabaseModel,
+ { id: "container2", name: "Container2" } as DatabaseModel,
+ ];
+
+ const mockCopyJobState = {
+ jobName: "",
+ migrationType: CopyJobMigrationType.Offline,
+ source: {
+ subscription: { subscriptionId: "test-subscription-id" },
+ account: {
+ id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
+ name: "test-account",
+ },
+ databaseId: "db1",
+ containerId: "container1",
+ },
+ target: {
+ subscriptionId: "test-subscription-id",
+ account: {
+ id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
+ name: "test-account",
+ },
+ databaseId: "db2",
+ containerId: "container2",
+ },
+ sourceReadAccessFromTarget: false,
+ };
+
+ const mockMemoizedData = {
+ source: mockCopyJobState.source,
+ target: mockCopyJobState.target,
+ sourceDbParams: ["test-sub", "test-rg", "test-account", "SQL"] as const,
+ sourceContainerParams: ["test-sub", "test-rg", "test-account", "db1", "SQL"] as const,
+ targetDbParams: ["test-sub", "test-rg", "test-account", "SQL"] as const,
+ targetContainerParams: ["test-sub", "test-rg", "test-account", "db2", "SQL"] as const,
+ };
+
+ beforeEach(() => {
+ mockExplorer = {} as Explorer;
+ mockShowAddCollectionPanel = jest.fn();
+ mockOnDropdownChange = jest.fn();
+
+ mockUseDatabases.mockReturnValue(mockDatabases);
+ mockUseDataContainers.mockReturnValue(mockContainers);
+ mockUseSourceAndTargetData.mockReturnValue(mockMemoizedData as ReturnType);
+ mockDropDownChangeHandler.mockReturnValue(() => mockOnDropdownChange);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const renderWithContext = (component: React.ReactElement) => {
+ return render({component} );
+ };
+
+ describe("Component Rendering", () => {
+ it("should render without crashing", () => {
+ renderWithContext( );
+ expect(screen.getByText("Select source and target containers for migration")).toBeInTheDocument();
+ });
+
+ it("should render description text", () => {
+ renderWithContext( );
+ expect(screen.getByText("Select source and target containers for migration")).toBeInTheDocument();
+ });
+
+ it("should render source container section", () => {
+ renderWithContext( );
+ expect(screen.getByText("Source Container")).toBeInTheDocument();
+ });
+
+ it("should render target container section", () => {
+ renderWithContext( );
+ expect(screen.getByText("Target Container")).toBeInTheDocument();
+ });
+
+ it("should return null when source is not available", () => {
+ mockUseSourceAndTargetData.mockReturnValue({
+ ...mockMemoizedData,
+ source: null,
+ } as ReturnType);
+
+ const { container } = renderWithContext( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("should call useDatabases hooks with correct parameters", () => {
+ renderWithContext( );
+
+ expect(mockUseDatabases).toHaveBeenCalledWith(...mockMemoizedData.sourceDbParams);
+ expect(mockUseDatabases).toHaveBeenCalledWith(...mockMemoizedData.targetDbParams);
+ });
+
+ it("should call useDataContainers hooks with correct parameters", () => {
+ renderWithContext( );
+
+ expect(mockUseDataContainers).toHaveBeenCalledWith(...mockMemoizedData.sourceContainerParams);
+ expect(mockUseDataContainers).toHaveBeenCalledWith(...mockMemoizedData.targetContainerParams);
+ });
+ });
+
+ describe("Database Options", () => {
+ it("should create source database options from useDatabases data", () => {
+ renderWithContext( );
+ expect(mockUseDatabases).toHaveBeenCalled();
+ });
+
+ it("should create target database options from useDatabases data", () => {
+ renderWithContext( );
+
+ expect(mockUseDatabases).toHaveBeenCalled();
+ });
+
+ it("should handle empty database list", () => {
+ mockUseDatabases.mockReturnValue([]);
+
+ renderWithContext( );
+ expect(mockUseDatabases).toHaveBeenCalled();
+ });
+
+ it("should handle undefined database list", () => {
+ mockUseDatabases.mockReturnValue(undefined);
+
+ renderWithContext( );
+ expect(mockUseDatabases).toHaveBeenCalled();
+ });
+ });
+
+ describe("Container Options", () => {
+ it("should create source container options from useDataContainers data", () => {
+ renderWithContext( );
+
+ expect(mockUseDataContainers).toHaveBeenCalled();
+ });
+
+ it("should create target container options from useDataContainers data", () => {
+ renderWithContext( );
+
+ expect(mockUseDataContainers).toHaveBeenCalled();
+ });
+
+ it("should handle empty container list", () => {
+ mockUseDataContainers.mockReturnValue([]);
+
+ renderWithContext( );
+ expect(mockUseDataContainers).toHaveBeenCalled();
+ });
+
+ it("should handle undefined container list", () => {
+ mockUseDataContainers.mockReturnValue(undefined);
+
+ renderWithContext( );
+ expect(mockUseDataContainers).toHaveBeenCalled();
+ });
+ });
+
+ describe("Event Handlers", () => {
+ it("should call dropDownChangeHandler with setCopyJobState", () => {
+ renderWithContext( );
+
+ expect(mockDropDownChangeHandler).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ it("should create dropdown change handlers for different types", () => {
+ renderWithContext( );
+ expect(mockDropDownChangeHandler).toHaveBeenCalled();
+ });
+ });
+
+ describe("Component Props", () => {
+ it("should pass showAddCollectionPanel to DatabaseContainerSection", () => {
+ renderWithContext( );
+ expect(screen.getByText("Target Container")).toBeInTheDocument();
+ });
+
+ it("should render without showAddCollectionPanel prop", () => {
+ renderWithContext( );
+
+ expect(screen.getByText("Source Container")).toBeInTheDocument();
+ expect(screen.getByText("Target Container")).toBeInTheDocument();
+ });
+ });
+
+ describe("Memoization", () => {
+ it("should memoize source database options", () => {
+ const { rerender } = renderWithContext( );
+
+ expect(mockUseDatabases).toHaveBeenCalled();
+ rerender(
+
+
+ ,
+ );
+
+ expect(mockUseDatabases).toHaveBeenCalled();
+ });
+
+ it("should memoize target database options", () => {
+ const { rerender } = renderWithContext( );
+
+ expect(mockUseDatabases).toHaveBeenCalled();
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(mockUseDatabases).toHaveBeenCalled();
+ });
+
+ it("should memoize source container options", () => {
+ const { rerender } = renderWithContext( );
+
+ expect(mockUseDataContainers).toHaveBeenCalled();
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(mockUseDataContainers).toHaveBeenCalled();
+ });
+
+ it("should memoize target container options", () => {
+ const { rerender } = renderWithContext( );
+
+ expect(mockUseDataContainers).toHaveBeenCalled();
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(mockUseDataContainers).toHaveBeenCalled();
+ });
+ });
+
+ describe("Database Container Section Props", () => {
+ it("should pass correct props to source DatabaseContainerSection", () => {
+ renderWithContext( );
+
+ expect(screen.getByText("Source Container")).toBeInTheDocument();
+ });
+
+ it("should pass correct props to target DatabaseContainerSection", () => {
+ renderWithContext( );
+
+ expect(screen.getByText("Target Container")).toBeInTheDocument();
+ });
+
+ it("should disable source container dropdown when no database is selected", () => {
+ mockUseSourceAndTargetData.mockReturnValue({
+ ...mockMemoizedData,
+ source: {
+ ...mockMemoizedData.source,
+ databaseId: "",
+ },
+ } as ReturnType);
+
+ renderWithContext( );
+ expect(screen.getByText("Source Container")).toBeInTheDocument();
+ });
+
+ it("should disable target container dropdown when no database is selected", () => {
+ mockUseSourceAndTargetData.mockReturnValue({
+ ...mockMemoizedData,
+ target: {
+ ...mockMemoizedData.target,
+ databaseId: "",
+ },
+ } as ReturnType);
+
+ renderWithContext( );
+ expect(screen.getByText("Target Container")).toBeInTheDocument();
+ });
+ });
+
+ describe("Error Handling", () => {
+ it("should handle hooks returning null gracefully", () => {
+ mockUseDatabases.mockReturnValue(null);
+ mockUseDataContainers.mockReturnValue(null);
+
+ renderWithContext( );
+
+ expect(screen.getByText("Select source and target containers for migration")).toBeInTheDocument();
+ });
+
+ it("should handle hooks throwing errors gracefully", () => {
+ const originalError = console.error;
+ console.error = jest.fn();
+
+ mockUseDatabases.mockImplementation(() => {
+ throw new Error("Database fetch error");
+ });
+
+ expect(() => {
+ renderWithContext( );
+ }).toThrow();
+
+ console.error = originalError;
+ });
+
+ it("should handle missing source data gracefully", () => {
+ mockUseSourceAndTargetData.mockReturnValue({
+ ...mockMemoizedData,
+ source: undefined,
+ } as ReturnType);
+
+ const { container } = renderWithContext( );
+ expect(container.firstChild).toBeNull();
+ });
+ });
+
+ describe("Integration with CopyJobContext", () => {
+ it("should use CopyJobContext for state management", () => {
+ renderWithContext( );
+
+ expect(mockUseSourceAndTargetData).toHaveBeenCalled();
+ });
+
+ it("should respond to context state changes", () => {
+ const { rerender } = renderWithContext( );
+
+ mockUseSourceAndTargetData.mockReturnValue({
+ ...mockMemoizedData,
+ source: {
+ ...mockMemoizedData.source,
+ databaseId: "different-db",
+ },
+ } as ReturnType);
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(mockUseSourceAndTargetData).toHaveBeenCalled();
+ });
+ });
+
+ describe("Stack Layout", () => {
+ it("should render with correct Stack className", () => {
+ const { container } = renderWithContext( );
+
+ const stackElement = container.querySelector(".selectSourceAndTargetContainers");
+ expect(stackElement).toBeInTheDocument();
+ });
+
+ it("should apply correct spacing tokens", () => {
+ renderWithContext( );
+
+ expect(screen.getByText("Select source and target containers for migration")).toBeInTheDocument();
+ });
+ });
+
+ describe("Component Structure", () => {
+ it("should render description, source section, and target section in correct order", () => {
+ renderWithContext( );
+
+ const description = screen.getByText("Select source and target containers for migration");
+ const sourceSection = screen.getByText("Source Container");
+ const targetSection = screen.getByText("Target Container");
+
+ expect(description).toBeInTheDocument();
+ expect(sourceSection).toBeInTheDocument();
+ expect(targetSection).toBeInTheDocument();
+ });
+
+ it("should maintain component hierarchy", () => {
+ const { container } = renderWithContext( );
+
+ const mainContainer = container.querySelector(".selectSourceAndTargetContainers");
+ expect(mainContainer).toBeInTheDocument();
+ });
+ });
+
+ describe("Performance", () => {
+ it("should not cause unnecessary re-renders when props don't change", () => {
+ const { rerender } = renderWithContext( );
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(mockUseSourceAndTargetData).toHaveBeenCalled();
+ });
+
+ it("should handle rapid state changes efficiently", () => {
+ const { rerender } = renderWithContext( );
+
+ for (let i = 0; i < 5; i++) {
+ mockUseSourceAndTargetData.mockReturnValue({
+ ...mockMemoizedData,
+ source: {
+ ...mockMemoizedData.source,
+ databaseId: `db-${i}`,
+ },
+ } as ReturnType);
+
+ rerender(
+
+
+ ,
+ );
+ }
+
+ expect(mockUseSourceAndTargetData).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx
index 8bfc76167..6a0ad3715 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx
@@ -7,7 +7,7 @@ import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { DatabaseContainerSection } from "./components/DatabaseContainerSection";
import { dropDownChangeHandler } from "./Events/DropDownChangeHandler";
-import { useMemoizedSourceAndTargetData } from "./memoizedData";
+import { useSourceAndTargetData } from "./memoizedData";
type SelectSourceAndTargetContainers = {
showAddCollectionPanel?: () => void;
@@ -16,35 +16,43 @@ type SelectSourceAndTargetContainers = {
const SelectSourceAndTargetContainers = ({ showAddCollectionPanel }: SelectSourceAndTargetContainers) => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams } =
- useMemoizedSourceAndTargetData(copyJobState);
+ useSourceAndTargetData(copyJobState);
- const sourceDatabases = useDatabases(...sourceDbParams) || [];
- const sourceContainers = useDataContainers(...sourceContainerParams) || [];
- const targetDatabases = useDatabases(...targetDbParams) || [];
- const targetContainers = useDataContainers(...targetContainerParams) || [];
+ if (!source) {
+ return null;
+ }
+
+ const sourceDatabases = useDatabases(...sourceDbParams);
+ const sourceContainers = useDataContainers(...sourceContainerParams);
+ const targetDatabases = useDatabases(...targetDbParams);
+ const targetContainers = useDataContainers(...targetContainerParams);
const sourceDatabaseOptions = React.useMemo(
- () => sourceDatabases.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })),
+ () => sourceDatabases?.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })) || [],
[sourceDatabases],
);
const sourceContainerOptions = React.useMemo(
- () => sourceContainers.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })),
+ () => sourceContainers?.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })) || [],
[sourceContainers],
);
const targetDatabaseOptions = React.useMemo(
- () => targetDatabases.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })),
+ () => targetDatabases?.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })) || [],
[targetDatabases],
);
const targetContainerOptions = React.useMemo(
- () => targetContainers.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })),
+ () => targetContainers?.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })) || [],
[targetContainers],
);
- const onDropdownChange = React.useCallback(dropDownChangeHandler(setCopyJobState), [setCopyJobState]);
+ const onDropdownChange = dropDownChangeHandler(setCopyJobState);
return (
-
- {ContainerCopyMessages.selectSourceAndTargetContainersDescription}
+
+ {ContainerCopyMessages.selectSourceAndTargetContainersDescription}
);
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.test.tsx
new file mode 100644
index 000000000..10d1f0766
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.test.tsx
@@ -0,0 +1,458 @@
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen } from "@testing-library/react";
+import React from "react";
+import ContainerCopyMessages from "../../../../ContainerCopyMessages";
+import { DatabaseContainerSectionProps, DropdownOptionType } from "../../../../Types/CopyJobTypes";
+import { DatabaseContainerSection } from "./DatabaseContainerSection";
+
+describe("DatabaseContainerSection", () => {
+ const mockDatabaseOnChange = jest.fn();
+ const mockContainerOnChange = jest.fn();
+ const mockHandleOnDemandCreateContainer = jest.fn();
+
+ const mockDatabaseOptions: DropdownOptionType[] = [
+ { key: "db1", text: "Database 1", data: { id: "db1" } },
+ { key: "db2", text: "Database 2", data: { id: "db2" } },
+ { key: "db3", text: "Database 3", data: { id: "db3" } },
+ ];
+
+ const mockContainerOptions: DropdownOptionType[] = [
+ { key: "container1", text: "Container 1", data: { id: "container1" } },
+ { key: "container2", text: "Container 2", data: { id: "container2" } },
+ { key: "container3", text: "Container 3", data: { id: "container3" } },
+ ];
+
+ const defaultProps: DatabaseContainerSectionProps = {
+ heading: "Source container",
+ databaseOptions: mockDatabaseOptions,
+ selectedDatabase: "db1",
+ databaseDisabled: false,
+ databaseOnChange: mockDatabaseOnChange,
+ containerOptions: mockContainerOptions,
+ selectedContainer: "container1",
+ containerDisabled: false,
+ containerOnChange: mockContainerOnChange,
+ sectionType: "source",
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("Component Rendering", () => {
+ it("renders the component with correct structure", () => {
+ const { container } = render( );
+
+ expect(container.firstChild).toHaveClass("databaseContainerSection");
+ expect(screen.getByText("Source container")).toBeInTheDocument();
+ });
+
+ it("renders heading correctly", () => {
+ render( );
+
+ const heading = screen.getByText("Source container");
+ expect(heading).toBeInTheDocument();
+ expect(heading.tagName).toBe("LABEL");
+ expect(heading).toHaveClass("subHeading");
+ });
+
+ it("renders database dropdown with correct properties", () => {
+ render( );
+
+ const databaseDropdown = screen.getByRole("combobox", {
+ name: ContainerCopyMessages.databaseDropdownLabel,
+ });
+
+ expect(databaseDropdown).toBeInTheDocument();
+ expect(databaseDropdown).toHaveAttribute("aria-label", ContainerCopyMessages.databaseDropdownLabel);
+ expect(databaseDropdown).not.toBeDisabled();
+ });
+
+ it("renders container dropdown with correct properties", () => {
+ render( );
+
+ const containerDropdown = screen.getByRole("combobox", {
+ name: ContainerCopyMessages.containerDropdownLabel,
+ });
+
+ expect(containerDropdown).toBeInTheDocument();
+ expect(containerDropdown).toHaveAttribute("aria-label", ContainerCopyMessages.containerDropdownLabel);
+ expect(containerDropdown).not.toBeDisabled();
+ });
+
+ it("renders database label correctly", () => {
+ render( );
+
+ expect(screen.getByText(`${ContainerCopyMessages.databaseDropdownLabel}:`)).toBeInTheDocument();
+ });
+
+ it("renders container label correctly", () => {
+ render( );
+
+ expect(screen.getByText(`${ContainerCopyMessages.containerDropdownLabel}:`)).toBeInTheDocument();
+ });
+
+ it("does not render create container button when handleOnDemandCreateContainer is not provided", () => {
+ render( );
+
+ expect(screen.queryByText(ContainerCopyMessages.createContainerButtonLabel)).not.toBeInTheDocument();
+ });
+
+ it("renders create container button when handleOnDemandCreateContainer is provided", () => {
+ const propsWithCreateHandler = {
+ ...defaultProps,
+ handleOnDemandCreateContainer: mockHandleOnDemandCreateContainer,
+ };
+ const { container } = render( );
+ const createButton = container.querySelector(".create-container-link-btn");
+
+ expect(createButton).toBeInTheDocument();
+ expect(createButton).toHaveTextContent(ContainerCopyMessages.createContainerButtonLabel);
+ });
+ });
+
+ describe("Dropdown States", () => {
+ it("renders database dropdown as disabled when databaseDisabled is true", () => {
+ const propsWithDisabledDatabase = {
+ ...defaultProps,
+ databaseDisabled: true,
+ };
+
+ render( );
+
+ const databaseDropdown = screen.getByRole("combobox", {
+ name: ContainerCopyMessages.databaseDropdownLabel,
+ });
+
+ expect(databaseDropdown).toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("renders container dropdown as disabled when containerDisabled is true", () => {
+ const propsWithDisabledContainer = {
+ ...defaultProps,
+ containerDisabled: true,
+ };
+
+ render( );
+
+ const containerDropdown = screen.getByRole("combobox", {
+ name: ContainerCopyMessages.containerDropdownLabel,
+ });
+
+ expect(containerDropdown).toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("handles falsy values for disabled props correctly", () => {
+ const propsWithFalsyDisabled = {
+ ...defaultProps,
+ databaseDisabled: undefined,
+ containerDisabled: null,
+ } as DatabaseContainerSectionProps;
+
+ render( );
+
+ const databaseDropdown = screen.getByRole("combobox", {
+ name: ContainerCopyMessages.databaseDropdownLabel,
+ });
+ const containerDropdown = screen.getByRole("combobox", {
+ name: ContainerCopyMessages.containerDropdownLabel,
+ });
+
+ expect(databaseDropdown).not.toHaveAttribute("aria-disabled", "true");
+ expect(containerDropdown).not.toHaveAttribute("aria-disabled", "true");
+ });
+ });
+
+ describe("User Interactions", () => {
+ it("calls databaseOnChange when database dropdown selection changes", () => {
+ render( );
+ const databaseDropdown = screen.getByRole("combobox", {
+ name: ContainerCopyMessages.databaseDropdownLabel,
+ });
+
+ fireEvent.click(databaseDropdown);
+ expect(databaseDropdown).toHaveAttribute("aria-label", ContainerCopyMessages.databaseDropdownLabel);
+ });
+
+ it("calls containerOnChange when container dropdown selection changes", () => {
+ render( );
+ const containerDropdown = screen.getByRole("combobox", {
+ name: ContainerCopyMessages.containerDropdownLabel,
+ });
+
+ fireEvent.click(containerDropdown);
+ expect(containerDropdown).toHaveAttribute("aria-label", ContainerCopyMessages.containerDropdownLabel);
+ });
+
+ it("calls handleOnDemandCreateContainer when create container button is clicked", () => {
+ const propsWithCreateHandler = {
+ ...defaultProps,
+ handleOnDemandCreateContainer: mockHandleOnDemandCreateContainer,
+ };
+
+ render( );
+
+ const createButton = screen.getByText(ContainerCopyMessages.createContainerButtonLabel);
+ fireEvent.click(createButton);
+
+ expect(mockHandleOnDemandCreateContainer).toHaveBeenCalledTimes(1);
+ expect(mockHandleOnDemandCreateContainer).toHaveBeenCalledWith();
+ });
+ });
+
+ describe("Props Validation", () => {
+ it("renders with different heading text", () => {
+ const propsWithDifferentHeading = {
+ ...defaultProps,
+ heading: "Target container",
+ };
+
+ render( );
+
+ expect(screen.getByText("Target container")).toBeInTheDocument();
+ expect(screen.queryByText("Source container")).not.toBeInTheDocument();
+ });
+
+ it("renders with different selected values", () => {
+ const propsWithDifferentSelections = {
+ ...defaultProps,
+ selectedDatabase: "db2",
+ selectedContainer: "container3",
+ };
+
+ render( );
+
+ expect(screen.getByText("Source container")).toBeInTheDocument();
+ });
+
+ it("renders with empty options arrays", () => {
+ const propsWithEmptyOptions = {
+ ...defaultProps,
+ databaseOptions: [],
+ containerOptions: [],
+ } as DatabaseContainerSectionProps;
+
+ render( );
+
+ const databaseDropdown = screen.getByRole("combobox", {
+ name: ContainerCopyMessages.databaseDropdownLabel,
+ });
+ const containerDropdown = screen.getByRole("combobox", {
+ name: ContainerCopyMessages.containerDropdownLabel,
+ });
+
+ expect(databaseDropdown).toBeInTheDocument();
+ expect(containerDropdown).toBeInTheDocument();
+ });
+ });
+
+ describe("Accessibility", () => {
+ it("has proper ARIA labels for dropdowns", () => {
+ render( );
+
+ const databaseDropdown = screen.getByRole("combobox", {
+ name: ContainerCopyMessages.databaseDropdownLabel,
+ });
+ const containerDropdown = screen.getByRole("combobox", {
+ name: ContainerCopyMessages.containerDropdownLabel,
+ });
+
+ expect(databaseDropdown).toHaveAttribute("aria-label", ContainerCopyMessages.databaseDropdownLabel);
+ expect(containerDropdown).toHaveAttribute("aria-label", ContainerCopyMessages.containerDropdownLabel);
+ });
+
+ it("has proper required attributes for dropdowns", () => {
+ render( );
+
+ const databaseDropdown = screen.getByRole("combobox", {
+ name: ContainerCopyMessages.databaseDropdownLabel,
+ });
+ const containerDropdown = screen.getByRole("combobox", {
+ name: ContainerCopyMessages.containerDropdownLabel,
+ });
+
+ expect(databaseDropdown).toHaveAttribute("aria-required", "true");
+ expect(containerDropdown).toHaveAttribute("aria-required", "true");
+ });
+
+ it("maintains proper label associations", () => {
+ render( );
+
+ expect(screen.getByText(`${ContainerCopyMessages.databaseDropdownLabel}:`)).toBeInTheDocument();
+ expect(screen.getByText(`${ContainerCopyMessages.containerDropdownLabel}:`)).toBeInTheDocument();
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("handles undefined optional props gracefully", () => {
+ const minimalProps: DatabaseContainerSectionProps = {
+ heading: "Test Heading",
+ databaseOptions: mockDatabaseOptions,
+ selectedDatabase: "db1",
+ databaseOnChange: mockDatabaseOnChange,
+ containerOptions: mockContainerOptions,
+ selectedContainer: "container1",
+ containerOnChange: mockContainerOnChange,
+ sectionType: "source",
+ };
+
+ render( );
+
+ expect(screen.getByText("Test Heading")).toBeInTheDocument();
+ expect(screen.queryByText(ContainerCopyMessages.createContainerButtonLabel)).not.toBeInTheDocument();
+ });
+
+ it("handles empty string selections", () => {
+ const propsWithEmptySelections = {
+ ...defaultProps,
+ selectedDatabase: "",
+ selectedContainer: "",
+ };
+
+ render( );
+
+ expect(screen.getByText("Source container")).toBeInTheDocument();
+ });
+
+ it("renders correctly with long option texts", () => {
+ const longOptions = [
+ {
+ key: "long1",
+ text: "This is a very long database name that might wrap to multiple lines in the dropdown",
+ data: { id: "long1" },
+ },
+ ];
+
+ const propsWithLongOptions = {
+ ...defaultProps,
+ databaseOptions: longOptions,
+ containerOptions: longOptions,
+ selectedDatabase: "long1",
+ selectedContainer: "long1",
+ };
+
+ render( );
+
+ expect(screen.getByText("Source container")).toBeInTheDocument();
+ });
+ });
+
+ describe("Component Structure", () => {
+ it("has correct CSS classes applied", () => {
+ const { container } = render( );
+
+ const mainContainer = container.querySelector(".databaseContainerSection");
+ expect(mainContainer).toBeInTheDocument();
+
+ const subHeading = screen.getByText("Source container");
+ expect(subHeading).toHaveClass("subHeading");
+ });
+
+ it("maintains proper component hierarchy", () => {
+ const { container } = render( );
+
+ const mainStack = container.querySelector(".databaseContainerSection");
+ expect(mainStack).toBeInTheDocument();
+
+ const fieldRows = container.querySelectorAll(".flex-row");
+ expect(fieldRows.length).toBe(2);
+ });
+
+ it("renders create button in correct position when provided", () => {
+ const propsWithCreateHandler = {
+ ...defaultProps,
+ handleOnDemandCreateContainer: mockHandleOnDemandCreateContainer,
+ };
+
+ const { container } = render( );
+
+ const createButton = screen.getByText(ContainerCopyMessages.createContainerButtonLabel);
+ expect(createButton).toBeInTheDocument();
+
+ const containerSection = container.querySelector(".databaseContainerSection");
+ expect(containerSection).toContainElement(createButton);
+ });
+
+ it("displays correct create container button label", () => {
+ const propsWithCreateHandler = {
+ ...defaultProps,
+ handleOnDemandCreateContainer: mockHandleOnDemandCreateContainer,
+ };
+
+ render( );
+
+ expect(screen.getByText(ContainerCopyMessages.createContainerButtonLabel)).toBeInTheDocument();
+ });
+ });
+
+ describe("Snapshot Testing", () => {
+ it("matches snapshot with minimal props", () => {
+ const minimalProps: DatabaseContainerSectionProps = {
+ heading: "Source Container",
+ databaseOptions: [{ key: "db1", text: "Database 1", data: { id: "db1" } }],
+ selectedDatabase: "db1",
+ databaseOnChange: jest.fn(),
+ containerOptions: [{ key: "c1", text: "Container 1", data: { id: "c1" } }],
+ selectedContainer: "c1",
+ containerOnChange: jest.fn(),
+ sectionType: "source",
+ };
+
+ const { container } = render( );
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("matches snapshot with all props including create container handler", () => {
+ const fullProps: DatabaseContainerSectionProps = {
+ heading: "Target Container",
+ databaseOptions: mockDatabaseOptions,
+ selectedDatabase: "db2",
+ databaseDisabled: false,
+ databaseOnChange: jest.fn(),
+ containerOptions: mockContainerOptions,
+ selectedContainer: "container2",
+ containerDisabled: false,
+ containerOnChange: jest.fn(),
+ handleOnDemandCreateContainer: jest.fn(),
+ sectionType: "target",
+ };
+
+ const { container } = render( );
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("matches snapshot with disabled states", () => {
+ const disabledProps: DatabaseContainerSectionProps = {
+ heading: "Disabled Section",
+ databaseOptions: mockDatabaseOptions,
+ selectedDatabase: "db1",
+ databaseDisabled: true,
+ databaseOnChange: jest.fn(),
+ containerOptions: mockContainerOptions,
+ selectedContainer: "container1",
+ containerDisabled: true,
+ containerOnChange: jest.fn(),
+ sectionType: "target",
+ };
+
+ const { container } = render( );
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it("matches snapshot with empty options", () => {
+ const emptyOptionsProps: DatabaseContainerSectionProps = {
+ heading: "Empty Options",
+ databaseOptions: [],
+ selectedDatabase: "",
+ databaseOnChange: jest.fn(),
+ containerOptions: [],
+ selectedContainer: "",
+ containerOnChange: jest.fn(),
+ sectionType: "target",
+ };
+
+ const { container } = render( );
+ expect(container.firstChild).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx
index 2edac6ce8..ef0833ee1 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx
@@ -15,6 +15,7 @@ export const DatabaseContainerSection = ({
containerDisabled,
containerOnChange,
handleOnDemandCreateContainer,
+ sectionType,
}: DatabaseContainerSectionProps) => (
{heading}
@@ -27,6 +28,7 @@ export const DatabaseContainerSection = ({
disabled={!!databaseDisabled}
selectedKey={selectedDatabase}
onChange={databaseOnChange}
+ data-test={`${sectionType}-databaseDropdown`}
/>
@@ -39,9 +41,14 @@ export const DatabaseContainerSection = ({
disabled={!!containerDisabled}
selectedKey={selectedContainer}
onChange={containerOnChange}
+ data-test={`${sectionType}-containerDropdown`}
/>
{handleOnDemandCreateContainer && (
- handleOnDemandCreateContainer()}>
+ handleOnDemandCreateContainer()}
+ >
{ContainerCopyMessages.createContainerButtonLabel}
)}
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/__snapshots__/DatabaseContainerSection.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/__snapshots__/DatabaseContainerSection.test.tsx.snap
new file mode 100644
index 000000000..4c3cf112d
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/__snapshots__/DatabaseContainerSection.test.tsx.snap
@@ -0,0 +1,526 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DatabaseContainerSection Snapshot Testing matches snapshot with all props including create container handler 1`] = `
+
+
+ Target Container
+
+
+
+
+ Database
+ :
+
+
+
+
+
+
+ Database 2
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Container
+ :
+
+
+
+
+
+
+
+ Container 2
+
+
+
+
+
+
+
+
+
+
+
+
+ Create a new container
+
+
+
+
+
+
+
+
+`;
+
+exports[`DatabaseContainerSection Snapshot Testing matches snapshot with disabled states 1`] = `
+
+
+ Disabled Section
+
+
+
+
+ Database
+ :
+
+
+
+
+
+
+ Database 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Container
+ :
+
+
+
+
+
+
+
+ Container 1
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`DatabaseContainerSection Snapshot Testing matches snapshot with empty options 1`] = `
+
+
+ Empty Options
+
+
+
+
+ Database
+ :
+
+
+
+
+
+
+ Select a database
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Container
+ :
+
+
+
+
+
+
+
+ Select a container
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`DatabaseContainerSection Snapshot Testing matches snapshot with minimal props 1`] = `
+
+
+ Source Container
+
+
+
+
+ Database
+ :
+
+
+
+
+
+
+ Database 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Container
+ :
+
+
+
+
+
+
+
+ Container 1
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.test.tsx
new file mode 100644
index 000000000..85de409cb
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.test.tsx
@@ -0,0 +1,387 @@
+import "@testing-library/jest-dom";
+import { render } from "@testing-library/react";
+import React from "react";
+import { DatabaseAccount, Subscription } from "../../../../../Contracts/DataModels";
+import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
+import { CopyJobContextState } from "../../../Types/CopyJobTypes";
+import { useSourceAndTargetData } from "./memoizedData";
+
+jest.mock("../../../CopyJobUtils", () => ({
+ getAccountDetailsFromResourceId: jest.fn(),
+}));
+
+import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
+
+const mockGetAccountDetailsFromResourceId = getAccountDetailsFromResourceId as jest.MockedFunction<
+ typeof getAccountDetailsFromResourceId
+>;
+
+interface TestComponentProps {
+ copyJobState: CopyJobContextState | null;
+ onResult?: (result: any) => void;
+}
+
+const TestComponent: React.FC = ({ copyJobState, onResult }) => {
+ const result = useSourceAndTargetData(copyJobState);
+
+ React.useEffect(() => {
+ onResult?.(result);
+ }, [result, onResult]);
+
+ return Test Component
;
+};
+
+describe("useSourceAndTargetData", () => {
+ const mockSubscription: Subscription = {
+ subscriptionId: "test-subscription-id",
+ displayName: "Test Subscription",
+ state: "Enabled",
+ subscriptionPolicies: null,
+ authorizationSource: "RoleBased",
+ };
+
+ const mockSourceAccount: DatabaseAccount = {
+ id: "/subscriptions/source-sub-id/resourceGroups/source-rg/providers/Microsoft.DocumentDB/databaseAccounts/source-account",
+ name: "source-account",
+ location: "East US",
+ type: "Microsoft.DocumentDB/databaseAccounts",
+ kind: "GlobalDocumentDB",
+ properties: {
+ documentEndpoint: "https://source-account.documents.azure.com:443/",
+ capabilities: [],
+ locations: [],
+ },
+ };
+
+ const mockTargetAccount: DatabaseAccount = {
+ id: "/subscriptions/target-sub-id/resourceGroups/target-rg/providers/Microsoft.DocumentDB/databaseAccounts/target-account",
+ name: "target-account",
+ location: "West US",
+ type: "Microsoft.DocumentDB/databaseAccounts",
+ kind: "GlobalDocumentDB",
+ properties: {
+ documentEndpoint: "https://target-account.documents.azure.com:443/",
+ capabilities: [],
+ locations: [],
+ },
+ };
+
+ const mockCopyJobState: CopyJobContextState = {
+ jobName: "test-job",
+ migrationType: CopyJobMigrationType.Offline,
+ sourceReadAccessFromTarget: false,
+ source: {
+ subscription: mockSubscription,
+ account: mockSourceAccount,
+ databaseId: "source-db",
+ containerId: "source-container",
+ },
+ target: {
+ subscriptionId: "target-subscription-id",
+ account: mockTargetAccount,
+ databaseId: "target-db",
+ containerId: "target-container",
+ },
+ };
+
+ beforeEach(() => {
+ mockGetAccountDetailsFromResourceId.mockImplementation((accountId) => {
+ if (accountId === mockSourceAccount.id) {
+ return {
+ subscriptionId: "source-sub-id",
+ resourceGroup: "source-rg",
+ accountName: "source-account",
+ };
+ } else if (accountId === mockTargetAccount.id) {
+ return {
+ subscriptionId: "target-sub-id",
+ resourceGroup: "target-rg",
+ accountName: "target-account",
+ };
+ }
+ return null;
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("Hook Execution", () => {
+ it("should return correct data structure when copyJobState is provided", () => {
+ let hookResult: any = null;
+ const onResult = jest.fn((result) => {
+ hookResult = result;
+ });
+
+ render( );
+
+ expect(onResult).toHaveBeenCalled();
+ expect(hookResult).toBeDefined();
+ expect(hookResult).toHaveProperty("source");
+ expect(hookResult).toHaveProperty("target");
+ expect(hookResult).toHaveProperty("sourceDbParams");
+ expect(hookResult).toHaveProperty("sourceContainerParams");
+ expect(hookResult).toHaveProperty("targetDbParams");
+ expect(hookResult).toHaveProperty("targetContainerParams");
+ });
+
+ it("should call getAccountDetailsFromResourceId with correct parameters", () => {
+ render( );
+
+ expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalledWith(mockSourceAccount.id);
+ expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalledWith(mockTargetAccount.id);
+ expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalledTimes(2);
+ });
+
+ it("should return source and target objects from copyJobState", () => {
+ let hookResult: any = null;
+ const onResult = jest.fn((result) => {
+ hookResult = result;
+ });
+
+ render( );
+
+ expect(hookResult.source).toEqual(mockCopyJobState.source);
+ expect(hookResult.target).toEqual(mockCopyJobState.target);
+ });
+
+ it("should construct sourceDbParams array correctly", () => {
+ let hookResult: any = null;
+ const onResult = jest.fn((result) => {
+ hookResult = result;
+ });
+
+ render( );
+
+ expect(hookResult.sourceDbParams).toEqual(["source-sub-id", "source-rg", "source-account", "SQL"]);
+ });
+
+ it("should construct sourceContainerParams array correctly", () => {
+ let hookResult: any = null;
+ const onResult = jest.fn((result) => {
+ hookResult = result;
+ });
+
+ render( );
+
+ expect(hookResult.sourceContainerParams).toEqual([
+ "source-sub-id",
+ "source-rg",
+ "source-account",
+ "source-db",
+ "SQL",
+ ]);
+ });
+
+ it("should construct targetDbParams array correctly", () => {
+ let hookResult: any = null;
+ const onResult = jest.fn((result) => {
+ hookResult = result;
+ });
+
+ render( );
+
+ expect(hookResult.targetDbParams).toEqual(["target-sub-id", "target-rg", "target-account", "SQL"]);
+ });
+
+ it("should construct targetContainerParams array correctly", () => {
+ let hookResult: any = null;
+ const onResult = jest.fn((result) => {
+ hookResult = result;
+ });
+
+ render( );
+
+ expect(hookResult.targetContainerParams).toEqual([
+ "target-sub-id",
+ "target-rg",
+ "target-account",
+ "target-db",
+ "SQL",
+ ]);
+ });
+ });
+
+ describe("Memoization and Performance", () => {
+ it("should work with React strict mode (double invocation)", () => {
+ let hookResult: any = null;
+ const onResult = jest.fn((result) => {
+ hookResult = result;
+ });
+
+ const { rerender } = render( );
+ const firstResult = { ...hookResult };
+
+ rerender( );
+ const secondResult = { ...hookResult };
+
+ expect(firstResult).toEqual(secondResult);
+ });
+
+ it("should handle component re-renders gracefully", () => {
+ let renderCount = 0;
+ const onResult = jest.fn(() => {
+ renderCount++;
+ });
+
+ const { rerender } = render( );
+
+ for (let i = 0; i < 5; i++) {
+ rerender( );
+ }
+
+ expect(renderCount).toBeGreaterThan(0);
+ expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalled();
+ });
+
+ it("should recalculate when copyJobState changes", () => {
+ let hookResult: any = null;
+ const onResult = jest.fn((result) => {
+ hookResult = result;
+ });
+
+ const { rerender } = render( );
+ const firstResult = { ...hookResult };
+
+ const updatedState = {
+ ...mockCopyJobState,
+ source: {
+ ...mockCopyJobState.source,
+ databaseId: "updated-source-db",
+ },
+ };
+
+ rerender( );
+ const secondResult = { ...hookResult };
+
+ expect(firstResult.sourceContainerParams[3]).toBe("source-db");
+ expect(secondResult.sourceContainerParams[3]).toBe("updated-source-db");
+ });
+ });
+
+ describe("Complex State Scenarios", () => {
+ it("should handle state with only source defined", () => {
+ const sourceOnlyState = {
+ ...mockCopyJobState,
+ target: undefined as any,
+ };
+
+ let hookResult: any = null;
+ const onResult = jest.fn((result) => {
+ hookResult = result;
+ });
+
+ render( );
+
+ expect(hookResult.source).toBeDefined();
+ expect(hookResult.target).toBeUndefined();
+ expect(hookResult.sourceDbParams).toEqual(["source-sub-id", "source-rg", "source-account", "SQL"]);
+ expect(hookResult.targetDbParams).toEqual([undefined, undefined, undefined, "SQL"]);
+ });
+
+ it("should handle state with only target defined", () => {
+ const targetOnlyState = {
+ ...mockCopyJobState,
+ source: undefined as any,
+ };
+
+ let hookResult: any = null;
+ const onResult = jest.fn((result) => {
+ hookResult = result;
+ });
+
+ render( );
+
+ expect(hookResult.source).toBeUndefined();
+ expect(hookResult.target).toBeDefined();
+ expect(hookResult.sourceDbParams).toEqual([undefined, undefined, undefined, "SQL"]);
+ expect(hookResult.targetDbParams).toEqual(["target-sub-id", "target-rg", "target-account", "SQL"]);
+ });
+
+ it("should handle state with missing database IDs", () => {
+ const stateWithoutDbIds = {
+ ...mockCopyJobState,
+ source: {
+ ...mockCopyJobState.source,
+ databaseId: undefined as any,
+ },
+ target: {
+ ...mockCopyJobState.target,
+ databaseId: undefined as any,
+ },
+ };
+
+ let hookResult: any = null;
+ const onResult = jest.fn((result) => {
+ hookResult = result;
+ });
+
+ render( );
+
+ expect(hookResult.sourceContainerParams[3]).toBeUndefined();
+ expect(hookResult.targetContainerParams[3]).toBeUndefined();
+ });
+
+ it("should handle state with missing accounts", () => {
+ const stateWithoutAccounts = {
+ ...mockCopyJobState,
+ source: {
+ ...mockCopyJobState.source,
+ account: undefined as any,
+ },
+ target: {
+ ...mockCopyJobState.target,
+ account: undefined as any,
+ },
+ };
+
+ let hookResult: any = null;
+ const onResult = jest.fn((result) => {
+ hookResult = result;
+ });
+
+ render( );
+
+ expect(mockGetAccountDetailsFromResourceId).toHaveBeenCalledWith(undefined);
+ expect(hookResult.sourceDbParams).toEqual([undefined, undefined, undefined, "SQL"]);
+ expect(hookResult.targetDbParams).toEqual([undefined, undefined, undefined, "SQL"]);
+ });
+ });
+
+ describe("Hook Return Value Structure", () => {
+ it("should return an object with exactly 6 properties", () => {
+ let hookResult: any = null;
+ const onResult = jest.fn((result) => {
+ hookResult = result;
+ });
+
+ render( );
+
+ const keys = Object.keys(hookResult);
+ expect(keys).toHaveLength(6);
+ expect(keys).toContain("source");
+ expect(keys).toContain("target");
+ expect(keys).toContain("sourceDbParams");
+ expect(keys).toContain("sourceContainerParams");
+ expect(keys).toContain("targetDbParams");
+ expect(keys).toContain("targetContainerParams");
+ });
+
+ it("should not return undefined properties when state is valid", () => {
+ let hookResult: any = null;
+ const onResult = jest.fn((result) => {
+ hookResult = result;
+ });
+
+ render( );
+
+ expect(hookResult.source).toBeDefined();
+ expect(hookResult.target).toBeDefined();
+ expect(hookResult.sourceDbParams).toBeDefined();
+ expect(hookResult.sourceContainerParams).toBeDefined();
+ expect(hookResult.targetDbParams).toBeDefined();
+ expect(hookResult.targetContainerParams).toBeDefined();
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx
index 627bc8812..76fa3dc7a 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.tsx
@@ -1,8 +1,7 @@
-import React from "react";
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
import { CopyJobContextState, DatabaseParams, DataContainerParams } from "../../../Types/CopyJobTypes";
-export function useMemoizedSourceAndTargetData(copyJobState: CopyJobContextState) {
+export function useSourceAndTargetData(copyJobState: CopyJobContextState) {
const { source, target } = copyJobState ?? {};
const selectedSourceAccount = source?.account;
const selectedTargetAccount = target?.account;
@@ -10,34 +9,29 @@ export function useMemoizedSourceAndTargetData(copyJobState: CopyJobContextState
subscriptionId: sourceSubscriptionId,
resourceGroup: sourceResourceGroup,
accountName: sourceAccountName,
- } = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
+ } = getAccountDetailsFromResourceId(selectedSourceAccount?.id) || {};
const {
subscriptionId: targetSubscriptionId,
resourceGroup: targetResourceGroup,
accountName: targetAccountName,
- } = getAccountDetailsFromResourceId(selectedTargetAccount?.id);
+ } = getAccountDetailsFromResourceId(selectedTargetAccount?.id) || {};
- const sourceDbParams = React.useMemo(
- () => [sourceSubscriptionId, sourceResourceGroup, sourceAccountName, "SQL"] as DatabaseParams,
- [sourceSubscriptionId, sourceResourceGroup, sourceAccountName],
- );
-
- const sourceContainerParams = React.useMemo(
- () =>
- [sourceSubscriptionId, sourceResourceGroup, sourceAccountName, source?.databaseId, "SQL"] as DataContainerParams,
- [sourceSubscriptionId, sourceResourceGroup, sourceAccountName, source?.databaseId],
- );
-
- const targetDbParams = React.useMemo(
- () => [targetSubscriptionId, targetResourceGroup, targetAccountName, "SQL"] as DatabaseParams,
- [targetSubscriptionId, targetResourceGroup, targetAccountName],
- );
-
- const targetContainerParams = React.useMemo(
- () =>
- [targetSubscriptionId, targetResourceGroup, targetAccountName, target?.databaseId, "SQL"] as DataContainerParams,
- [targetSubscriptionId, targetResourceGroup, targetAccountName, target?.databaseId],
- );
+ const sourceDbParams = [sourceSubscriptionId, sourceResourceGroup, sourceAccountName, "SQL"] as DatabaseParams;
+ const sourceContainerParams = [
+ sourceSubscriptionId,
+ sourceResourceGroup,
+ sourceAccountName,
+ source?.databaseId,
+ "SQL",
+ ] as DataContainerParams;
+ const targetDbParams = [targetSubscriptionId, targetResourceGroup, targetAccountName, "SQL"] as DatabaseParams;
+ const targetContainerParams = [
+ targetSubscriptionId,
+ targetResourceGroup,
+ targetAccountName,
+ target?.databaseId,
+ "SQL",
+ ] as DataContainerParams;
return { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams };
}
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/__snapshots__/CreateCopyJobScreensProvider.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/__snapshots__/CreateCopyJobScreensProvider.test.tsx.snap
new file mode 100644
index 000000000..31aab621f
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/__snapshots__/CreateCopyJobScreensProvider.test.tsx.snap
@@ -0,0 +1,89 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`CreateCopyJobScreensProvider should match snapshot for default render: default-render 1`] = `
+
+
+
+`;
+
+exports[`CreateCopyJobScreensProvider should match snapshot for edge cases: empty-explorer 1`] = `
+
+
+
+`;
+
+exports[`CreateCopyJobScreensProvider should match snapshot for edge cases: partial-explorer 1`] = `
+
+
+
+`;
+
+exports[`CreateCopyJobScreensProvider should render with explorer prop 1`] = `
+
+
+
+`;
+
+exports[`CreateCopyJobScreensProvider should render with null explorer 1`] = `
+
+
+
+`;
+
+exports[`CreateCopyJobScreensProvider should render with undefined explorer 1`] = `
+
+
+
+`;
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.test.tsx
new file mode 100644
index 000000000..737d9c78f
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.test.tsx
@@ -0,0 +1,324 @@
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import React from "react";
+import { CopyJobMigrationType } from "../../Enums/CopyJobEnums";
+import { CopyJobContextState } from "../../Types/CopyJobTypes";
+import { useCopyJobNavigation } from "./useCopyJobNavigation";
+
+jest.mock("../../../../hooks/useSidePanel", () => ({
+ useSidePanel: {
+ getState: jest.fn(() => ({
+ closeSidePanel: jest.fn(),
+ })),
+ },
+}));
+
+jest.mock("../../Actions/CopyJobActions", () => ({
+ submitCreateCopyJob: jest.fn(),
+}));
+
+jest.mock("../../Context/CopyJobContext", () => ({
+ useCopyJobContext: jest.fn(),
+}));
+
+jest.mock("./useCopyJobPrerequisitesCache", () => ({
+ useCopyJobPrerequisitesCache: jest.fn(),
+}));
+
+jest.mock("./useCreateCopyJobScreensList", () => ({
+ SCREEN_KEYS: {
+ SelectAccount: "SelectAccount",
+ AssignPermissions: "AssignPermissions",
+ SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers",
+ CreateCollection: "CreateCollection",
+ PreviewCopyJob: "PreviewCopyJob",
+ },
+ useCreateCopyJobScreensList: jest.fn(),
+}));
+
+jest.mock("../../CopyJobUtils", () => ({
+ getContainerIdentifiers: jest.fn(),
+ isIntraAccountCopy: jest.fn(),
+}));
+
+import { useSidePanel } from "../../../../hooks/useSidePanel";
+import { submitCreateCopyJob } from "../../Actions/CopyJobActions";
+import { useCopyJobContext } from "../../Context/CopyJobContext";
+import { getContainerIdentifiers, isIntraAccountCopy } from "../../CopyJobUtils";
+import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache";
+import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList";
+
+const TestComponent: React.FC<{
+ onHookResult?: (result: ReturnType) => void;
+}> = ({ onHookResult }) => {
+ const hookResult = useCopyJobNavigation();
+
+ React.useEffect(() => {
+ onHookResult?.(hookResult);
+ }, [hookResult, onHookResult]);
+
+ return (
+
+
{hookResult.currentScreen?.key}
+
{hookResult.isPrimaryDisabled.toString()}
+
{hookResult.isPreviousDisabled.toString()}
+
{hookResult.primaryBtnText}
+
+ {hookResult.primaryBtnText}
+
+
+ Previous
+
+
+ Cancel
+
+ {hookResult.currentScreen?.key === SCREEN_KEYS.SelectSourceAndTargetContainers && (
+
+ Show Collection Panel
+
+ )}
+
+ );
+};
+
+describe("useCopyJobNavigation", () => {
+ const createMockCopyJobState = (overrides?: Partial): CopyJobContextState => ({
+ jobName: "test-job",
+ migrationType: CopyJobMigrationType.Offline,
+ source: {
+ subscription: { subscriptionId: "source-sub-id" } as any,
+ account: { id: "source-account-id", name: "Account-1" } as any,
+ databaseId: "source-db",
+ containerId: "source-container",
+ },
+ target: {
+ subscriptionId: "target-sub-id",
+ account: { id: "target-account-id", name: "Account-2" } as any,
+ databaseId: "target-db",
+ containerId: "target-container",
+ },
+ ...overrides,
+ });
+
+ const createMockScreen = (key: string, validations: any[] = []) => ({
+ key,
+ component: {key} Screen
,
+ validations,
+ });
+
+ const mockResetCopyJobState = jest.fn();
+ const mockSetContextError = jest.fn();
+ const mockCloseSidePanel = jest.fn();
+ const mockCopyJobState = createMockCopyJobState();
+ const mockValidationCache = new Map([
+ ["validation1", true],
+ ["validation2", true],
+ ]);
+
+ const setupMocks = (screensList: any[] = [], isIntraAccount = false) => {
+ (useCopyJobContext as jest.Mock).mockReturnValue({
+ copyJobState: mockCopyJobState,
+ resetCopyJobState: mockResetCopyJobState,
+ setContextError: mockSetContextError,
+ });
+
+ (useCopyJobPrerequisitesCache as unknown as jest.Mock).mockReturnValue({
+ validationCache: mockValidationCache,
+ });
+
+ (useCreateCopyJobScreensList as jest.Mock).mockReturnValue(
+ screensList.length > 0 ? screensList : [createMockScreen(SCREEN_KEYS.SelectAccount)],
+ );
+
+ (useSidePanel.getState as jest.Mock).mockReturnValue({
+ closeSidePanel: mockCloseSidePanel,
+ });
+
+ (getContainerIdentifiers as jest.Mock).mockImplementation((container) => ({
+ accountId: container.account?.id,
+ databaseId: container.databaseId,
+ containerId: container.containerId,
+ }));
+
+ (isIntraAccountCopy as jest.Mock).mockReturnValue(isIntraAccount);
+ };
+
+ const clickPrimaryButton = () => fireEvent.click(screen.getByTestId("primary-btn"));
+ const clickPreviousButton = () => fireEvent.click(screen.getByTestId("previous-btn"));
+
+ const expectScreen = (screenKey: string) => {
+ expect(screen.getByTestId("current-screen")).toHaveTextContent(screenKey);
+ };
+
+ const expectPrimaryButtonText = (text: string) => {
+ expect(screen.getByTestId("primary-btn-text")).toHaveTextContent(text);
+ };
+
+ const expectPrimaryDisabled = (disabled: boolean) => {
+ expect(screen.getByTestId("primary-disabled")).toHaveTextContent(disabled.toString());
+ };
+
+ const navigateToScreen = (screenKey: string, clicks: number) => {
+ for (let i = 0; i < clicks; i++) {
+ clickPrimaryButton();
+ }
+ expectScreen(screenKey);
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ setupMocks();
+ });
+
+ describe("Initial state and navigation", () => {
+ test("should start with SelectAccount screen and disable previous button", () => {
+ render( );
+
+ expectScreen(SCREEN_KEYS.SelectAccount);
+ expect(screen.getByTestId("previous-disabled")).toHaveTextContent("true");
+ });
+
+ test("should show Next button text by default", () => {
+ render( );
+ expectPrimaryButtonText("Next");
+ });
+
+ test("should navigate through screens and show Create button for CreateCollection", () => {
+ const screens = [
+ createMockScreen(SCREEN_KEYS.SelectAccount),
+ createMockScreen(SCREEN_KEYS.SelectSourceAndTargetContainers),
+ createMockScreen(SCREEN_KEYS.CreateCollection),
+ ];
+ setupMocks(screens, true);
+
+ render( );
+
+ expectScreen(SCREEN_KEYS.SelectAccount);
+ clickPrimaryButton();
+
+ expectScreen(SCREEN_KEYS.SelectSourceAndTargetContainers);
+ expectPrimaryButtonText("Next");
+
+ fireEvent.click(screen.getByTestId("add-collection-btn"));
+ expectScreen(SCREEN_KEYS.CreateCollection);
+ expectPrimaryButtonText("Create");
+
+ clickPreviousButton();
+ expectScreen(SCREEN_KEYS.SelectSourceAndTargetContainers);
+ expectPrimaryButtonText("Next");
+ });
+ });
+
+ describe("Validation logic", () => {
+ test("should disable primary button when validations fail", () => {
+ const invalidScreen = createMockScreen(SCREEN_KEYS.SelectAccount, [
+ { validate: () => false, message: "Invalid state" },
+ ]);
+ setupMocks([invalidScreen]);
+
+ render( );
+ expectPrimaryDisabled(true);
+ });
+
+ test("should enable primary button when all validations pass", () => {
+ const validScreen = createMockScreen(SCREEN_KEYS.SelectAccount, [
+ { validate: () => true, message: "Valid state" },
+ ]);
+ setupMocks([validScreen]);
+
+ render( );
+ expectPrimaryDisabled(false);
+ });
+
+ test("should prevent navigation when source and target containers are identical", () => {
+ const screens = [
+ createMockScreen(SCREEN_KEYS.SelectAccount),
+ createMockScreen(SCREEN_KEYS.SelectSourceAndTargetContainers, [
+ { validate: () => true, message: "Valid containers" },
+ ]),
+ ];
+ setupMocks(screens, true);
+
+ (getContainerIdentifiers as jest.Mock).mockImplementation(() => ({
+ accountId: "same-account",
+ databaseId: "same-db",
+ containerId: "same-container",
+ }));
+
+ render( );
+
+ navigateToScreen(SCREEN_KEYS.SelectSourceAndTargetContainers, 1);
+ clickPrimaryButton();
+
+ expectScreen(SCREEN_KEYS.SelectSourceAndTargetContainers);
+ expect(mockSetContextError).toHaveBeenCalledWith(
+ "Source and destination containers cannot be the same. Please select different containers to proceed.",
+ );
+ });
+ });
+
+ describe("Copy job submission", () => {
+ const setupToPreviewScreen = () => {
+ const screens = [
+ createMockScreen(SCREEN_KEYS.SelectAccount),
+ createMockScreen(SCREEN_KEYS.SelectSourceAndTargetContainers),
+ createMockScreen(SCREEN_KEYS.PreviewCopyJob),
+ ];
+ setupMocks(screens, true);
+
+ render( );
+ navigateToScreen(SCREEN_KEYS.PreviewCopyJob, 2);
+ clickPrimaryButton();
+ };
+
+ test("should handle successful copy job submission", async () => {
+ (submitCreateCopyJob as jest.Mock).mockResolvedValue(undefined);
+
+ setupToPreviewScreen();
+
+ await waitFor(() => {
+ expect(submitCreateCopyJob).toHaveBeenCalledWith(mockCopyJobState, expect.any(Function));
+ });
+ });
+
+ test("should handle copy job submission error", async () => {
+ const error = new Error("Submission failed");
+ (submitCreateCopyJob as jest.Mock).mockRejectedValue(error);
+ setupToPreviewScreen();
+
+ await waitFor(() => {
+ expect(mockSetContextError).toHaveBeenCalledWith("Submission failed");
+ });
+ });
+
+ test("should handle unknown error during submission", async () => {
+ (submitCreateCopyJob as jest.Mock).mockRejectedValue("Unknown error");
+
+ setupToPreviewScreen();
+
+ await waitFor(() => {
+ expect(mockSetContextError).toHaveBeenCalledWith("Failed to create copy job. Please try again later.");
+ });
+ });
+
+ test("should disable buttons during loading", async () => {
+ let resolveSubmission: () => void;
+ const submissionPromise = new Promise((resolve) => {
+ resolveSubmission = resolve;
+ });
+ (submitCreateCopyJob as jest.Mock).mockReturnValue(submissionPromise);
+
+ setupToPreviewScreen();
+
+ await waitFor(() => {
+ expectPrimaryDisabled(true);
+ });
+
+ resolveSubmission!();
+
+ await waitFor(() => {
+ expectPrimaryDisabled(false);
+ });
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts
index dd8059547..6419f9471 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts
@@ -39,6 +39,7 @@ export function useCopyJobNavigation() {
const [state, dispatch] = useReducer(navigationReducer, { screenHistory: [SCREEN_KEYS.SelectAccount] });
const handlePrevious = useCallback(() => {
+ setContextError(null);
dispatch({ type: "PREVIOUS" });
}, [dispatch]);
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobPrerequisitesCache.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobPrerequisitesCache.test.tsx
new file mode 100644
index 000000000..5b17d1c3b
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobPrerequisitesCache.test.tsx
@@ -0,0 +1,334 @@
+import "@testing-library/jest-dom";
+import { act, render, screen } from "@testing-library/react";
+import React from "react";
+import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache";
+
+describe("useCopyJobPrerequisitesCache", () => {
+ let hookResult: any;
+
+ const TestComponent = ({ onHookUpdate }: { onHookUpdate?: () => void }): JSX.Element => {
+ hookResult = useCopyJobPrerequisitesCache();
+
+ React.useEffect(() => {
+ if (onHookUpdate) {
+ onHookUpdate();
+ }
+ }, [onHookUpdate]);
+
+ return (
+
+ {hookResult.validationCache.size}
+ {
+ const testCache = new Map();
+ testCache.set("test-key", true);
+ hookResult.setValidationCache(testCache);
+ }}
+ >
+ Set Cache
+
+ {
+ hookResult.setValidationCache(new Map());
+ }}
+ >
+ Clear Cache
+
+
+ );
+ };
+
+ afterEach(() => {
+ if (hookResult) {
+ act(() => {
+ hookResult.setValidationCache(new Map());
+ });
+ }
+ });
+
+ it("should initialize with an empty validation cache", () => {
+ render( );
+
+ expect(hookResult.validationCache).toBeInstanceOf(Map);
+ expect(hookResult.validationCache.size).toBe(0);
+ expect(screen.getByTestId("cache-size")).toHaveTextContent("0");
+ });
+
+ it("should provide a setValidationCache function", () => {
+ render( );
+
+ expect(typeof hookResult.setValidationCache).toBe("function");
+ });
+
+ it("should update validation cache when setValidationCache is called", () => {
+ render( );
+
+ const testCache = new Map();
+ testCache.set("test-key", true);
+ testCache.set("another-key", false);
+
+ act(() => {
+ hookResult.setValidationCache(testCache);
+ });
+
+ expect(hookResult.validationCache).toBe(testCache);
+ expect(hookResult.validationCache.size).toBe(2);
+ expect(hookResult.validationCache.get("test-key")).toBe(true);
+ expect(hookResult.validationCache.get("another-key")).toBe(false);
+ expect(screen.getByTestId("cache-size")).toHaveTextContent("2");
+ });
+
+ it("should replace the entire validation cache when setValidationCache is called", () => {
+ render( );
+
+ const initialCache = new Map();
+ initialCache.set("initial-key", true);
+
+ act(() => {
+ hookResult.setValidationCache(initialCache);
+ });
+
+ expect(hookResult.validationCache.get("initial-key")).toBe(true);
+ expect(screen.getByTestId("cache-size")).toHaveTextContent("1");
+
+ const newCache = new Map();
+ newCache.set("new-key", false);
+
+ act(() => {
+ hookResult.setValidationCache(newCache);
+ });
+
+ expect(hookResult.validationCache.get("initial-key")).toBeUndefined();
+ expect(hookResult.validationCache.get("new-key")).toBe(false);
+ expect(hookResult.validationCache.size).toBe(1);
+ expect(screen.getByTestId("cache-size")).toHaveTextContent("1");
+ });
+
+ it("should handle empty Map updates", () => {
+ render( );
+
+ const initialCache = new Map();
+ initialCache.set("test-key", true);
+
+ act(() => {
+ hookResult.setValidationCache(initialCache);
+ });
+
+ expect(hookResult.validationCache.size).toBe(1);
+ expect(screen.getByTestId("cache-size")).toHaveTextContent("1");
+
+ act(() => {
+ screen.getByTestId("clear-cache-button").click();
+ });
+
+ expect(hookResult.validationCache.size).toBe(0);
+ expect(screen.getByTestId("cache-size")).toHaveTextContent("0");
+ });
+
+ it("should maintain state across multiple hook instances (global store behavior)", () => {
+ let firstHookResult: any;
+ let secondHookResult: any;
+
+ const FirstComponent = (): JSX.Element => {
+ firstHookResult = useCopyJobPrerequisitesCache();
+ return First
;
+ };
+
+ const SecondComponent = (): JSX.Element => {
+ secondHookResult = useCopyJobPrerequisitesCache();
+ return Second
;
+ };
+
+ render(
+
+
+
+
,
+ );
+
+ const testCache = new Map();
+ testCache.set("shared-key", true);
+
+ act(() => {
+ firstHookResult.setValidationCache(testCache);
+ });
+
+ expect(secondHookResult.validationCache.get("shared-key")).toBe(true);
+ expect(secondHookResult.validationCache.size).toBe(1);
+ expect(firstHookResult.validationCache.get("shared-key")).toBe(true);
+ expect(firstHookResult.validationCache.size).toBe(1);
+ });
+
+ it("should allow updates from different hook instances", () => {
+ let firstHookResult: any;
+ let secondHookResult: any;
+
+ const FirstComponent = (): JSX.Element => {
+ firstHookResult = useCopyJobPrerequisitesCache();
+ return (
+ {
+ const testCache = new Map();
+ testCache.set("key-from-first", true);
+ firstHookResult.setValidationCache(testCache);
+ }}
+ >
+ Update from First
+
+ );
+ };
+
+ const SecondComponent = (): JSX.Element => {
+ secondHookResult = useCopyJobPrerequisitesCache();
+ return (
+ {
+ const testCache = new Map();
+ testCache.set("key-from-second", false);
+ secondHookResult.setValidationCache(testCache);
+ }}
+ >
+ Update from Second
+
+ );
+ };
+
+ render(
+
+
+
+
,
+ );
+
+ act(() => {
+ screen.getByTestId("first-update").click();
+ });
+
+ expect(secondHookResult.validationCache.get("key-from-first")).toBe(true);
+
+ act(() => {
+ screen.getByTestId("second-update").click();
+ });
+
+ expect(firstHookResult.validationCache.get("key-from-second")).toBe(false);
+ expect(firstHookResult.validationCache.get("key-from-first")).toBeUndefined();
+ });
+
+ it("should handle complex validation scenarios", () => {
+ const ComplexTestComponent = (): JSX.Element => {
+ hookResult = useCopyJobPrerequisitesCache();
+
+ const handleComplexUpdate = () => {
+ const complexCache = new Map();
+ complexCache.set("database-validation", true);
+ complexCache.set("container-validation", true);
+ complexCache.set("network-validation", false);
+ complexCache.set("authentication-validation", true);
+ complexCache.set("permission-validation", false);
+ hookResult.setValidationCache(complexCache);
+ };
+
+ return (
+
+ Set Complex Cache
+
+ );
+ };
+
+ render( );
+
+ act(() => {
+ screen.getByTestId("complex-update").click();
+ });
+
+ expect(hookResult.validationCache.size).toBe(5);
+ expect(hookResult.validationCache.get("database-validation")).toBe(true);
+ expect(hookResult.validationCache.get("container-validation")).toBe(true);
+ expect(hookResult.validationCache.get("network-validation")).toBe(false);
+ expect(hookResult.validationCache.get("authentication-validation")).toBe(true);
+ expect(hookResult.validationCache.get("permission-validation")).toBe(false);
+ });
+
+ it("should handle edge case keys", () => {
+ const EdgeCaseTestComponent = (): JSX.Element => {
+ hookResult = useCopyJobPrerequisitesCache();
+
+ const handleEdgeCaseUpdate = () => {
+ const edgeCaseCache = new Map();
+ edgeCaseCache.set("", true);
+ edgeCaseCache.set(" ", false);
+ edgeCaseCache.set("special-chars!@#$%^&*()", true);
+ edgeCaseCache.set("very-long-key-".repeat(10), false);
+ edgeCaseCache.set("unicode-key-🔑", true);
+ hookResult.setValidationCache(edgeCaseCache);
+ };
+
+ return (
+
+ Set Edge Case Cache
+
+ );
+ };
+
+ render( );
+
+ act(() => {
+ screen.getByTestId("edge-case-update").click();
+ });
+
+ expect(hookResult.validationCache.size).toBe(5);
+ expect(hookResult.validationCache.get("")).toBe(true);
+ expect(hookResult.validationCache.get(" ")).toBe(false);
+ expect(hookResult.validationCache.get("special-chars!@#$%^&*()")).toBe(true);
+ expect(hookResult.validationCache.get("very-long-key-".repeat(10))).toBe(false);
+ expect(hookResult.validationCache.get("unicode-key-🔑")).toBe(true);
+ });
+
+ it("should handle setting the same cache reference without errors", () => {
+ let testCache: Map;
+
+ const SameReferenceTestComponent = (): JSX.Element => {
+ hookResult = useCopyJobPrerequisitesCache();
+
+ const handleFirstUpdate = () => {
+ testCache = new Map();
+ testCache.set("test-key", true);
+ hookResult.setValidationCache(testCache);
+ };
+
+ const handleSecondUpdate = () => {
+ hookResult.setValidationCache(testCache);
+ };
+
+ return (
+
+
+ First Update
+
+
+ Second Update (Same Reference)
+
+ {hookResult.validationCache.get("test-key")?.toString()}
+
+ );
+ };
+
+ render( );
+ act(() => {
+ screen.getByTestId("first-update").click();
+ });
+ expect(hookResult.validationCache.get("test-key")).toBe(true);
+ expect(screen.getByTestId("cache-content")).toHaveTextContent("true");
+
+ act(() => {
+ screen.getByTestId("second-update").click();
+ });
+ expect(hookResult.validationCache).toBe(testCache);
+ expect(hookResult.validationCache.get("test-key")).toBe(true);
+ expect(screen.getByTestId("cache-content")).toHaveTextContent("true");
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.test.tsx
new file mode 100644
index 000000000..3768ef5f1
--- /dev/null
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.test.tsx
@@ -0,0 +1,477 @@
+import "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import React from "react";
+import Explorer from "../../../Explorer";
+import CopyJobContextProvider, { useCopyJobContext } from "../../Context/CopyJobContext";
+import { CopyJobMigrationType } from "../../Enums/CopyJobEnums";
+import { CopyJobContextState } from "../../Types/CopyJobTypes";
+import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList";
+
+jest.mock("../../Context/CopyJobContext", () => {
+ const actual = jest.requireActual("../../Context/CopyJobContext");
+ return {
+ __esModule: true,
+ ...actual,
+ default: actual.default,
+ useCopyJobContext: jest.fn(),
+ };
+});
+
+jest.mock("../Screens/AssignPermissions/AssignPermissions", () => {
+ const MockAssignPermissions = () => {
+ return AssignPermissions
;
+ };
+ MockAssignPermissions.displayName = "MockAssignPermissions";
+ return MockAssignPermissions;
+});
+
+jest.mock("../Screens/CreateContainer/AddCollectionPanelWrapper", () => {
+ const MockAddCollectionPanelWrapper = () => {
+ return AddCollectionPanelWrapper
;
+ };
+ MockAddCollectionPanelWrapper.displayName = "MockAddCollectionPanelWrapper";
+ return MockAddCollectionPanelWrapper;
+});
+
+jest.mock("../Screens/PreviewCopyJob/PreviewCopyJob", () => {
+ const MockPreviewCopyJob = () => {
+ return PreviewCopyJob
;
+ };
+ MockPreviewCopyJob.displayName = "MockPreviewCopyJob";
+ return MockPreviewCopyJob;
+});
+
+jest.mock("../Screens/SelectAccount/SelectAccount", () => {
+ const MockSelectAccount = () => {
+ return SelectAccount
;
+ };
+ MockSelectAccount.displayName = "MockSelectAccount";
+ return MockSelectAccount;
+});
+
+jest.mock("../Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers", () => {
+ const MockSelectSourceAndTargetContainers = () => {
+ return SelectSourceAndTargetContainers
;
+ };
+ MockSelectSourceAndTargetContainers.displayName = "MockSelectSourceAndTargetContainers";
+ return MockSelectSourceAndTargetContainers;
+});
+
+const TestHookComponent: React.FC<{ goBack: () => void }> = ({ goBack }) => {
+ const screens = useCreateCopyJobScreensList(goBack);
+
+ return (
+
+ {screens.map((screen, index) => (
+
+
{screen.key}
+
{screen.component}
+
+ {JSON.stringify(screen.validations.map((v) => v.message))}
+
+
+ ))}
+
+ );
+};
+
+describe("useCreateCopyJobScreensList", () => {
+ const mockExplorer = {} as Explorer;
+ const mockGoBack = jest.fn();
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useCopyJobContext as jest.Mock).mockReturnValue({
+ explorer: mockExplorer,
+ });
+ });
+
+ const renderWithContext = (component: React.ReactElement) => {
+ return render({component} );
+ };
+
+ describe("Hook behavior", () => {
+ it("should return screens list with correct keys and components", () => {
+ renderWithContext( );
+
+ expect(screen.getByTestId("test-hook-component")).toBeInTheDocument();
+ expect(screen.getByTestId("screen-key-0")).toHaveTextContent(SCREEN_KEYS.SelectAccount);
+ expect(screen.getByTestId("screen-key-1")).toHaveTextContent(SCREEN_KEYS.SelectSourceAndTargetContainers);
+ expect(screen.getByTestId("screen-key-2")).toHaveTextContent(SCREEN_KEYS.CreateCollection);
+ expect(screen.getByTestId("screen-key-3")).toHaveTextContent(SCREEN_KEYS.PreviewCopyJob);
+ expect(screen.getByTestId("screen-key-4")).toHaveTextContent(SCREEN_KEYS.AssignPermissions);
+
+ expect(screen.getByTestId("select-account")).toBeInTheDocument();
+ expect(screen.getByTestId("select-source-target")).toBeInTheDocument();
+ expect(screen.getByTestId("add-collection-panel")).toBeInTheDocument();
+ expect(screen.getByTestId("preview-copy-job")).toBeInTheDocument();
+ expect(screen.getByTestId("assign-permissions")).toBeInTheDocument();
+ });
+
+ it("should return exactly 5 screens in the correct order", () => {
+ renderWithContext( );
+
+ const screens = screen.getAllByTestId(/screen-\d+/);
+ expect(screens).toHaveLength(5);
+ });
+
+ it("should memoize results based on explorer dependency", () => {
+ const { rerender } = renderWithContext( );
+ const initialScreens = screen.getAllByTestId(/screen-key-\d+/).map((el) => el.textContent);
+ rerender(
+
+
+ ,
+ );
+
+ const rerenderScreens = screen.getAllByTestId(/screen-key-\d+/).map((el) => el.textContent);
+ expect(rerenderScreens).toEqual(initialScreens);
+ });
+ });
+
+ describe("Screen validations", () => {
+ describe("SelectAccount screen validation", () => {
+ it("should validate subscription and account presence", () => {
+ renderWithContext( );
+
+ const validationMessages = JSON.parse(screen.getByTestId("screen-validations-0").textContent || "[]");
+ expect(validationMessages).toContain("Please select a subscription and account to proceed");
+ });
+
+ it("should pass validation when subscription and account are present", () => {
+ const mockState: CopyJobContextState = {
+ jobName: "",
+ migrationType: CopyJobMigrationType.Offline,
+ source: {
+ subscription: { subscriptionId: "test-sub" } as any,
+ account: { name: "test-account" } as any,
+ databaseId: "",
+ containerId: "",
+ },
+ target: {
+ subscriptionId: "",
+ account: null as any,
+ databaseId: "",
+ containerId: "",
+ },
+ };
+ const ValidationTestComponent = () => {
+ const screens = useCreateCopyJobScreensList(mockGoBack);
+ const selectAccountScreen = screens.find((s) => s.key === SCREEN_KEYS.SelectAccount);
+ const isValid = selectAccountScreen?.validations[0]?.validate(mockState);
+
+ return {isValid ? "valid" : "invalid"}
;
+ };
+
+ renderWithContext( );
+ expect(screen.getByTestId("validation-result")).toHaveTextContent("valid");
+ });
+
+ it("should fail validation when subscription is missing", () => {
+ const mockState: CopyJobContextState = {
+ jobName: "",
+ migrationType: CopyJobMigrationType.Offline,
+ source: {
+ subscription: null as any,
+ account: { name: "test-account" } as any,
+ databaseId: "",
+ containerId: "",
+ },
+ target: {
+ subscriptionId: "",
+ account: null as any,
+ databaseId: "",
+ containerId: "",
+ },
+ };
+
+ const ValidationTestComponent = () => {
+ const screens = useCreateCopyJobScreensList(mockGoBack);
+ const selectAccountScreen = screens.find((s) => s.key === SCREEN_KEYS.SelectAccount);
+ const isValid = selectAccountScreen?.validations[0]?.validate(mockState);
+
+ return {isValid ? "valid" : "invalid"}
;
+ };
+
+ renderWithContext( );
+ expect(screen.getByTestId("validation-result")).toHaveTextContent("invalid");
+ });
+ });
+
+ describe("SelectSourceAndTargetContainers screen validation", () => {
+ it("should validate source and target containers", () => {
+ renderWithContext( );
+
+ const validationMessages = JSON.parse(screen.getByTestId("screen-validations-1").textContent || "[]");
+ expect(validationMessages).toContain("Please select source and target containers to proceed");
+ });
+
+ it("should pass validation when all required fields are present", () => {
+ const mockState: CopyJobContextState = {
+ jobName: "",
+ migrationType: CopyJobMigrationType.Offline,
+ source: {
+ subscription: null as any,
+ account: null as any,
+ databaseId: "source-db",
+ containerId: "source-container",
+ },
+ target: {
+ subscriptionId: "",
+ account: null as any,
+ databaseId: "target-db",
+ containerId: "target-container",
+ },
+ };
+
+ const ValidationTestComponent = () => {
+ const screens = useCreateCopyJobScreensList(mockGoBack);
+ const screen = screens.find((s) => s.key === SCREEN_KEYS.SelectSourceAndTargetContainers);
+ const isValid = screen?.validations[0]?.validate(mockState);
+
+ return {isValid ? "valid" : "invalid"}
;
+ };
+
+ renderWithContext( );
+ expect(screen.getByTestId("validation-result")).toHaveTextContent("valid");
+ });
+
+ it("should fail validation when source database is missing", () => {
+ const mockState: CopyJobContextState = {
+ jobName: "",
+ migrationType: CopyJobMigrationType.Offline,
+ source: {
+ subscription: null as any,
+ account: null as any,
+ databaseId: "",
+ containerId: "source-container",
+ },
+ target: {
+ subscriptionId: "",
+ account: null as any,
+ databaseId: "target-db",
+ containerId: "target-container",
+ },
+ };
+
+ const ValidationTestComponent = () => {
+ const screens = useCreateCopyJobScreensList(mockGoBack);
+ const screen = screens.find((s) => s.key === SCREEN_KEYS.SelectSourceAndTargetContainers);
+ const isValid = screen?.validations[0]?.validate(mockState);
+
+ return {isValid ? "valid" : "invalid"}
;
+ };
+
+ renderWithContext( );
+ expect(screen.getByTestId("validation-result")).toHaveTextContent("invalid");
+ });
+ });
+
+ describe("CreateCollection screen", () => {
+ it("should have no validations", () => {
+ renderWithContext( );
+
+ const validationMessages = JSON.parse(screen.getByTestId("screen-validations-2").textContent || "[]");
+ expect(validationMessages).toEqual([]);
+ });
+ });
+
+ describe("PreviewCopyJob screen validation", () => {
+ it("should validate job name format", () => {
+ renderWithContext( );
+
+ const validationMessages = JSON.parse(screen.getByTestId("screen-validations-3").textContent || "[]");
+ expect(validationMessages).toContain("Please enter a job name to proceed");
+ });
+
+ it("should pass validation with valid job name", () => {
+ const mockState: CopyJobContextState = {
+ jobName: "valid-job-name_123",
+ migrationType: CopyJobMigrationType.Offline,
+ source: {
+ subscription: null as any,
+ account: null as any,
+ databaseId: "",
+ containerId: "",
+ },
+ target: {
+ subscriptionId: "",
+ account: null as any,
+ databaseId: "",
+ containerId: "",
+ },
+ };
+
+ const ValidationTestComponent = () => {
+ const screens = useCreateCopyJobScreensList(mockGoBack);
+ const screen = screens.find((s) => s.key === SCREEN_KEYS.PreviewCopyJob);
+ const isValid = screen?.validations[0]?.validate(mockState);
+
+ return {isValid ? "valid" : "invalid"}
;
+ };
+
+ renderWithContext( );
+ expect(screen.getByTestId("validation-result")).toHaveTextContent("valid");
+ });
+
+ it("should fail validation with invalid job name characters", () => {
+ const mockState: CopyJobContextState = {
+ jobName: "invalid job name with spaces!",
+ migrationType: CopyJobMigrationType.Offline,
+ source: {
+ subscription: null as any,
+ account: null as any,
+ databaseId: "",
+ containerId: "",
+ },
+ target: {
+ subscriptionId: "",
+ account: null as any,
+ databaseId: "",
+ containerId: "",
+ },
+ };
+
+ const ValidationTestComponent = () => {
+ const screens = useCreateCopyJobScreensList(mockGoBack);
+ const screen = screens.find((s) => s.key === SCREEN_KEYS.PreviewCopyJob);
+ const isValid = screen?.validations[0]?.validate(mockState);
+
+ return {isValid ? "valid" : "invalid"}
;
+ };
+
+ renderWithContext( );
+ expect(screen.getByTestId("validation-result")).toHaveTextContent("invalid");
+ });
+
+ it("should fail validation with empty job name", () => {
+ const mockState: CopyJobContextState = {
+ jobName: "",
+ migrationType: CopyJobMigrationType.Offline,
+ source: {
+ subscription: null as any,
+ account: null as any,
+ databaseId: "",
+ containerId: "",
+ },
+ target: {
+ subscriptionId: "",
+ account: null as any,
+ databaseId: "",
+ containerId: "",
+ },
+ };
+
+ const ValidationTestComponent = () => {
+ const screens = useCreateCopyJobScreensList(mockGoBack);
+ const screen = screens.find((s) => s.key === SCREEN_KEYS.PreviewCopyJob);
+ const isValid = screen?.validations[0]?.validate(mockState);
+
+ return {isValid ? "valid" : "invalid"}
;
+ };
+
+ renderWithContext( );
+ expect(screen.getByTestId("validation-result")).toHaveTextContent("invalid");
+ });
+ });
+
+ describe("AssignPermissions screen validation", () => {
+ it("should validate cache values", () => {
+ renderWithContext( );
+
+ const validationMessages = JSON.parse(screen.getByTestId("screen-validations-4").textContent || "[]");
+ expect(validationMessages).toContain("Please ensure all previous steps are valid to proceed");
+ });
+
+ it("should pass validation when all cache values are true", () => {
+ const mockCache = new Map([
+ ["step1", true],
+ ["step2", true],
+ ["step3", true],
+ ]);
+
+ const ValidationTestComponent = () => {
+ const screens = useCreateCopyJobScreensList(mockGoBack);
+ const screen = screens.find((s) => s.key === SCREEN_KEYS.AssignPermissions);
+ const isValid = screen?.validations[0]?.validate(mockCache);
+
+ return {isValid ? "valid" : "invalid"}
;
+ };
+
+ renderWithContext( );
+ expect(screen.getByTestId("validation-result")).toHaveTextContent("valid");
+ });
+
+ it("should fail validation when cache is empty", () => {
+ const mockCache = new Map();
+
+ const ValidationTestComponent = () => {
+ const screens = useCreateCopyJobScreensList(mockGoBack);
+ const screen = screens.find((s) => s.key === SCREEN_KEYS.AssignPermissions);
+ const isValid = screen?.validations[0]?.validate(mockCache);
+
+ return {isValid ? "valid" : "invalid"}
;
+ };
+
+ renderWithContext( );
+ expect(screen.getByTestId("validation-result")).toHaveTextContent("invalid");
+ });
+
+ it("should fail validation when any cache value is false", () => {
+ const mockCache = new Map([
+ ["step1", true],
+ ["step2", false],
+ ["step3", true],
+ ]);
+
+ const ValidationTestComponent = () => {
+ const screens = useCreateCopyJobScreensList(mockGoBack);
+ const screen = screens.find((s) => s.key === SCREEN_KEYS.AssignPermissions);
+ const isValid = screen?.validations[0]?.validate(mockCache);
+
+ return {isValid ? "valid" : "invalid"}
;
+ };
+
+ renderWithContext( );
+ expect(screen.getByTestId("validation-result")).toHaveTextContent("invalid");
+ });
+ });
+ });
+
+ describe("SCREEN_KEYS constant", () => {
+ it("should export correct screen keys", () => {
+ expect(SCREEN_KEYS.CreateCollection).toBe("CreateCollection");
+ expect(SCREEN_KEYS.SelectAccount).toBe("SelectAccount");
+ expect(SCREEN_KEYS.SelectSourceAndTargetContainers).toBe("SelectSourceAndTargetContainers");
+ expect(SCREEN_KEYS.PreviewCopyJob).toBe("PreviewCopyJob");
+ expect(SCREEN_KEYS.AssignPermissions).toBe("AssignPermissions");
+ });
+ });
+
+ describe("Component props", () => {
+ it("should pass explorer to AddCollectionPanelWrapper", () => {
+ renderWithContext( );
+ expect(screen.getByTestId("add-collection-panel")).toBeInTheDocument();
+ });
+
+ it("should pass goBack function to AddCollectionPanelWrapper", () => {
+ renderWithContext( );
+ expect(screen.getByTestId("add-collection-panel")).toBeInTheDocument();
+ });
+ });
+
+ describe("Error handling", () => {
+ it("should handle context provider error gracefully", () => {
+ const consoleError = jest.spyOn(console, "error").mockImplementation(() => {});
+
+ (useCopyJobContext as jest.Mock).mockImplementation(() => {
+ throw new Error("Context not found");
+ });
+
+ expect(() => {
+ render( );
+ }).toThrow("Context not found");
+
+ consoleError.mockRestore();
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx
index acb17f602..0b5283558 100644
--- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx
+++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx
@@ -8,11 +8,11 @@ import SelectAccount from "../Screens/SelectAccount/SelectAccount";
import SelectSourceAndTargetContainers from "../Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers";
const SCREEN_KEYS = {
- CreateCollection: "CreateCollection",
SelectAccount: "SelectAccount",
- SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers",
- PreviewCopyJob: "PreviewCopyJob",
AssignPermissions: "AssignPermissions",
+ SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers",
+ CreateCollection: "CreateCollection",
+ PreviewCopyJob: "PreviewCopyJob",
};
type Validation = {
diff --git a/src/Explorer/ContainerCopy/Enums/CopyJobEnums.ts b/src/Explorer/ContainerCopy/Enums/CopyJobEnums.ts
index 9be43bcc8..10548f05f 100644
--- a/src/Explorer/ContainerCopy/Enums/CopyJobEnums.ts
+++ b/src/Explorer/ContainerCopy/Enums/CopyJobEnums.ts
@@ -11,6 +11,7 @@ export enum IdentityType {
export enum DefaultIdentityType {
SystemAssignedIdentity = "systemassignedidentity",
+ FirstPartyIdentity = "FirstPartyIdentity",
}
export enum BackupPolicyType {
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx
new file mode 100644
index 000000000..f8cad1cd5
--- /dev/null
+++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx
@@ -0,0 +1,611 @@
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen } from "@testing-library/react";
+import React from "react";
+import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums/CopyJobEnums";
+import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
+import CopyJobActionMenu from "./CopyJobActionMenu";
+
+jest.mock("../../ContainerCopyMessages", () => ({
+ __esModule: true,
+ default: {
+ MonitorJobs: {
+ Columns: {
+ actions: "Actions",
+ },
+ Actions: {
+ pause: "Pause",
+ resume: "Resume",
+ cancel: "Cancel",
+ complete: "Complete",
+ },
+ },
+ },
+}));
+
+describe("CopyJobActionMenu", () => {
+ const createMockJob = (overrides: Partial = {}): CopyJobType =>
+ ({
+ ID: "test-job-id",
+ Mode: CopyJobMigrationType.Offline,
+ Name: "Test Job",
+ Status: CopyJobStatusType.InProgress,
+ CompletionPercentage: 50,
+ Duration: "00:10:30",
+ LastUpdatedTime: "2025-01-01T10:00:00Z",
+ timestamp: Date.now(),
+ Source: {
+ databaseName: "sourceDb",
+ collectionName: "sourceContainer",
+ component: "source",
+ },
+ Destination: {
+ databaseName: "targetDb",
+ collectionName: "targetContainer",
+ component: "destination",
+ },
+ ...overrides,
+ }) as CopyJobType;
+
+ const mockHandleClick: HandleJobActionClickType = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("Component Rendering", () => {
+ it("should render the action menu button for active jobs", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.InProgress });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ expect(actionButton).toBeInTheDocument();
+ expect(actionButton).toHaveAttribute("aria-label", "Actions");
+ expect(actionButton).toHaveAttribute("title", "Actions");
+ });
+
+ it("should not render anything for completed jobs", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.Completed });
+
+ const { container } = render( );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("should not render anything for cancelled jobs", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.Cancelled });
+
+ const { container } = render( );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("should not render anything for failed jobs", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.Failed });
+
+ const { container } = render( );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("should not render anything for faulted jobs", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.Faulted });
+
+ const { container } = render( );
+
+ expect(container.firstChild).toBeNull();
+ });
+ });
+
+ describe("Menu Items for Different Job Statuses", () => {
+ it("should show pause and cancel actions for InProgress jobs", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.InProgress });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ expect(screen.getByText("Pause")).toBeInTheDocument();
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ expect(screen.queryByText("Resume")).not.toBeInTheDocument();
+ });
+
+ it("should show resume and cancel actions for Paused jobs", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.Paused });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ expect(screen.getByText("Resume")).toBeInTheDocument();
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ expect(screen.queryByText("Pause")).not.toBeInTheDocument();
+ });
+
+ it("should show pause and cancel actions for Pending jobs", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.Pending });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ expect(screen.getByText("Pause")).toBeInTheDocument();
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ expect(screen.queryByText("Resume")).not.toBeInTheDocument();
+ });
+
+ it("should show only resume action for Skipped jobs", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.Skipped });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ expect(screen.getByText("Resume")).toBeInTheDocument();
+ expect(screen.queryByText("Pause")).not.toBeInTheDocument();
+ expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
+ });
+
+ it("should show pause and cancel actions for Running jobs", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.Running });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ expect(screen.getByText("Pause")).toBeInTheDocument();
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ expect(screen.queryByText("Resume")).not.toBeInTheDocument();
+ });
+
+ it("should show pause and cancel actions for Partitioning jobs", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.Partitioning });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ expect(screen.getByText("Pause")).toBeInTheDocument();
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ expect(screen.queryByText("Resume")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Online Mode Complete Action", () => {
+ it("should show complete action for online InProgress jobs", () => {
+ const job = createMockJob({
+ Status: CopyJobStatusType.InProgress,
+ Mode: CopyJobMigrationType.Online,
+ });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ expect(screen.getByText("Complete")).toBeInTheDocument();
+ expect(screen.getByText("Pause")).toBeInTheDocument();
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ });
+
+ it("should show complete action for online Running jobs", () => {
+ const job = createMockJob({
+ Status: CopyJobStatusType.Running,
+ Mode: CopyJobMigrationType.Online,
+ });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ expect(screen.getByText("Complete")).toBeInTheDocument();
+ });
+
+ it("should show complete action for online Partitioning jobs", () => {
+ const job = createMockJob({
+ Status: CopyJobStatusType.Partitioning,
+ Mode: CopyJobMigrationType.Online,
+ });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ expect(screen.getByText("Complete")).toBeInTheDocument();
+ });
+
+ it("should not show complete action for offline jobs", () => {
+ const job = createMockJob({
+ Status: CopyJobStatusType.InProgress,
+ Mode: CopyJobMigrationType.Offline,
+ });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ expect(screen.queryByText("Complete")).not.toBeInTheDocument();
+ });
+
+ it("should handle case-insensitive online mode detection", () => {
+ const job = createMockJob({
+ Status: CopyJobStatusType.InProgress,
+ Mode: "ONLINE",
+ });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ expect(screen.getByText("Complete")).toBeInTheDocument();
+ });
+ });
+
+ describe("Action Click Handling", () => {
+ it("should call handleClick when pause action is clicked", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.InProgress });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ const pauseButton = screen.getByText("Pause");
+ fireEvent.click(pauseButton);
+
+ expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.pause, expect.any(Function));
+ });
+
+ it("should call handleClick when cancel action is clicked", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.InProgress });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ const cancelButton = screen.getByText("Cancel");
+ fireEvent.click(cancelButton);
+
+ expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.cancel, expect.any(Function));
+ });
+
+ it("should call handleClick when resume action is clicked", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.Paused });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ const resumeButton = screen.getByText("Resume");
+ fireEvent.click(resumeButton);
+
+ expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.resume, expect.any(Function));
+ });
+
+ it("should call handleClick when complete action is clicked", () => {
+ const job = createMockJob({
+ Status: CopyJobStatusType.InProgress,
+ Mode: CopyJobMigrationType.Online,
+ });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ const completeButton = screen.getByText("Complete");
+ fireEvent.click(completeButton);
+
+ expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.complete, expect.any(Function));
+ });
+ });
+
+ describe("Disabled States During Updates", () => {
+ const TestComponentWrapper: React.FC<{
+ job: CopyJobType;
+ initialUpdatingState?: { jobName: string; action: string } | null;
+ }> = ({ job, initialUpdatingState = null }) => {
+ const stateUpdater = React.useState(initialUpdatingState);
+ const setUpdatingJobAction = stateUpdater[1];
+
+ const testHandleClick: HandleJobActionClickType = (job, action, setUpdatingJobActionCallback) => {
+ setUpdatingJobActionCallback({ jobName: job.Name, action });
+ setUpdatingJobAction({ jobName: job.Name, action });
+ };
+
+ return ;
+ };
+
+ it("should disable pause action when job is being paused", async () => {
+ const job = createMockJob({ Status: CopyJobStatusType.InProgress });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ const pauseButton = screen.getByText("Pause");
+ fireEvent.click(pauseButton);
+ fireEvent.click(actionButton);
+ const pauseButtonAfterClick = screen.getByText("Pause");
+ expect(pauseButtonAfterClick).toBeInTheDocument();
+ });
+
+ it("should not disable actions for different jobs when one is updating", () => {
+ const job1 = createMockJob({ Name: "Job1", Status: CopyJobStatusType.InProgress });
+ const job2 = createMockJob({ Name: "Job2", Status: CopyJobStatusType.InProgress });
+
+ const { rerender } = render( );
+ let actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+ fireEvent.click(screen.getByText("Pause"));
+ rerender( );
+
+ actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ expect(screen.getByText("Pause")).toBeInTheDocument();
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ });
+
+ it("should properly handle multiple action types being disabled for the same job", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.InProgress });
+ render( );
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+
+ fireEvent.click(actionButton);
+ fireEvent.click(screen.getByText("Pause"));
+
+ fireEvent.click(actionButton);
+ fireEvent.click(screen.getByText("Cancel"));
+
+ fireEvent.click(actionButton);
+ expect(screen.getByText("Pause")).toBeInTheDocument();
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ });
+
+ it("should handle complete action disabled state for online jobs", () => {
+ const job = createMockJob({
+ Status: CopyJobStatusType.InProgress,
+ Mode: CopyJobMigrationType.Online,
+ });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ const completeButton = screen.getByText("Complete");
+ fireEvent.click(completeButton);
+
+ fireEvent.click(actionButton);
+ expect(screen.getByText("Complete")).toBeInTheDocument();
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("should handle undefined mode gracefully", () => {
+ const job = createMockJob({
+ Status: CopyJobStatusType.InProgress,
+ Mode: undefined as any,
+ });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ expect(screen.queryByText("Complete")).not.toBeInTheDocument();
+ expect(screen.getByText("Pause")).toBeInTheDocument();
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ });
+
+ it("should handle null mode gracefully", () => {
+ const job = createMockJob({
+ Status: CopyJobStatusType.InProgress,
+ Mode: null as any,
+ });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ expect(screen.queryByText("Complete")).not.toBeInTheDocument();
+ });
+
+ it("should handle empty string mode gracefully", () => {
+ const job = createMockJob({
+ Status: CopyJobStatusType.InProgress,
+ Mode: "",
+ });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ expect(screen.queryByText("Complete")).not.toBeInTheDocument();
+ });
+
+ it("should return all base items for unknown status", () => {
+ const job = createMockJob({ Status: "UnknownStatus" as any });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ expect(screen.getByText("Pause")).toBeInTheDocument();
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ expect(screen.getByText("Resume")).toBeInTheDocument();
+ });
+ });
+
+ describe("Icon and Accessibility", () => {
+ it("should have correct icon and accessibility attributes", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.InProgress });
+ render( );
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+
+ expect(actionButton).toHaveAttribute("aria-label", "Actions");
+ expect(actionButton).toHaveAttribute("title", "Actions");
+
+ const moreIcon = actionButton.querySelector('[data-icon-name="More"]');
+ expect(moreIcon || actionButton).toBeInTheDocument();
+ });
+
+ it("should have correct menu item icons", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.InProgress });
+ render( );
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ expect(screen.getByText("Pause")).toBeInTheDocument();
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ });
+ });
+
+ describe("Component State Management", () => {
+ it("should manage updating job action state correctly", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.InProgress });
+ const mockHandleClickWithState: HandleJobActionClickType = jest.fn((job, action, setUpdatingJobAction) => {
+ setUpdatingJobAction({ jobName: job.Name, action });
+ });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ const pauseButton = screen.getByText("Pause");
+ fireEvent.click(pauseButton);
+
+ expect(mockHandleClickWithState).toHaveBeenCalledWith(job, CopyJobActions.pause, expect.any(Function));
+ });
+
+ it("should handle rapid successive clicks properly", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.InProgress });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+ const pauseButton = screen.getByText("Pause");
+ fireEvent.click(pauseButton);
+
+ fireEvent.click(actionButton);
+ const pauseButton2 = screen.getByText("Pause");
+ fireEvent.click(pauseButton2);
+
+ fireEvent.click(actionButton);
+ const pauseButton3 = screen.getByText("Pause");
+ fireEvent.click(pauseButton3);
+
+ expect(mockHandleClick).toHaveBeenCalledTimes(3);
+ });
+ });
+
+ describe("Integration Tests", () => {
+ it("should work correctly with different job names", () => {
+ const jobWithLongName = createMockJob({
+ Name: "Very Long Job Name That Might Cause UI Issues",
+ Status: CopyJobStatusType.InProgress,
+ });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ const pauseButton = screen.getByText("Pause");
+ fireEvent.click(pauseButton);
+
+ expect(mockHandleClick).toHaveBeenCalledWith(jobWithLongName, CopyJobActions.pause, expect.any(Function));
+ });
+
+ it("should handle special characters in job names", () => {
+ const jobWithSpecialChars = createMockJob({
+ Name: "Job-Name_With$pecial#Characters!@",
+ Status: CopyJobStatusType.Paused,
+ });
+
+ render( );
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ const resumeButton = screen.getByText("Resume");
+ fireEvent.click(resumeButton);
+
+ expect(mockHandleClick).toHaveBeenCalledWith(jobWithSpecialChars, CopyJobActions.resume, expect.any(Function));
+ });
+
+ it("should maintain consistent behavior across re-renders", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.InProgress });
+ const { rerender } = render( );
+
+ let actionButton = screen.getByRole("button", { name: "Actions" });
+ expect(actionButton).toBeInTheDocument();
+
+ rerender( );
+
+ actionButton = screen.getByRole("button", { name: "Actions" });
+ expect(actionButton).toBeInTheDocument();
+
+ fireEvent.click(actionButton);
+ expect(screen.getByText("Pause")).toBeInTheDocument();
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ });
+
+ it("should handle prop changes correctly", () => {
+ const job1 = createMockJob({ Status: CopyJobStatusType.InProgress });
+ const job2 = createMockJob({ Status: CopyJobStatusType.Paused });
+
+ const { rerender } = render( );
+
+ let actionButton = screen.getByRole("button", { name: "Actions" });
+ expect(actionButton).toBeInTheDocument();
+
+ rerender( );
+
+ actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ expect(screen.getByText("Resume")).toBeInTheDocument();
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ expect(screen.queryByText("Pause")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Performance and Memory", () => {
+ it("should not create memory leaks with multiple renders", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.InProgress });
+ const { unmount } = render( );
+ expect(() => unmount()).not.toThrow();
+ });
+
+ it("should handle null/undefined props gracefully", () => {
+ const incompleteJob = {
+ ...createMockJob({ Status: CopyJobStatusType.InProgress }),
+ Name: undefined as any,
+ };
+
+ expect(() => {
+ render( );
+ }).not.toThrow();
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx
index b43307be9..5d41b8595 100644
--- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx
+++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx
@@ -83,6 +83,7 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick
return (
{
+ const MockCopyJobActionMenu = ({ job }: { job: CopyJobType }) => {
+ return Action Menu
;
+ };
+ MockCopyJobActionMenu.displayName = "MockCopyJobActionMenu";
+ return MockCopyJobActionMenu;
+});
+
+jest.mock("./CopyJobStatusWithIcon", () => {
+ const MockCopyJobStatusWithIcon = ({ status }: { status: CopyJobStatusType }) => {
+ return Status: {status}
;
+ };
+ MockCopyJobStatusWithIcon.displayName = "MockCopyJobStatusWithIcon";
+ return MockCopyJobStatusWithIcon;
+});
+
+describe("CopyJobColumns", () => {
+ type OnColumnClickType = IColumn & { onColumnClick: () => void };
+ const mockHandleSort = jest.fn();
+ const mockHandleActionClick: HandleJobActionClickType = jest.fn();
+
+ const mockJob = {
+ ID: "test-job-id",
+ Mode: "Online",
+ Name: "Test Job Name",
+ Status: CopyJobStatusType.InProgress,
+ CompletionPercentage: 75,
+ Duration: "00:05:30",
+ LastUpdatedTime: "2024-12-01T10:30:00Z",
+ timestamp: 1701426600000,
+ Source: {
+ databaseName: "test-source-db",
+ containerName: "test-source-container",
+ component: "CosmosDBSql",
+ },
+ Destination: {
+ databaseName: "test-dest-db",
+ containerName: "test-dest-container",
+ component: "CosmosDBSql",
+ },
+ } as CopyJobType;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("getColumns", () => {
+ it("should return an array of IColumn objects", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false);
+
+ expect(columns).toBeDefined();
+ expect(Array.isArray(columns)).toBe(true);
+ expect(columns.length).toBe(6);
+
+ columns.forEach((column: IColumn) => {
+ expect(column).toHaveProperty("key");
+ expect(column).toHaveProperty("name");
+ expect(column).toHaveProperty("minWidth");
+ expect(column).toHaveProperty("maxWidth");
+ expect(column).toHaveProperty("isResizable");
+ });
+ });
+
+ it("should have correct column keys", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false);
+
+ const expectedKeys = ["LastUpdatedTime", "Name", "Mode", "CompletionPercentage", "CopyJobStatus", "Actions"];
+ const actualKeys = columns.map((column) => column.key);
+
+ expect(actualKeys).toEqual(expectedKeys);
+ });
+
+ it("should have correct column names from ContainerCopyMessages", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false);
+
+ expect(columns[0].name).toBe(ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime);
+ expect(columns[1].name).toBe(ContainerCopyMessages.MonitorJobs.Columns.name);
+ expect(columns[2].name).toBe(ContainerCopyMessages.MonitorJobs.Columns.mode);
+ expect(columns[3].name).toBe(ContainerCopyMessages.MonitorJobs.Columns.completionPercentage);
+ expect(columns[4].name).toBe(ContainerCopyMessages.MonitorJobs.Columns.status);
+ expect(columns[5].name).toBe("");
+ });
+
+ it("should configure sortable columns correctly when no sort is applied", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false);
+
+ expect(columns[0].isSorted).toBe(false); // LastUpdatedTime
+ expect(columns[1].isSorted).toBe(false); // Name
+ expect(columns[2].isSorted).toBe(false); // Mode
+ expect(columns[3].isSorted).toBe(false); // CompletionPercentage
+ expect(columns[4].isSorted).toBe(false); // CopyJobStatus
+ });
+
+ it("should configure sorted column correctly when sort is applied", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, "Name", true);
+
+ expect(columns[1].isSorted).toBe(true);
+ expect(columns[1].isSortedDescending).toBe(true);
+
+ expect(columns[0].isSorted).toBe(false);
+ expect(columns[2].isSorted).toBe(false);
+ expect(columns[3].isSorted).toBe(false);
+ expect(columns[4].isSorted).toBe(false);
+ });
+
+ it("should handle timestamp sorting for LastUpdatedTime column", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, "timestamp", false);
+
+ expect(columns[0].isSorted).toBe(true);
+ expect(columns[0].isSortedDescending).toBe(false);
+ });
+
+ it("should call handleSort with correct column keys when column headers are clicked", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false);
+
+ (columns[0] as OnColumnClickType).onColumnClick?.();
+ expect(mockHandleSort).toHaveBeenCalledWith("timestamp");
+
+ (columns[1] as OnColumnClickType).onColumnClick();
+ expect(mockHandleSort).toHaveBeenCalledWith("Name");
+
+ (columns[2] as OnColumnClickType).onColumnClick();
+ expect(mockHandleSort).toHaveBeenCalledWith("Mode");
+
+ (columns[3] as OnColumnClickType).onColumnClick();
+ expect(mockHandleSort).toHaveBeenCalledWith("CompletionPercentage");
+
+ (columns[4] as OnColumnClickType).onColumnClick();
+ expect(mockHandleSort).toHaveBeenCalledWith("Status");
+
+ expect(mockHandleSort).toHaveBeenCalledTimes(5);
+ });
+
+ it("should have correct column widths and resizability", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false);
+
+ expect(columns[0].minWidth).toBe(140); // LastUpdatedTime
+ expect(columns[0].maxWidth).toBe(300);
+ expect(columns[0].isResizable).toBe(true);
+
+ expect(columns[1].minWidth).toBe(140); // Name
+ expect(columns[1].maxWidth).toBe(300);
+ expect(columns[1].isResizable).toBe(true);
+
+ expect(columns[2].minWidth).toBe(90); // Mode
+ expect(columns[2].maxWidth).toBe(200);
+ expect(columns[2].isResizable).toBe(true);
+
+ expect(columns[3].minWidth).toBe(110); // CompletionPercentage
+ expect(columns[3].maxWidth).toBe(200);
+ expect(columns[3].isResizable).toBe(true);
+
+ expect(columns[4].minWidth).toBe(130); // CopyJobStatus
+ expect(columns[4].maxWidth).toBe(200);
+ expect(columns[4].isResizable).toBe(true);
+
+ expect(columns[5].minWidth).toBe(80); // Actions
+ expect(columns[5].maxWidth).toBe(200);
+ expect(columns[5].isResizable).toBe(true);
+ });
+ });
+
+ describe("Column Render Functions", () => {
+ let columns: IColumn[];
+
+ beforeEach(() => {
+ columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false);
+ });
+
+ describe("Name column render function", () => {
+ it("should render job name with correct styling", () => {
+ const nameColumn = columns.find((col) => col.key === "Name");
+ expect(nameColumn?.onRender).toBeDefined();
+
+ const rendered = nameColumn?.onRender?.(mockJob);
+ const { container } = render({rendered}
);
+
+ const jobNameElement = container.querySelector(".jobNameLink");
+ expect(jobNameElement).toBeInTheDocument();
+ expect(jobNameElement).toHaveTextContent("Test Job Name");
+ });
+
+ it("should handle empty job name", () => {
+ const nameColumn = columns.find((col) => col.key === "Name");
+ const jobWithEmptyName = { ...mockJob, Name: "" };
+
+ const rendered = nameColumn?.onRender?.(jobWithEmptyName);
+ const { container } = render({rendered}
);
+
+ const jobNameElement = container.querySelector(".jobNameLink");
+ expect(jobNameElement).toBeInTheDocument();
+ expect(jobNameElement).toHaveTextContent("");
+ });
+
+ it("should handle special characters in job name", () => {
+ const nameColumn = columns.find((col) => col.key === "Name");
+ const jobWithSpecialName = { ...mockJob, Name: "Test & 'Name' \"With\" Special Characters" };
+
+ const rendered = nameColumn?.onRender?.(jobWithSpecialName);
+ const { container } = render({rendered}
);
+
+ const jobNameElement = container.querySelector(".jobNameLink");
+ expect(jobNameElement).toBeInTheDocument();
+ expect(jobNameElement).toHaveTextContent("Test & 'Name' \"With\" Special Characters");
+ });
+ });
+
+ describe("CompletionPercentage column render function", () => {
+ it("should render completion percentage with % symbol", () => {
+ const completionColumn = columns.find((col) => col.key === "CompletionPercentage");
+ expect(completionColumn?.onRender).toBeDefined();
+
+ const result = completionColumn?.onRender?.(mockJob);
+ expect(result).toBe("75%");
+ });
+
+ it("should handle 0% completion", () => {
+ const completionColumn = columns.find((col) => col.key === "CompletionPercentage");
+ const jobWithZeroCompletion = { ...mockJob, CompletionPercentage: 0 };
+
+ const result = completionColumn?.onRender?.(jobWithZeroCompletion);
+ expect(result).toBe("0%");
+ });
+
+ it("should handle 100% completion", () => {
+ const completionColumn = columns.find((col) => col.key === "CompletionPercentage");
+ const jobWithFullCompletion = { ...mockJob, CompletionPercentage: 100 };
+
+ const result = completionColumn?.onRender?.(jobWithFullCompletion);
+ expect(result).toBe("100%");
+ });
+
+ it("should handle decimal completion percentages", () => {
+ const completionColumn = columns.find((col) => col.key === "CompletionPercentage");
+ const jobWithDecimalCompletion = { ...mockJob, CompletionPercentage: 75.5 };
+
+ const result = completionColumn?.onRender?.(jobWithDecimalCompletion);
+ expect(result).toBe("75.5%");
+ });
+
+ it("should handle negative completion percentages", () => {
+ const completionColumn = columns.find((col) => col.key === "CompletionPercentage");
+ const jobWithNegativeCompletion = { ...mockJob, CompletionPercentage: -5 };
+
+ const result = completionColumn?.onRender?.(jobWithNegativeCompletion);
+ expect(result).toBe("-5%");
+ });
+ });
+
+ describe("CopyJobStatus column render function", () => {
+ it("should render CopyJobStatusWithIcon component", () => {
+ const statusColumn = columns.find((col) => col.key === "CopyJobStatus");
+ expect(statusColumn?.onRender).toBeDefined();
+
+ const rendered = statusColumn?.onRender?.(mockJob);
+ const { container } = render({rendered}
);
+
+ const statusIcon = container.querySelector(`[data-testid="status-icon-${mockJob.Status}"]`);
+ expect(statusIcon).toBeInTheDocument();
+ expect(statusIcon).toHaveTextContent(`Status: ${mockJob.Status}`);
+ });
+
+ it("should handle different job statuses", () => {
+ const statusColumn = columns.find((col) => col.key === "CopyJobStatus");
+
+ Object.values(CopyJobStatusType).forEach((status) => {
+ const jobWithStatus = { ...mockJob, Status: status };
+ const rendered = statusColumn?.onRender?.(jobWithStatus);
+ const { container } = render({rendered}
);
+
+ const statusIcon = container.querySelector(`[data-testid="status-icon-${status}"]`);
+ expect(statusIcon).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe("Actions column render function", () => {
+ it("should render CopyJobActionMenu component", () => {
+ const actionsColumn = columns.find((col) => col.key === "Actions");
+ expect(actionsColumn?.onRender).toBeDefined();
+
+ const rendered = actionsColumn?.onRender?.(mockJob);
+ const { container } = render({rendered}
);
+
+ const actionMenu = container.querySelector(`[data-testid="action-menu-${mockJob.Name}"]`);
+ expect(actionMenu).toBeInTheDocument();
+ expect(actionMenu).toHaveTextContent("Action Menu");
+ });
+
+ it("should pass correct props to CopyJobActionMenu", () => {
+ const actionsColumn = columns.find((col) => col.key === "Actions");
+ const rendered = actionsColumn?.onRender?.(mockJob);
+
+ expect(rendered).toBeDefined();
+ expect(React.isValidElement(rendered)).toBe(true);
+ });
+ });
+ });
+
+ describe("Column Field Names", () => {
+ it("should have correct fieldName properties", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false);
+
+ expect(columns[0].fieldName).toBe("LastUpdatedTime");
+ expect(columns[1].fieldName).toBe("Name");
+ expect(columns[2].fieldName).toBe("Mode");
+ expect(columns[3].fieldName).toBe("CompletionPercentage");
+ expect(columns[4].fieldName).toBe("Status");
+ expect(columns[5].fieldName).toBeUndefined(); // Actions column doesn't have fieldName
+ });
+ });
+
+ describe("Different Sort Configurations", () => {
+ it("should handle ascending sort", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, "Name", false);
+
+ const nameColumn = columns.find((col) => col.key === "Name");
+ expect(nameColumn?.isSorted).toBe(true);
+ expect(nameColumn?.isSortedDescending).toBe(false);
+ });
+
+ it("should handle descending sort", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, "Mode", true);
+
+ const modeColumn = columns.find((col) => col.key === "Mode");
+ expect(modeColumn?.isSorted).toBe(true);
+ expect(modeColumn?.isSortedDescending).toBe(true);
+ });
+
+ it("should handle sort on CompletionPercentage column", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, "CompletionPercentage", false);
+
+ const completionColumn = columns.find((col) => col.key === "CompletionPercentage");
+ expect(completionColumn?.isSorted).toBe(true);
+ expect(completionColumn?.isSortedDescending).toBe(false);
+
+ const nameColumn = columns.find((col) => col.key === "Name");
+ expect(nameColumn?.isSorted).toBe(false);
+ });
+
+ it("should handle sort on Status column", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, "Status", true);
+
+ const statusColumn = columns.find((col) => col.key === "CopyJobStatus");
+ expect(statusColumn?.isSorted).toBe(true);
+ expect(statusColumn?.isSortedDescending).toBe(true);
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("should handle undefined sortedColumnKey", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false);
+
+ const sortableColumns = columns.filter((col) => col.key !== "Actions");
+ sortableColumns.forEach((column) => {
+ expect(column.isSorted).toBe(false);
+ });
+ });
+
+ it("should handle null job object in render functions gracefully", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false);
+
+ const nameColumn = columns.find((col) => col.key === "Name");
+ expect(() => {
+ nameColumn?.onRender?.(null as any);
+ }).toThrow();
+
+ const completionColumn = columns.find((col) => col.key === "CompletionPercentage");
+ expect(() => {
+ completionColumn?.onRender?.(null as any);
+ }).toThrow();
+ });
+
+ it("should handle job object with missing properties", () => {
+ const incompleteJob = {
+ Name: "Incomplete Job",
+ } as CopyJobType;
+
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false);
+
+ const nameColumn = columns.find((col) => col.key === "Name");
+ const rendered = nameColumn?.onRender?.(incompleteJob);
+ const { container } = render({rendered}
);
+
+ const jobNameElement = container.querySelector(".jobNameLink");
+ expect(jobNameElement).toHaveTextContent("Incomplete Job");
+ });
+
+ it("should handle unknown sortedColumnKey", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, "UnknownColumn", false);
+
+ const sortableColumns = columns.filter((col) => col.key !== "Actions");
+ sortableColumns.forEach((column) => {
+ expect(column.isSorted).toBe(false);
+ });
+ });
+ });
+
+ describe("Accessibility", () => {
+ it("should have Actions column without name for accessibility", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false);
+
+ const actionsColumn = columns.find((col) => col.key === "Actions");
+ expect(actionsColumn?.name).toBe("");
+ });
+
+ it("should maintain column structure for screen readers", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false);
+
+ const columnsWithNames = columns.filter((col) => col.key !== "Actions");
+ columnsWithNames.forEach((column) => {
+ expect(column.name).toBeTruthy();
+ expect(typeof column.name).toBe("string");
+ expect(column.name.length).toBeGreaterThan(0);
+ });
+ });
+ });
+
+ describe("Function References", () => {
+ it("should maintain function reference stability", () => {
+ const columns1 = getColumns(mockHandleSort, mockHandleActionClick, undefined, false);
+ const columns2 = getColumns(mockHandleSort, mockHandleActionClick, undefined, false);
+
+ (columns1[0] as OnColumnClickType).onColumnClick?.();
+ (columns2[0] as OnColumnClickType).onColumnClick?.();
+
+ expect(mockHandleSort).toHaveBeenCalledTimes(2);
+ expect(mockHandleSort).toHaveBeenNthCalledWith(1, "timestamp");
+ expect(mockHandleSort).toHaveBeenNthCalledWith(2, "timestamp");
+ });
+
+ it("should call handleActionClick when action menu is rendered", () => {
+ const columns = getColumns(mockHandleSort, mockHandleActionClick, undefined, false);
+ const actionsColumn = columns.find((col) => col.key === "Actions");
+
+ const rendered = actionsColumn?.onRender?.(mockJob);
+ expect(React.isValidElement(rendered)).toBe(true);
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.test.tsx
new file mode 100644
index 000000000..1aa57ebca
--- /dev/null
+++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.test.tsx
@@ -0,0 +1,383 @@
+import "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import React from "react";
+import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
+import { CopyJobType } from "../../Types/CopyJobTypes";
+import CopyJobDetails from "./CopyJobDetails";
+
+jest.mock("./CopyJobStatusWithIcon", () => {
+ const MockCopyJobStatusWithIcon = ({ status }: { status: CopyJobStatusType }) => {
+ return {status} ;
+ };
+ MockCopyJobStatusWithIcon.displayName = "MockCopyJobStatusWithIcon";
+ return MockCopyJobStatusWithIcon;
+});
+
+jest.mock("../../ContainerCopyMessages", () => ({
+ errorTitle: "Error Details",
+ sourceDatabaseLabel: "Source Database",
+ sourceContainerLabel: "Source Container",
+ targetDatabaseLabel: "Destination Database",
+ targetContainerLabel: "Destination Container",
+ sourceAccountLabel: "Source Account",
+ MonitorJobs: {
+ Columns: {
+ lastUpdatedTime: "Date & time",
+ status: "Status",
+ mode: "Mode",
+ },
+ },
+}));
+
+describe("CopyJobDetails", () => {
+ const mockBasicJob: CopyJobType = {
+ ID: "test-job-1",
+ Mode: "Offline",
+ Name: "test-job-1",
+ Status: CopyJobStatusType.InProgress,
+ CompletionPercentage: 50,
+ Duration: "10 minutes",
+ LastUpdatedTime: "2024-01-01T10:00:00Z",
+ timestamp: 1704110400000,
+ Source: {
+ component: "CosmosDBSql",
+ databaseName: "sourceDb",
+ containerName: "sourceContainer",
+ remoteAccountName: "sourceAccount",
+ },
+ Destination: {
+ component: "CosmosDBSql",
+ databaseName: "targetDb",
+ containerName: "targetContainer",
+ remoteAccountName: "targetAccount",
+ },
+ };
+
+ const mockJobWithError: CopyJobType = {
+ ...mockBasicJob,
+ ID: "test-job-error",
+ Status: CopyJobStatusType.Failed,
+ Error: {
+ message: "Failed to connect to source database",
+ code: "CONNECTION_ERROR",
+ },
+ };
+
+ const mockJobWithNullValues: CopyJobType = {
+ ...mockBasicJob,
+ ID: "test-job-null",
+ Source: {
+ component: "CosmosDBSql",
+ databaseName: undefined,
+ containerName: undefined,
+ remoteAccountName: undefined,
+ },
+ Destination: {
+ component: "CosmosDBSql",
+ databaseName: undefined,
+ containerName: undefined,
+ remoteAccountName: undefined,
+ },
+ };
+
+ describe("Basic Rendering", () => {
+ it("renders the component with correct structure", () => {
+ render( );
+
+ const container = screen.getByTestId("copy-job-details");
+ expect(container).toBeInTheDocument();
+ expect(container).toHaveClass("copyJobDetailsContainer");
+ });
+
+ it("displays job details without error when no error exists", () => {
+ render( );
+
+ expect(screen.queryByTestId("error-stack")).not.toBeInTheDocument();
+ expect(screen.getByTestId("selectedcollection-stack")).toBeInTheDocument();
+ });
+
+ it("renders all required job information fields", () => {
+ render( );
+
+ expect(screen.getByText("Date & time")).toBeInTheDocument();
+ expect(screen.getByText("2024-01-01T10:00:00Z")).toBeInTheDocument();
+
+ expect(screen.getByText("Source Account")).toBeInTheDocument();
+ expect(screen.getByText("sourceAccount")).toBeInTheDocument();
+
+ expect(screen.getByText("Mode")).toBeInTheDocument();
+ expect(screen.getByText("Offline")).toBeInTheDocument();
+ });
+
+ it("renders the DetailsList with correct job data", () => {
+ render( );
+
+ expect(screen.getByText("sourceDb")).toBeInTheDocument();
+ expect(screen.getByText("sourceContainer")).toBeInTheDocument();
+
+ expect(screen.getByText("targetDb")).toBeInTheDocument();
+ expect(screen.getByText("targetContainer")).toBeInTheDocument();
+
+ expect(screen.getByTestId("copy-job-status-icon")).toBeInTheDocument();
+ expect(screen.getByTestId("copy-job-status-icon")).toHaveTextContent("InProgress");
+ });
+ });
+
+ describe("Error Handling", () => {
+ it("displays error section when job has error", () => {
+ render( );
+
+ const errorStack = screen.getByTestId("error-stack");
+ expect(errorStack).toBeInTheDocument();
+
+ expect(screen.getByText("Error Details")).toBeInTheDocument();
+ expect(screen.getByText("Failed to connect to source database")).toBeInTheDocument();
+ });
+
+ it("does not display error section when job has no error", () => {
+ render( );
+
+ expect(screen.queryByTestId("error-stack")).not.toBeInTheDocument();
+ expect(screen.queryByText("Error Details")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Null/Undefined Value Handling", () => {
+ it("displays 'N/A' for null or undefined source values", () => {
+ render( );
+
+ const nATexts = screen.getAllByText("N/A");
+ expect(nATexts).toHaveLength(4);
+ });
+
+ it("handles null remote account name gracefully", () => {
+ render( );
+ expect(screen.getByTestId("copy-job-details")).toBeInTheDocument();
+ });
+
+ it("handles empty status gracefully", () => {
+ const jobWithEmptyStatus: CopyJobType = {
+ ...mockBasicJob,
+ Status: "" as CopyJobStatusType,
+ };
+
+ render( );
+
+ expect(screen.getByTestId("copy-job-status-icon")).toHaveTextContent("");
+ });
+ });
+
+ describe("Different Job Statuses", () => {
+ const statusTestCases = [
+ CopyJobStatusType.Pending,
+ CopyJobStatusType.Running,
+ CopyJobStatusType.Paused,
+ CopyJobStatusType.Completed,
+ CopyJobStatusType.Failed,
+ CopyJobStatusType.Cancelled,
+ ];
+
+ statusTestCases.forEach((status) => {
+ it(`renders correctly for ${status} status`, () => {
+ const jobWithStatus: CopyJobType = {
+ ...mockBasicJob,
+ Status: status,
+ };
+
+ render( );
+
+ expect(screen.getByTestId("copy-job-status-icon")).toHaveTextContent(status);
+ });
+ });
+ });
+
+ describe("Component Memoization", () => {
+ it("re-renders when job ID changes", () => {
+ render( );
+
+ expect(screen.getByText(CopyJobStatusType.InProgress)).toBeInTheDocument();
+
+ const updatedJob: CopyJobType = {
+ ...mockBasicJob,
+ Status: CopyJobStatusType.Completed,
+ };
+
+ render( );
+
+ expect(screen.getByText(CopyJobStatusType.Completed)).toBeInTheDocument();
+ });
+
+ it("re-renders when error changes", () => {
+ const { rerender } = render( );
+
+ expect(screen.queryByTestId("error-stack")).not.toBeInTheDocument();
+
+ rerender( );
+
+ expect(screen.getByTestId("error-stack")).toBeInTheDocument();
+ });
+
+ it("does not re-render when other props change but ID and Error stay same", () => {
+ const jobWithSameIdAndError = {
+ ...mockBasicJob,
+ Mode: "Online",
+ CompletionPercentage: 75,
+ };
+
+ const { rerender } = render( );
+ rerender( );
+ expect(screen.getByTestId("copy-job-details")).toBeInTheDocument();
+ });
+ });
+
+ describe("Data Transformation", () => {
+ it("correctly transforms job data for DetailsList items", () => {
+ render( );
+ expect(screen.getByText("sourceContainer")).toBeInTheDocument();
+ expect(screen.getByText("sourceDb")).toBeInTheDocument();
+ expect(screen.getByText("targetContainer")).toBeInTheDocument();
+ expect(screen.getByText("targetDb")).toBeInTheDocument();
+ expect(screen.getByTestId("copy-job-status-icon")).toHaveTextContent("InProgress");
+ });
+
+ it("handles complex job data structure", () => {
+ const complexJob: CopyJobType = {
+ ...mockBasicJob,
+ Source: {
+ component: "CosmosDBSql",
+ databaseName: "complex-source-db-with-hyphens",
+ containerName: "complex_source_container_with_underscores",
+ remoteAccountName: "complex.source.account",
+ },
+ Destination: {
+ component: "CosmosDBSql",
+ databaseName: "complex-target-db-with-hyphens",
+ containerName: "complex_target_container_with_underscores",
+ remoteAccountName: "complex.target.account",
+ },
+ };
+
+ render( );
+
+ expect(screen.getByText("complex-source-db-with-hyphens")).toBeInTheDocument();
+ expect(screen.getByText("complex_source_container_with_underscores")).toBeInTheDocument();
+ expect(screen.getByText("complex-target-db-with-hyphens")).toBeInTheDocument();
+ expect(screen.getByText("complex_target_container_with_underscores")).toBeInTheDocument();
+ expect(screen.getByText("complex.source.account")).toBeInTheDocument();
+ });
+ });
+
+ describe("DetailsList Configuration", () => {
+ it("configures DetailsList with correct layout mode", () => {
+ render( );
+ expect(screen.getByText("sourceContainer")).toBeInTheDocument();
+ });
+
+ it("renders all expected column data", () => {
+ render( );
+ expect(screen.getByText("sourceDb")).toBeInTheDocument();
+ expect(screen.getByText("sourceContainer")).toBeInTheDocument();
+ expect(screen.getByText("targetDb")).toBeInTheDocument();
+ expect(screen.getByText("targetContainer")).toBeInTheDocument();
+ expect(screen.getByTestId("copy-job-status-icon")).toBeInTheDocument();
+ });
+ });
+
+ describe("Accessibility", () => {
+ it("has proper data-testid attributes", () => {
+ render( );
+
+ expect(screen.getByTestId("copy-job-details")).toBeInTheDocument();
+ expect(screen.getByTestId("error-stack")).toBeInTheDocument();
+ expect(screen.getByTestId("selectedcollection-stack")).toBeInTheDocument();
+ });
+
+ it("renders semantic HTML structure", () => {
+ render( );
+
+ const container = screen.getByTestId("copy-job-details");
+ expect(container).toBeInTheDocument();
+
+ const nestedStack = screen.getByTestId("selectedcollection-stack");
+ expect(nestedStack).toBeInTheDocument();
+ });
+ });
+
+ describe("CSS and Styling", () => {
+ it("applies correct CSS classes", () => {
+ render( );
+
+ const container = screen.getByTestId("copy-job-details");
+ expect(container).toHaveClass("copyJobDetailsContainer");
+ });
+
+ it("applies correct styling to error text", () => {
+ render( );
+
+ const errorText = screen.getByText("Failed to connect to source database");
+ expect(errorText).toHaveStyle({ whiteSpace: "pre-wrap" });
+ });
+
+ it("applies bold styling to heading texts", () => {
+ render( );
+
+ const dateTimeHeading = screen.getByText("Date & time");
+ const sourceAccountHeading = screen.getByText("Source Account");
+ const modeHeading = screen.getByText("Mode");
+
+ expect(dateTimeHeading).toHaveClass("bold");
+ expect(sourceAccountHeading).toHaveClass("bold");
+ expect(modeHeading).toHaveClass("bold");
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("handles job with minimal required data", () => {
+ const minimalJob = {
+ ID: "minimal",
+ Mode: "",
+ Name: "",
+ Status: CopyJobStatusType.Pending,
+ CompletionPercentage: 0,
+ Duration: "",
+ LastUpdatedTime: "",
+ timestamp: 0,
+ Source: {
+ component: "CosmosDBSql",
+ },
+ Destination: {
+ component: "CosmosDBSql",
+ },
+ } as CopyJobType;
+
+ render( );
+
+ expect(screen.getByTestId("copy-job-details")).toBeInTheDocument();
+ expect(screen.getAllByText("N/A")).toHaveLength(4);
+ });
+
+ it("handles very long text values", () => {
+ const longTextJob: CopyJobType = {
+ ...mockBasicJob,
+ Source: {
+ ...mockBasicJob.Source,
+ databaseName: "very-long-database-name-that-might-cause-layout-issues-in-the-ui-component",
+ containerName: "very-long-container-name-that-might-cause-layout-issues-in-the-ui-component",
+ remoteAccountName: "very-long-account-name-that-might-cause-layout-issues-in-the-ui-component",
+ },
+ Error: {
+ message:
+ "This is a very long error message that contains multiple sentences and might span several lines when displayed in the user interface. It should handle line breaks and maintain readability even with extensive content.",
+ code: "LONG_ERROR",
+ },
+ };
+
+ render( );
+
+ expect(
+ screen.getByText("very-long-database-name-that-might-cause-layout-issues-in-the-ui-component"),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/This is a very long error message/)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.tsx
index f131cc4e5..63c7fcf46 100644
--- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.tsx
+++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.tsx
@@ -1,5 +1,6 @@
import { DetailsList, DetailsListLayoutMode, IColumn, Stack, Text } from "@fluentui/react";
import React, { memo } from "react";
+import { useThemeStore } from "../../../../hooks/useTheme";
import ContainerCopyMessages from "../../ContainerCopyMessages";
import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
import { CopyJobType } from "../../Types/CopyJobTypes";
@@ -63,6 +64,19 @@ const getCopyJobDetailsListColumns = (): IColumn[] => {
};
const CopyJobDetails: React.FC = ({ job }) => {
+ const isDarkMode = useThemeStore((state) => state.isDarkMode);
+
+ const errorMessageStyle: React.CSSProperties = {
+ whiteSpace: "pre-wrap",
+ ...(isDarkMode && {
+ whiteSpace: "pre-wrap",
+ backgroundColor: "var(--colorNeutralBackground2)",
+ color: "var(--colorNeutralForeground1)",
+ padding: "10px",
+ borderRadius: "4px",
+ }),
+ };
+
const selectedContainers = [
{
sourceContainerName: job?.Source?.containerName || "N/A",
@@ -77,10 +91,10 @@ const CopyJobDetails: React.FC = ({ job }) => {
{job.Error ? (
-
+
{ContainerCopyMessages.errorTitle}
-
+
{job.Error.message}
@@ -88,16 +102,16 @@ const CopyJobDetails: React.FC = ({ job }) => {
- {ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime}
- {job.LastUpdatedTime}
+ {ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime}
+ {job.LastUpdatedTime}
- {ContainerCopyMessages.sourceAccountLabel}
- {job.Source?.remoteAccountName}
+ {ContainerCopyMessages.sourceAccountLabel}
+ {job.Source?.remoteAccountName}
- {ContainerCopyMessages.MonitorJobs.Columns.mode}
- {job.Mode}
+ {ContainerCopyMessages.MonitorJobs.Columns.mode}
+ {job.Mode}
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.test.tsx
new file mode 100644
index 000000000..836ce3643
--- /dev/null
+++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.test.tsx
@@ -0,0 +1,162 @@
+import "@testing-library/jest-dom";
+import { render } from "@testing-library/react";
+import React from "react";
+import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
+import CopyJobStatusWithIcon from "./CopyJobStatusWithIcon";
+
+jest.mock("@fluentui/react", () => ({
+ ...jest.requireActual("@fluentui/react"),
+ getTheme: () => ({
+ semanticColors: {
+ bodySubtext: "#666666",
+ errorIcon: "#d13438",
+ successIcon: "#107c10",
+ },
+ palette: {
+ themePrimary: "#0078d4",
+ },
+ }),
+ mergeStyles: () => "mocked-styles",
+ mergeStyleSets: (styleSet: any) => {
+ const result: any = {};
+ Object.keys(styleSet).forEach((key) => {
+ result[key] = "mocked-style-" + key;
+ });
+ return result;
+ },
+}));
+
+describe("CopyJobStatusWithIcon", () => {
+ describe("Static Icon Status Types - Snapshot Tests", () => {
+ const staticIconStatuses = [
+ CopyJobStatusType.Pending,
+ CopyJobStatusType.Paused,
+ CopyJobStatusType.Skipped,
+ CopyJobStatusType.Cancelled,
+ CopyJobStatusType.Failed,
+ CopyJobStatusType.Faulted,
+ CopyJobStatusType.Completed,
+ ];
+
+ test.each(staticIconStatuses)("renders %s status correctly", (status) => {
+ const { container } = render( );
+ expect(container.firstChild).toMatchSnapshot();
+ });
+ });
+
+ describe("Spinner Status Types", () => {
+ const spinnerStatuses = [CopyJobStatusType.Running, CopyJobStatusType.InProgress, CopyJobStatusType.Partitioning];
+
+ test.each(spinnerStatuses)("renders %s with spinner and expected text", (status) => {
+ const { container } = render( );
+
+ const spinner = container.querySelector('[class*="ms-Spinner"]');
+ expect(spinner).toBeInTheDocument();
+ expect(container).toHaveTextContent("Running");
+ expect(container.firstChild).toMatchSnapshot();
+ });
+ });
+
+ describe("PropTypes Validation", () => {
+ it("has correct display name", () => {
+ expect(CopyJobStatusWithIcon.displayName).toBe("CopyJobStatusWithIcon");
+ });
+ it("accepts all valid CopyJobStatusType values", () => {
+ const allStatuses = Object.values(CopyJobStatusType);
+
+ allStatuses.forEach((status) => {
+ expect(() => {
+ render( );
+ }).not.toThrow();
+ });
+ });
+ });
+
+ describe("Accessibility", () => {
+ it("provides proper aria-label for icon elements", () => {
+ const { container } = render( );
+
+ const icon = container.querySelector('[class*="ms-Icon"]');
+ expect(icon).toHaveAttribute("aria-label", CopyJobStatusType.Failed);
+ });
+
+ it("provides meaningful text content for screen readers", () => {
+ const { container } = render( );
+
+ expect(container).toHaveTextContent("Running");
+ });
+ });
+
+ describe("Icon and Status Mapping", () => {
+ it("renders correct status text based on mapping", () => {
+ const statusMappings = [
+ { status: CopyJobStatusType.Pending, expectedText: "Queued" },
+ { status: CopyJobStatusType.Paused, expectedText: "Paused" },
+ { status: CopyJobStatusType.Failed, expectedText: "Failed" },
+ { status: CopyJobStatusType.Completed, expectedText: "Completed" },
+ { status: CopyJobStatusType.Running, expectedText: "Running" },
+ ];
+
+ statusMappings.forEach(({ status, expectedText }) => {
+ const { container, unmount } = render( );
+ expect(container).toHaveTextContent(expectedText);
+ unmount();
+ });
+ });
+
+ it("renders icons for static status types", () => {
+ const staticStatuses = [
+ CopyJobStatusType.Pending,
+ CopyJobStatusType.Paused,
+ CopyJobStatusType.Failed,
+ CopyJobStatusType.Completed,
+ ];
+
+ staticStatuses.forEach((status) => {
+ const { container, unmount } = render( );
+ const icon = container.querySelector('[class*="ms-Icon"]');
+ const spinner = container.querySelector('[class*="ms-Spinner"]');
+
+ expect(icon).toBeInTheDocument();
+ expect(spinner).not.toBeInTheDocument();
+
+ unmount();
+ });
+ });
+
+ it("renders spinners for progress status types", () => {
+ const progressStatuses = [
+ CopyJobStatusType.Running,
+ CopyJobStatusType.InProgress,
+ CopyJobStatusType.Partitioning,
+ ];
+
+ progressStatuses.forEach((status) => {
+ const { container, unmount } = render( );
+ const icon = container.querySelector('[class*="ms-Icon"]');
+ const spinner = container.querySelector('[class*="ms-Spinner"]');
+
+ expect(spinner).toBeInTheDocument();
+ expect(icon).not.toBeInTheDocument();
+
+ unmount();
+ });
+ });
+ });
+
+ describe("Performance", () => {
+ it("does not cause unnecessary re-renders with same props", () => {
+ const renderSpy = jest.fn();
+ const TestWrapper = ({ status }: { status: CopyJobStatusType }) => {
+ renderSpy();
+ return ;
+ };
+
+ const { rerender } = render( );
+ expect(renderSpy).toHaveBeenCalledTimes(1);
+
+ rerender( );
+ expect(renderSpy).toHaveBeenCalledTimes(2);
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx
index b10d69087..1fb996639 100644
--- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx
+++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx
@@ -1,29 +1,14 @@
-import { FontIcon, getTheme, mergeStyles, mergeStyleSets, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
+import { FontIcon, mergeStyles, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
+import PropTypes from "prop-types";
import React from "react";
import ContainerCopyMessages from "../../ContainerCopyMessages";
import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
-const theme = getTheme();
-
const iconClass = mergeStyles({
fontSize: "16px",
marginRight: "8px",
});
-const classNames = mergeStyleSets({
- [CopyJobStatusType.Pending]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
- [CopyJobStatusType.InProgress]: [{ color: theme.palette.themePrimary }, iconClass],
- [CopyJobStatusType.Running]: [{ color: theme.palette.themePrimary }, iconClass],
- [CopyJobStatusType.Partitioning]: [{ color: theme.palette.themePrimary }, iconClass],
- [CopyJobStatusType.Paused]: [{ color: theme.palette.themePrimary }, iconClass],
- [CopyJobStatusType.Skipped]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
- [CopyJobStatusType.Cancelled]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
- [CopyJobStatusType.Failed]: [{ color: theme.semanticColors.errorIcon }, iconClass],
- [CopyJobStatusType.Faulted]: [{ color: theme.semanticColors.errorIcon }, iconClass],
- [CopyJobStatusType.Completed]: [{ color: theme.semanticColors.successIcon }, iconClass],
- unknown: [{ color: theme.semanticColors.bodySubtext }, iconClass],
-});
-
const iconMap: Partial> = {
[CopyJobStatusType.Pending]: "Clock",
[CopyJobStatusType.Paused]: "CirclePause",
@@ -34,7 +19,22 @@ const iconMap: Partial> = {
[CopyJobStatusType.Completed]: "CompletedSolid",
};
-const CopyJobStatusWithIcon: React.FC<{ status: CopyJobStatusType }> = ({ status }) => {
+// Icon colors for different statuses
+const statusIconColors: Partial> = {
+ [CopyJobStatusType.Failed]: "var(--colorPaletteRedForeground1)",
+ [CopyJobStatusType.Faulted]: "var(--colorPaletteRedForeground1)",
+ [CopyJobStatusType.Completed]: "var(--colorSuccessGreen)",
+ [CopyJobStatusType.InProgress]: "var(--colorBrandForeground1)",
+ [CopyJobStatusType.Running]: "var(--colorBrandForeground1)",
+ [CopyJobStatusType.Partitioning]: "var(--colorBrandForeground1)",
+ [CopyJobStatusType.Paused]: "var(--colorBrandForeground1)",
+};
+
+export interface CopyJobStatusWithIconProps {
+ status: CopyJobStatusType;
+}
+
+const CopyJobStatusWithIcon: React.FC = React.memo(({ status }) => {
const statusText = ContainerCopyMessages.MonitorJobs.Status[status] || "Unknown";
const isSpinnerStatus = [
@@ -42,21 +42,24 @@ const CopyJobStatusWithIcon: React.FC<{ status: CopyJobStatusType }> = ({ status
CopyJobStatusType.InProgress,
CopyJobStatusType.Partitioning,
].includes(status);
+ const iconColor = statusIconColors[status] || "var(--colorNeutralForeground2)";
+ const iconStyle = mergeStyles(iconClass, { color: iconColor });
return (
{isSpinnerStatus ? (
) : (
-
+
)}
- {statusText}
+ {statusText}
);
+});
+
+CopyJobStatusWithIcon.displayName = "CopyJobStatusWithIcon";
+CopyJobStatusWithIcon.propTypes = {
+ status: PropTypes.oneOf(Object.values(CopyJobStatusType)).isRequired,
};
export default CopyJobStatusWithIcon;
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.test.tsx
new file mode 100644
index 000000000..d81f64289
--- /dev/null
+++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.test.tsx
@@ -0,0 +1,73 @@
+jest.mock("../../Actions/CopyJobActions");
+
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen } from "@testing-library/react";
+import Explorer from "Explorer/Explorer";
+import React from "react";
+import * as Actions from "../../Actions/CopyJobActions";
+import ContainerCopyMessages from "../../ContainerCopyMessages";
+import CopyJobsNotFound from "./CopyJobs.NotFound";
+
+describe("CopyJobsNotFound", () => {
+ let mockExplorer: Explorer;
+
+ beforeEach(() => {
+ mockExplorer = {} as Explorer;
+ jest.clearAllMocks();
+ });
+
+ it("should render the component with correct elements", () => {
+ const { container, getByText } = render( );
+
+ const image = container.querySelector(".notFoundContainer .ms-Image");
+ expect(image).toBeInTheDocument();
+ expect(image).toHaveAttribute("style", "width: 100px; height: 100px;");
+ expect(getByText(ContainerCopyMessages.noCopyJobsTitle)).toBeInTheDocument();
+
+ const button = screen.getByRole("button", {
+ name: ContainerCopyMessages.createCopyJobButtonText,
+ });
+ expect(button).toBeInTheDocument();
+ expect(button).toHaveClass("createCopyJobButton");
+ });
+
+ it("should render with correct container classes", () => {
+ const { container } = render( );
+
+ const notFoundContainer = container.querySelector(".notFoundContainer");
+ expect(notFoundContainer).toBeInTheDocument();
+ expect(notFoundContainer).toHaveClass("flexContainer", "centerContent");
+ });
+
+ it("should call openCreateCopyJobPanel when button is clicked", () => {
+ const openCreateCopyJobPanelSpy = jest.spyOn(Actions, "openCreateCopyJobPanel");
+
+ render( );
+
+ const button = screen.getByRole("button", {
+ name: ContainerCopyMessages.createCopyJobButtonText,
+ });
+
+ fireEvent.click(button);
+
+ expect(openCreateCopyJobPanelSpy).toHaveBeenCalledTimes(1);
+ expect(openCreateCopyJobPanelSpy).toHaveBeenCalledWith(mockExplorer);
+ });
+
+ it("should render ActionButton with correct props", () => {
+ render( );
+
+ const button = screen.getByRole("button", {
+ name: ContainerCopyMessages.createCopyJobButtonText,
+ });
+
+ expect(button).toBeInTheDocument();
+ expect(button.textContent).toBe(ContainerCopyMessages.createCopyJobButtonText);
+ });
+
+ it("should use memo to prevent unnecessary re-renders", () => {
+ const { rerender } = render( );
+ rerender( );
+ expect(screen.getByRole("heading", { level: 4 })).toBeInTheDocument();
+ });
+});
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx
index 1b8ad0436..3c9658d62 100644
--- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx
+++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx
@@ -17,7 +17,7 @@ const CopyJobsNotFound: React.FC = ({ explorer }) => {
Actions.openCreateCopyJobPanel(explorer)}
>
{ContainerCopyMessages.createCopyJobButtonText}
@@ -25,4 +25,4 @@ const CopyJobsNotFound: React.FC = ({ explorer }) => {
);
};
-export default CopyJobsNotFound;
+export default React.memo(CopyJobsNotFound);
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx
new file mode 100644
index 000000000..c3b723265
--- /dev/null
+++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx
@@ -0,0 +1,446 @@
+import "@testing-library/jest-dom";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import React from "react";
+import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
+import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
+import CopyJobsList from "./CopyJobsList";
+
+jest.mock("../../Actions/CopyJobActions", () => ({
+ openCopyJobDetailsPanel: jest.fn(),
+}));
+
+jest.mock("./CopyJobColumns", () => ({
+ getColumns: jest.fn(() => [
+ {
+ key: "Name",
+ name: "Name",
+ fieldName: "Name",
+ minWidth: 140,
+ maxWidth: 300,
+ isResizable: true,
+ onRender: (job: CopyJobType) => {job.Name} ,
+ },
+ {
+ key: "Status",
+ name: "Status",
+ fieldName: "Status",
+ minWidth: 130,
+ maxWidth: 200,
+ isResizable: true,
+ onRender: (job: CopyJobType) => {job.Status} ,
+ },
+ {
+ key: "CompletionPercentage",
+ name: "Progress",
+ fieldName: "CompletionPercentage",
+ minWidth: 110,
+ maxWidth: 200,
+ isResizable: true,
+ onRender: (job: CopyJobType) => {job.CompletionPercentage}% ,
+ },
+ {
+ key: "Actions",
+ name: "Actions",
+ minWidth: 80,
+ maxWidth: 200,
+ isResizable: true,
+ onRender: (job: CopyJobType) => Actions ,
+ },
+ ]),
+}));
+
+// Sample test data
+const mockJobs: CopyJobType[] = [
+ {
+ ID: "job-1",
+ Mode: "Live",
+ Name: "Test Job 1",
+ Status: CopyJobStatusType.Running,
+ CompletionPercentage: 45,
+ Duration: "00:05:30",
+ LastUpdatedTime: "2025-01-01 10:00:00",
+ timestamp: 1704110400000,
+ Source: {
+ component: "CosmosDBSql",
+ remoteAccountName: "source-account",
+ databaseName: "sourceDb",
+ containerName: "sourceContainer",
+ },
+ Destination: {
+ component: "CosmosDBSql",
+ databaseName: "targetDb",
+ containerName: "targetContainer",
+ },
+ },
+ {
+ ID: "job-2",
+ Mode: "Offline",
+ Name: "Test Job 2",
+ Status: CopyJobStatusType.Completed,
+ CompletionPercentage: 100,
+ Duration: "00:15:45",
+ LastUpdatedTime: "2025-01-01 11:00:00",
+ timestamp: 1704114000000,
+ Source: {
+ component: "CosmosDBSql",
+ remoteAccountName: "source-account-2",
+ databaseName: "sourceDb2",
+ containerName: "sourceContainer2",
+ },
+ Destination: {
+ component: "CosmosDBSql",
+ databaseName: "targetDb2",
+ containerName: "targetContainer2",
+ },
+ },
+ {
+ ID: "job-3",
+ Mode: "Live",
+ Name: "Test Job 3",
+ Status: CopyJobStatusType.Failed,
+ CompletionPercentage: 25,
+ Duration: "00:02:15",
+ LastUpdatedTime: "2025-01-01 09:30:00",
+ timestamp: 1704108600000,
+ Error: {
+ message: "Connection timeout",
+ code: "TIMEOUT_ERROR",
+ },
+ Source: {
+ component: "CosmosDBSql",
+ remoteAccountName: "source-account-3",
+ databaseName: "sourceDb3",
+ containerName: "sourceContainer3",
+ },
+ Destination: {
+ component: "CosmosDBSql",
+ databaseName: "targetDb3",
+ containerName: "targetContainer3",
+ },
+ },
+];
+
+const mockHandleActionClick: HandleJobActionClickType = jest.fn();
+
+describe("CopyJobsList", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("Rendering", () => {
+ it("renders empty list when no jobs provided", () => {
+ render( );
+
+ expect(screen.queryByText("Test Job 1")).not.toBeInTheDocument();
+ });
+
+ it("renders jobs list with provided jobs", () => {
+ render( );
+
+ expect(screen.getByText("Test Job 1")).toBeInTheDocument();
+ expect(screen.getByText("Test Job 2")).toBeInTheDocument();
+ expect(screen.getByText("Test Job 3")).toBeInTheDocument();
+ });
+
+ it("renders job statuses correctly", () => {
+ render( );
+
+ expect(screen.getByText(CopyJobStatusType.Running)).toBeInTheDocument();
+ expect(screen.getByText(CopyJobStatusType.Completed)).toBeInTheDocument();
+ expect(screen.getByText(CopyJobStatusType.Failed)).toBeInTheDocument();
+ });
+
+ it("renders completion percentages correctly", () => {
+ render( );
+
+ expect(screen.getByText("45%")).toBeInTheDocument();
+ expect(screen.getByText("100%")).toBeInTheDocument();
+ expect(screen.getByText("25%")).toBeInTheDocument();
+ });
+
+ it("renders action menus for each job", () => {
+ render( );
+
+ expect(screen.getByTestId("action-menu-job-1")).toBeInTheDocument();
+ expect(screen.getByTestId("action-menu-job-2")).toBeInTheDocument();
+ expect(screen.getByTestId("action-menu-job-3")).toBeInTheDocument();
+ });
+ });
+
+ describe("Pagination", () => {
+ it("shows pager when jobs exceed page size", () => {
+ const manyJobs: CopyJobType[] = Array.from({ length: 15 }, (_, i) => ({
+ ...mockJobs[0],
+ ID: `job-${i + 1}`,
+ Name: `Test Job ${i + 1}`,
+ }));
+
+ render( );
+
+ expect(screen.getByLabelText("Go to first page")).toBeInTheDocument();
+ expect(screen.getByLabelText("Go to previous page")).toBeInTheDocument();
+ expect(screen.getByLabelText("Go to next page")).toBeInTheDocument();
+ expect(screen.getByLabelText("Go to last page")).toBeInTheDocument();
+ });
+
+ it("does not show pager when jobs are within page size", () => {
+ render( );
+
+ expect(screen.queryByLabelText("Go to first page")).not.toBeInTheDocument();
+ expect(screen.queryByLabelText("Go to previous page")).not.toBeInTheDocument();
+ expect(screen.queryByLabelText("Go to next page")).not.toBeInTheDocument();
+ expect(screen.queryByLabelText("Go to last page")).not.toBeInTheDocument();
+ });
+
+ it("displays correct page information", () => {
+ const manyJobs: CopyJobType[] = Array.from({ length: 25 }, (_, i) => ({
+ ...mockJobs[0],
+ ID: `job-${i + 1}`,
+ Name: `Test Job ${i + 1}`,
+ }));
+
+ render( );
+
+ expect(screen.getByText("Showing 1 - 10 of 25 items")).toBeInTheDocument();
+ expect(screen.getByText("Page 1 of 3")).toBeInTheDocument();
+ });
+
+ it("navigates to next page correctly", async () => {
+ const manyJobs: CopyJobType[] = Array.from({ length: 15 }, (_, i) => ({
+ ...mockJobs[0],
+ ID: `job-${i + 1}`,
+ Name: `Test Job ${i + 1}`,
+ }));
+
+ render( );
+
+ expect(screen.getByText("Test Job 1")).toBeInTheDocument();
+ expect(screen.getByText("Test Job 10")).toBeInTheDocument();
+ expect(screen.queryByText("Test Job 11")).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByLabelText("Go to next page"));
+
+ await waitFor(() => {
+ expect(screen.queryByText("Test Job 1")).not.toBeInTheDocument();
+ expect(screen.getByText("Test Job 11")).toBeInTheDocument();
+ expect(screen.getByText("Test Job 15")).toBeInTheDocument();
+ });
+ });
+
+ it("uses custom page size when provided", () => {
+ const manyJobs: CopyJobType[] = Array.from({ length: 8 }, (_, i) => ({
+ ...mockJobs[0],
+ ID: `job-${i + 1}`,
+ Name: `Test Job ${i + 1}`,
+ }));
+
+ render( );
+
+ expect(screen.getByLabelText("Go to next page")).toBeInTheDocument();
+ expect(screen.getByText("Showing 1 - 5 of 8 items")).toBeInTheDocument();
+ });
+ });
+
+ describe("Sorting", () => {
+ it("sorts jobs by name in ascending order", async () => {
+ const unsortedJobs = [
+ { ...mockJobs[0], Name: "Z Job" },
+ { ...mockJobs[1], Name: "A Job" },
+ { ...mockJobs[2], Name: "M Job" },
+ ];
+
+ render( );
+
+ const rows = screen.getAllByText(/Job$/);
+ expect(rows[0]).toHaveTextContent("Z Job");
+ expect(rows[1]).toHaveTextContent("A Job");
+ expect(rows[2]).toHaveTextContent("M Job");
+ });
+
+ it("resets pagination to first page after sorting", async () => {
+ const manyJobs: CopyJobType[] = Array.from({ length: 15 }, (_, i) => ({
+ ...mockJobs[0],
+ ID: `job-${i + 1}`,
+ Name: `Job ${String.fromCharCode(90 - i)}`,
+ }));
+
+ render( );
+
+ fireEvent.click(screen.getByLabelText("Go to next page"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Showing 11 - 15 of 15 items")).toBeInTheDocument();
+ });
+ });
+
+ it("updates jobs list when jobs prop changes", async () => {
+ const { rerender } = render( );
+
+ expect(screen.getByText("Test Job 1")).toBeInTheDocument();
+ expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument();
+
+ rerender( );
+
+ expect(screen.getByText("Test Job 1")).toBeInTheDocument();
+ expect(screen.getByText("Test Job 2")).toBeInTheDocument();
+ expect(screen.getByText("Test Job 3")).toBeInTheDocument();
+ });
+
+ it("resets start index when jobs change", async () => {
+ const manyJobs: CopyJobType[] = Array.from({ length: 15 }, (_, i) => ({
+ ...mockJobs[0],
+ ID: `job-${i + 1}`,
+ Name: `Test Job ${i + 1}`,
+ }));
+
+ const { rerender } = render(
+ ,
+ );
+
+ fireEvent.click(screen.getByLabelText("Go to next page"));
+
+ await waitFor(() => {
+ expect(screen.getByText("Showing 11 - 15 of 15 items")).toBeInTheDocument();
+ });
+
+ const newJobs = [mockJobs[0], mockJobs[1]];
+ rerender( );
+
+ expect(screen.queryByLabelText("Go to next page")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("Row Interactions", () => {
+ it("calls openCopyJobDetailsPanel when row is clicked", async () => {
+ const { openCopyJobDetailsPanel } = await import("../../Actions/CopyJobActions");
+
+ render( );
+
+ const jobNameElement = screen.getByText("Test Job 1");
+ const rowElement = jobNameElement.closest('[role="row"]') || jobNameElement.closest("div");
+
+ if (rowElement) {
+ fireEvent.click(rowElement);
+ } else {
+ fireEvent.click(jobNameElement);
+ }
+
+ await waitFor(() => {
+ expect(openCopyJobDetailsPanel).toHaveBeenCalledWith(mockJobs[0]);
+ });
+ });
+
+ it("applies cursor pointer style to rows", () => {
+ render( );
+
+ const jobNameElement = screen.getByText("Test Job 1");
+ const rowElement = jobNameElement.closest("div");
+
+ expect(rowElement).toBeInTheDocument();
+ });
+ });
+
+ describe("Component Props", () => {
+ it("uses default page size when not provided", () => {
+ const manyJobs: CopyJobType[] = Array.from({ length: 12 }, (_, i) => ({
+ ...mockJobs[0],
+ ID: `job-${i + 1}`,
+ Name: `Test Job ${i + 1}`,
+ }));
+
+ render( );
+
+ expect(screen.getByLabelText("Go to next page")).toBeInTheDocument();
+ expect(screen.getByText("Showing 1 - 10 of 12 items")).toBeInTheDocument();
+ });
+
+ it("passes correct props to getColumns function", async () => {
+ const { getColumns } = await import("./CopyJobColumns");
+
+ render( );
+
+ expect(getColumns).toHaveBeenCalledWith(
+ expect.any(Function), // handleSort
+ mockHandleActionClick, // handleActionClick
+ undefined, // sortedColumnKey
+ false, // isSortedDescending
+ );
+ });
+ });
+
+ describe("Accessibility", () => {
+ it("renders with proper ARIA attributes", () => {
+ render( );
+
+ const detailsList = screen.getByRole("grid");
+ expect(detailsList).toBeInTheDocument();
+ });
+
+ it("has accessible pager controls", () => {
+ const manyJobs: CopyJobType[] = Array.from({ length: 15 }, (_, i) => ({
+ ...mockJobs[0],
+ ID: `job-${i + 1}`,
+ Name: `Test Job ${i + 1}`,
+ }));
+
+ render( );
+
+ expect(screen.getByLabelText("Go to first page")).toBeInTheDocument();
+ expect(screen.getByLabelText("Go to previous page")).toBeInTheDocument();
+ expect(screen.getByLabelText("Go to next page")).toBeInTheDocument();
+ expect(screen.getByLabelText("Go to last page")).toBeInTheDocument();
+ });
+ });
+
+ describe("Error Handling", () => {
+ it("handles empty jobs array gracefully", () => {
+ expect(() => {
+ render( );
+ }).not.toThrow();
+ });
+
+ it("handles jobs with missing optional properties", () => {
+ const incompleteJob: CopyJobType = {
+ ID: "incomplete-job",
+ Mode: "Live",
+ Name: "Incomplete Job",
+ Status: CopyJobStatusType.Running,
+ CompletionPercentage: 0,
+ Duration: "00:00:00",
+ LastUpdatedTime: "2025-01-01 12:00:00",
+ timestamp: 1704117600000,
+ Source: {
+ component: "CosmosDBSql",
+ remoteAccountName: "source-account",
+ databaseName: "sourceDb",
+ containerName: "sourceContainer",
+ },
+ Destination: {
+ component: "CosmosDBSql",
+ databaseName: "targetDb",
+ containerName: "targetContainer",
+ },
+ };
+
+ expect(() => {
+ render( );
+ }).not.toThrow();
+
+ expect(screen.getByText("Incomplete Job")).toBeInTheDocument();
+ });
+
+ it("handles very large job lists", () => {
+ const largeJobsList: CopyJobType[] = Array.from({ length: 1000 }, (_, i) => ({
+ ...mockJobs[0],
+ ID: `job-${i + 1}`,
+ Name: `Job ${i + 1}`,
+ }));
+
+ expect(() => {
+ render( );
+ }).not.toThrow();
+
+ expect(screen.getByText("Showing 1 - 10 of 1000 items")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx
index 6fdf915d6..a263ac137 100644
--- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx
+++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx
@@ -1,9 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable react/prop-types */
import {
ConstrainMode,
DetailsListLayoutMode,
DetailsRow,
IColumn,
+ IDetailsRowProps,
ScrollablePane,
ScrollbarVisibility,
ShimmeredDetailsList,
@@ -13,6 +15,8 @@ import {
} from "@fluentui/react";
import React, { useEffect } from "react";
import Pager from "../../../../Common/Pager";
+import { useThemeStore } from "../../../../hooks/useTheme";
+import { getThemeTokens } from "../../../Theme/ThemeUtil";
import { openCopyJobDetailsPanel } from "../../Actions/CopyJobActions";
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
import { getColumns } from "./CopyJobColumns";
@@ -24,13 +28,15 @@ interface CopyJobsListProps {
}
const styles = {
- container: { height: "calc(100vh - 25em)" } as React.CSSProperties,
+ container: { height: "100%" } as React.CSSProperties,
stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties,
};
const PAGE_SIZE = 10;
const CopyJobsList: React.FC = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => {
+ const isDarkMode = useThemeStore((state) => state.isDarkMode);
+ const themeTokens = getThemeTokens(isDarkMode);
const [startIndex, setStartIndex] = React.useState(0);
const [sortedJobs, setSortedJobs] = React.useState(jobs);
const [sortedColumnKey, setSortedColumnKey] = React.useState(undefined);
@@ -58,22 +64,19 @@ const CopyJobsList: React.FC = ({ jobs, handleActionClick, pa
setStartIndex(0);
};
- const columns: IColumn[] = React.useMemo(
- () => getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending),
- [handleSort, handleActionClick, sortedColumnKey, isSortedDescending],
- );
+ const columns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending);
- const _handleRowClick = React.useCallback((job: CopyJobType) => {
+ const _handleRowClick = (job: CopyJobType) => {
openCopyJobDetailsPanel(job);
- }, []);
+ };
- const _onRenderRow = React.useCallback((props: any) => {
+ const _onRenderRow = (props: IDetailsRowProps) => {
return (
);
- }, []);
+ };
return (
@@ -81,6 +84,7 @@ const CopyJobsList: React.FC
= ({ jobs, handleActionClick, pa
= ({ jobs, handleActionClick, pa
enableShimmer={false}
constrainMode={ConstrainMode.unconstrained}
layoutMode={DetailsListLayoutMode.justified}
- onRenderDetailsHeader={(props, defaultRender) => (
-
- {defaultRender({ ...props })}
-
- )}
+ onRenderDetailsHeader={(props, defaultRender) => {
+ const bgColor = themeTokens.colorNeutralBackground3;
+ const textColor = themeTokens.colorNeutralForeground1;
+ return (
+
+
+ {defaultRender({
+ ...props,
+ styles: {
+ root: {
+ backgroundColor: bgColor,
+ selectors: {
+ ".ms-DetailsHeader-cellTitle": { color: textColor },
+ ".ms-DetailsHeader-cellName": { color: textColor },
+ },
+ },
+ },
+ })}
+
+
+ );
+ }}
/>
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/__snapshots__/CopyJobStatusWithIcon.test.tsx.snap b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/__snapshots__/CopyJobStatusWithIcon.test.tsx.snap
new file mode 100644
index 000000000..d2e4482ce
--- /dev/null
+++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/__snapshots__/CopyJobStatusWithIcon.test.tsx.snap
@@ -0,0 +1,208 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`CopyJobStatusWithIcon Spinner Status Types renders InProgress with spinner and expected text 1`] = `
+
+`;
+
+exports[`CopyJobStatusWithIcon Spinner Status Types renders Partitioning with spinner and expected text 1`] = `
+
+`;
+
+exports[`CopyJobStatusWithIcon Spinner Status Types renders Running with spinner and expected text 1`] = `
+
+`;
+
+exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Cancelled status correctly 1`] = `
+
+
+
+
+
+ Cancelled
+
+
+`;
+
+exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Completed status correctly 1`] = `
+
+
+
+
+
+ Completed
+
+
+`;
+
+exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Failed status correctly 1`] = `
+
+
+
+
+
+ Failed
+
+
+`;
+
+exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Faulted status correctly 1`] = `
+
+
+
+
+
+ Failed
+
+
+`;
+
+exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Paused status correctly 1`] = `
+
+
+
+
+
+ Paused
+
+
+`;
+
+exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Pending status correctly 1`] = `
+
+
+
+
+
+ Queued
+
+
+`;
+
+exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders Skipped status correctly 1`] = `
+
+
+
+
+
+ Cancelled
+
+
+`;
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobRefState.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobRefState.test.tsx
new file mode 100644
index 000000000..afdb419df
--- /dev/null
+++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobRefState.test.tsx
@@ -0,0 +1,122 @@
+import { MonitorCopyJobsRefState } from "./MonitorCopyJobRefState";
+import { MonitorCopyJobsRef } from "./MonitorCopyJobs";
+
+describe("MonitorCopyJobsRefState", () => {
+ beforeEach(() => {
+ MonitorCopyJobsRefState.setState({ ref: null });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("should initialize with null ref", () => {
+ const state = MonitorCopyJobsRefState.getState();
+ expect(state.ref).toBeNull();
+ });
+
+ it("should set ref using setRef", () => {
+ const mockRef: MonitorCopyJobsRef = {
+ refreshJobList: jest.fn(),
+ };
+
+ const state = MonitorCopyJobsRefState.getState();
+ state.setRef(mockRef);
+
+ const updatedState = MonitorCopyJobsRefState.getState();
+ expect(updatedState.ref).toBe(mockRef);
+ expect(updatedState.ref).toEqual(mockRef);
+ });
+
+ it("should allow setting ref to null", () => {
+ const mockRef: MonitorCopyJobsRef = {
+ refreshJobList: jest.fn(),
+ };
+
+ MonitorCopyJobsRefState.getState().setRef(mockRef);
+ expect(MonitorCopyJobsRefState.getState().ref).toBe(mockRef);
+
+ MonitorCopyJobsRefState.getState().setRef(null);
+ expect(MonitorCopyJobsRefState.getState().ref).toBeNull();
+ });
+
+ it("should call refreshJobList method on the stored ref", () => {
+ const mockRefreshJobList = jest.fn();
+ const mockRef: MonitorCopyJobsRef = {
+ refreshJobList: mockRefreshJobList,
+ };
+
+ MonitorCopyJobsRefState.getState().setRef(mockRef);
+
+ const state = MonitorCopyJobsRefState.getState();
+ state.ref?.refreshJobList();
+
+ expect(mockRefreshJobList).toHaveBeenCalledTimes(1);
+ });
+
+ it("should handle calling refreshJobList when ref is null", () => {
+ MonitorCopyJobsRefState.setState({ ref: null });
+
+ const state = MonitorCopyJobsRefState.getState();
+ expect(state.ref).toBeNull();
+
+ expect(() => {
+ state.ref?.refreshJobList();
+ }).not.toThrow();
+ });
+
+ it("should allow partial state updates", () => {
+ const mockRef: MonitorCopyJobsRef = {
+ refreshJobList: jest.fn(),
+ };
+
+ MonitorCopyJobsRefState.setState({ ref: mockRef });
+ const state1 = MonitorCopyJobsRefState.getState();
+ expect(state1.ref).toBe(mockRef);
+ expect(state1.setRef).toBeDefined();
+
+ const newMockRef: MonitorCopyJobsRef = {
+ refreshJobList: jest.fn(),
+ };
+ MonitorCopyJobsRefState.setState({ ref: newMockRef });
+ const state2 = MonitorCopyJobsRefState.getState();
+ expect(state2.ref).toBe(newMockRef);
+ expect(state2.setRef).toBeDefined();
+ });
+
+ it("should handle multiple subscribers", () => {
+ const mockSubscriber1 = jest.fn();
+ const mockSubscriber2 = jest.fn();
+
+ const unsubscribe1 = MonitorCopyJobsRefState.subscribe(mockSubscriber1);
+ const unsubscribe2 = MonitorCopyJobsRefState.subscribe(mockSubscriber2);
+
+ const mockRef: MonitorCopyJobsRef = {
+ refreshJobList: jest.fn(),
+ };
+
+ MonitorCopyJobsRefState.getState().setRef(mockRef);
+
+ expect(mockSubscriber1).toHaveBeenCalled();
+ expect(mockSubscriber2).toHaveBeenCalled();
+
+ unsubscribe1();
+ unsubscribe2();
+ });
+
+ it("should not notify unsubscribed listeners", () => {
+ const mockSubscriber = jest.fn();
+
+ const unsubscribe = MonitorCopyJobsRefState.subscribe(mockSubscriber);
+ unsubscribe();
+
+ const mockRef: MonitorCopyJobsRef = {
+ refreshJobList: jest.fn(),
+ };
+
+ mockSubscriber.mockClear();
+ MonitorCopyJobsRefState.getState().setRef(mockRef);
+
+ expect(mockSubscriber).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.test.tsx
new file mode 100644
index 000000000..a59ebf687
--- /dev/null
+++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.test.tsx
@@ -0,0 +1,435 @@
+import "@testing-library/jest-dom";
+import { act, render, screen, waitFor } from "@testing-library/react";
+import React from "react";
+import { DataTransferJobGetResults } from "Utils/arm/generatedClients/dataTransferService/types";
+import Explorer from "../../Explorer";
+import * as CopyJobActions from "../Actions/CopyJobActions";
+import { CopyJobStatusType } from "../Enums/CopyJobEnums";
+import { CopyJobType } from "../Types/CopyJobTypes";
+import MonitorCopyJobs from "./MonitorCopyJobs";
+
+jest.mock("Common/ShimmerTree/ShimmerTree", () => {
+ const MockShimmerTree = () => {
+ return Loading...
;
+ };
+ MockShimmerTree.displayName = "MockShimmerTree";
+ return MockShimmerTree;
+});
+
+jest.mock("./Components/CopyJobsList", () => {
+ const MockCopyJobsList = ({ jobs }: any) => {
+ return Jobs: {jobs.length}
;
+ };
+ MockCopyJobsList.displayName = "MockCopyJobsList";
+ return MockCopyJobsList;
+});
+
+jest.mock("./Components/CopyJobs.NotFound", () => {
+ const MockCopyJobsNotFound = () => {
+ return No jobs found
;
+ };
+ MockCopyJobsNotFound.displayName = "MockCopyJobsNotFound";
+ return MockCopyJobsNotFound;
+});
+
+jest.mock("../Actions/CopyJobActions", () => ({
+ getCopyJobs: jest.fn(),
+ updateCopyJobStatus: jest.fn(),
+}));
+
+describe("MonitorCopyJobs", () => {
+ let mockExplorer: Explorer;
+ const mockGetCopyJobs = CopyJobActions.getCopyJobs as jest.MockedFunction;
+ const mockUpdateCopyJobStatus = CopyJobActions.updateCopyJobStatus as jest.MockedFunction<
+ typeof CopyJobActions.updateCopyJobStatus
+ >;
+
+ const mockJobs: CopyJobType[] = [
+ {
+ ID: "1",
+ Mode: "Offline",
+ Name: "test-job-1",
+ Status: CopyJobStatusType.InProgress,
+ CompletionPercentage: 50,
+ Duration: "10 minutes",
+ LastUpdatedTime: "1/1/2024, 10:00:00 AM",
+ timestamp: 1704110400000,
+ Source: {
+ component: "CosmosDBSql",
+ databaseName: "db1",
+ containerName: "container1",
+ },
+ Destination: {
+ component: "CosmosDBSql",
+ databaseName: "db2",
+ containerName: "container2",
+ },
+ },
+ {
+ ID: "2",
+ Mode: "Online",
+ Name: "test-job-2",
+ Status: CopyJobStatusType.Completed,
+ CompletionPercentage: 100,
+ Duration: "20 minutes",
+ LastUpdatedTime: "1/1/2024, 11:00:00 AM",
+ timestamp: 1704114000000,
+ Source: {
+ component: "CosmosDBSql",
+ databaseName: "db3",
+ containerName: "container3",
+ },
+ Destination: {
+ component: "CosmosDBSql",
+ databaseName: "db4",
+ containerName: "container4",
+ },
+ },
+ ];
+
+ beforeEach(() => {
+ mockExplorer = {} as Explorer;
+ mockGetCopyJobs.mockResolvedValue(mockJobs);
+ mockUpdateCopyJobStatus.mockResolvedValue({
+ id: "test-id",
+ type: "Microsoft.DocumentDB/databaseAccounts/dataTransferJobs",
+ properties: {
+ jobName: "test-job-1",
+ status: "Paused",
+ lastUpdatedUtcTime: "2024-01-01T10:00:00Z",
+ processedCount: 500,
+ totalCount: 1000,
+ mode: "Offline",
+ duration: "00:10:00",
+ source: {
+ databaseName: "db1",
+ containerName: "container1",
+ component: "CosmosDBSql",
+ },
+ destination: {
+ databaseName: "db2",
+ containerName: "container2",
+ component: "CosmosDBSql",
+ },
+ error: {
+ message: "",
+ code: "",
+ },
+ },
+ } as DataTransferJobGetResults);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("Initial Rendering", () => {
+ it("renders the component with correct structure", async () => {
+ render( );
+
+ const container = document.querySelector(".monitorCopyJobs");
+ expect(container).toBeInTheDocument();
+ expect(container).toHaveClass("flexContainer");
+
+ await waitFor(() => {
+ expect(mockGetCopyJobs).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it("displays shimmer while loading initially", () => {
+ render( );
+
+ expect(screen.getByTestId("shimmer-tree")).toBeInTheDocument();
+ expect(screen.getByText("Loading...")).toBeInTheDocument();
+ });
+
+ it("fetches jobs on mount", async () => {
+ render( );
+
+ await waitFor(() => {
+ expect(mockGetCopyJobs).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe("Job List Display", () => {
+ it("displays job list when jobs are loaded", async () => {
+ render( );
+
+ await waitFor(
+ () => {
+ expect(screen.getByTestId("copy-jobs-list")).toBeInTheDocument();
+ },
+ { timeout: 3000 },
+ );
+
+ expect(screen.getByText("Jobs: 2")).toBeInTheDocument();
+ expect(screen.queryByTestId("shimmer-tree")).not.toBeInTheDocument();
+ });
+
+ it("displays not found component when no jobs exist", async () => {
+ mockGetCopyJobs.mockResolvedValue([]);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("copy-jobs-not-found")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("No jobs found")).toBeInTheDocument();
+ expect(screen.queryByTestId("copy-jobs-list")).not.toBeInTheDocument();
+ });
+
+ it("passes correct jobs to CopyJobsList component", async () => {
+ render( );
+ await waitFor(() => {
+ expect(screen.getByTestId("copy-jobs-list")).toBeInTheDocument();
+ });
+ expect(screen.getByText("Jobs: 2")).toBeInTheDocument();
+ });
+
+ it("updates job status when action is triggered", async () => {
+ const ref = React.createRef();
+ render( );
+ await waitFor(() => {
+ expect(screen.getByTestId("copy-jobs-list")).toBeInTheDocument();
+ });
+ expect(mockJobs[0].Status).toBe(CopyJobStatusType.InProgress);
+ });
+ });
+
+ describe("Error Handling", () => {
+ it("displays error message when fetch fails", async () => {
+ const errorMessage = "Failed to load copy jobs. Please try again later.";
+ mockGetCopyJobs.mockRejectedValue(new Error(errorMessage));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText(errorMessage)).toBeInTheDocument();
+ });
+
+ expect(screen.queryByTestId("shimmer-tree")).not.toBeInTheDocument();
+ });
+
+ it("allows dismissing error message", async () => {
+ mockGetCopyJobs.mockRejectedValue(new Error("Failed to load copy jobs"));
+ const { container } = render( );
+ await waitFor(() => {
+ expect(screen.getByText(/Failed to load copy jobs/)).toBeInTheDocument();
+ });
+
+ const dismissButton = container.querySelector('[aria-label="Close"]');
+ if (dismissButton) {
+ dismissButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ }
+ await waitFor(() => {
+ expect(screen.queryByText(/Failed to load copy jobs/)).not.toBeInTheDocument();
+ });
+ });
+
+ it("displays custom error message from getCopyJobs", async () => {
+ const customError = { message: "Custom error occurred" };
+ mockGetCopyJobs.mockRejectedValue(customError);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Custom error occurred")).toBeInTheDocument();
+ });
+ });
+
+ it("displays error when job action update fails", async () => {
+ mockUpdateCopyJobStatus.mockRejectedValue(new Error("Update failed"));
+
+ const ref = React.createRef();
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("copy-jobs-list")).toBeInTheDocument();
+ });
+
+ const mockHandleActionClick = jest.fn(async (job, action, setUpdatingJobAction) => {
+ setUpdatingJobAction({ jobName: job.Name, action });
+ await mockUpdateCopyJobStatus(job, action);
+ });
+
+ await expect(mockHandleActionClick(mockJobs[0], "pause", jest.fn())).rejects.toThrow("Update failed");
+ });
+ });
+
+ describe("Polling and Refresh", () => {
+ it.skip("polls for jobs at regular intervals", async () => {
+ render( );
+
+ await waitFor(() => {
+ expect(mockGetCopyJobs).toHaveBeenCalledTimes(1);
+ });
+
+ act(() => {
+ jest.advanceTimersByTime(30000);
+ });
+
+ await waitFor(() => {
+ expect(mockGetCopyJobs).toHaveBeenCalledTimes(2);
+ });
+
+ act(() => {
+ jest.advanceTimersByTime(30000);
+ });
+
+ await waitFor(() => {
+ expect(mockGetCopyJobs).toHaveBeenCalledTimes(3);
+ });
+ });
+
+ it("stops polling when component unmounts", async () => {
+ const { unmount } = render( );
+
+ await waitFor(() => {
+ expect(mockGetCopyJobs).toHaveBeenCalledTimes(1);
+ });
+
+ unmount();
+
+ act(() => {
+ jest.advanceTimersByTime(60000);
+ });
+
+ expect(mockGetCopyJobs).toHaveBeenCalledTimes(1);
+ });
+
+ it("refreshes job list via ref", async () => {
+ const ref = React.createRef();
+ render( );
+
+ await waitFor(() => {
+ expect(mockGetCopyJobs).toHaveBeenCalledTimes(1);
+ });
+
+ act(() => {
+ ref.current?.refreshJobList();
+ });
+
+ await waitFor(() => {
+ expect(mockGetCopyJobs).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it("prevents refresh when update is in progress", async () => {
+ const ref = React.createRef();
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("copy-jobs-list")).toBeInTheDocument();
+ });
+
+ mockUpdateCopyJobStatus.mockImplementation(
+ () =>
+ new Promise((resolve) =>
+ setTimeout(
+ () =>
+ resolve({
+ id: "test-id",
+ type: "Microsoft.DocumentDB/databaseAccounts/dataTransferJobs",
+ properties: {
+ jobName: "test-job-1",
+ status: "Paused",
+ lastUpdatedUtcTime: "2024-01-01T10:00:00Z",
+ processedCount: 500,
+ totalCount: 1000,
+ mode: "Offline",
+ duration: "00:10:00",
+ source: {
+ databaseName: "db1",
+ collectionName: "container1",
+ component: "CosmosDBSql",
+ },
+ destination: {
+ databaseName: "db2",
+ collectionName: "container2",
+ component: "CosmosDBSql",
+ },
+ error: {
+ message: "",
+ code: "",
+ },
+ },
+ } as DataTransferJobGetResults),
+ 5000,
+ ),
+ ),
+ );
+
+ expect(ref.current).toHaveProperty("refreshJobList");
+ expect(typeof ref.current.refreshJobList).toBe("function");
+ });
+ });
+
+ describe("Edge Cases", () => {
+ it("handles empty job array", async () => {
+ mockGetCopyJobs.mockResolvedValue([]);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("copy-jobs-not-found")).toBeInTheDocument();
+ });
+ });
+
+ it("handles null response from getCopyJobs gracefully", async () => {
+ mockGetCopyJobs.mockResolvedValue(null as any);
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("copy-jobs-not-found")).toBeInTheDocument();
+ });
+ });
+
+ it("handles explorer prop correctly", () => {
+ const { rerender } = render( );
+
+ const newExplorer = {} as Explorer;
+ rerender( );
+
+ expect(document.querySelector(".monitorCopyJobs")).toBeInTheDocument();
+ });
+ });
+
+ describe("Ref Handle", () => {
+ it("exposes refreshJobList method through ref", () => {
+ const ref = React.createRef();
+ render( );
+
+ expect(ref.current).toBeDefined();
+ expect(ref.current).toHaveProperty("refreshJobList");
+ expect(typeof ref.current.refreshJobList).toBe("function");
+ });
+
+ it("refreshJobList triggers getCopyJobs", async () => {
+ const ref = React.createRef();
+ render( );
+
+ await waitFor(() => {
+ expect(mockGetCopyJobs).toHaveBeenCalledTimes(1);
+ });
+
+ ref.current?.refreshJobList();
+
+ await waitFor(() => {
+ expect(mockGetCopyJobs).toHaveBeenCalledTimes(2);
+ });
+ });
+ });
+
+ describe("Action Callback", () => {
+ it("provides handleActionClick callback to CopyJobsList", async () => {
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("copy-jobs-list")).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx
index 7278bc26c..c89488cbc 100644
--- a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx
+++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx
@@ -4,13 +4,14 @@ import ShimmerTree, { IndentLevel } from "Common/ShimmerTree/ShimmerTree";
import Explorer from "Explorer/Explorer";
import React, { forwardRef, useEffect, useImperativeHandle } from "react";
import { getCopyJobs, updateCopyJobStatus } from "../Actions/CopyJobActions";
-import { convertToCamelCase } from "../CopyJobUtils";
+import { convertToCamelCase, isEqual } from "../CopyJobUtils";
import { CopyJobStatusType } from "../Enums/CopyJobEnums";
import CopyJobsNotFound from "../MonitorCopyJobs/Components/CopyJobs.NotFound";
import { CopyJobType, JobActionUpdatorType } from "../Types/CopyJobTypes";
import CopyJobsList from "./Components/CopyJobsList";
-const FETCH_INTERVAL_MS = 30 * 1000;
+const FETCH_INTERVAL = 2 * 60 * 1000;
+const SHIMMER_INDENT_LEVELS: IndentLevel[] = Array(7).fill({ level: 0, width: "100%" });
interface MonitorCopyJobsProps {
explorer: Explorer;
@@ -27,8 +28,6 @@ const MonitorCopyJobs = forwardRef(({
const isUpdatingRef = React.useRef(false);
const isFirstFetchRef = React.useRef(true);
- const indentLevels = React.useMemo(() => Array(7).fill({ level: 0, width: "100%" }), []);
-
const fetchJobs = React.useCallback(async () => {
if (isUpdatingRef.current) {
return;
@@ -40,12 +39,14 @@ const MonitorCopyJobs = forwardRef(({
setError(null);
const response = await getCopyJobs();
+ const normalizedResponse = response || [];
setJobs((prevJobs) => {
- const isSame = JSON.stringify(prevJobs) === JSON.stringify(response);
- return isSame ? prevJobs : response;
+ return isEqual(prevJobs, normalizedResponse) ? prevJobs : normalizedResponse;
});
} catch (error) {
- setError(error.message || "Failed to load copy jobs. Please try again later.");
+ if (error.message !== "Previous copy job request was cancelled.") {
+ setError(error.message || "Failed to load copy jobs. Please try again later.");
+ }
} finally {
if (isFirstFetchRef.current) {
setLoading(false);
@@ -56,7 +57,7 @@ const MonitorCopyJobs = forwardRef(({
useEffect(() => {
fetchJobs();
- const intervalId = setInterval(fetchJobs, FETCH_INTERVAL_MS);
+ const intervalId = setInterval(fetchJobs, FETCH_INTERVAL);
return () => clearInterval(intervalId);
}, [fetchJobs]);
@@ -99,27 +100,27 @@ const MonitorCopyJobs = forwardRef(({
[],
);
- const renderJobsList = () => {
- if (loading) {
- return null;
- }
- if (jobs.length > 0) {
- return ;
- }
- return ;
- };
-
return (
- {loading && }
+ {loading && (
+
+ )}
{error && (
- setError(null)}>
+ setError(null)}
+ dismissButtonAriaLabel="Close"
+ >
{error}
)}
- {renderJobsList()}
+ {!loading && jobs.length > 0 && }
+ {!loading && jobs.length === 0 && }
);
});
+MonitorCopyJobs.displayName = "MonitorCopyJobs";
+
export default MonitorCopyJobs;
diff --git a/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts b/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts
index e9ebbd0da..c004f3f9b 100644
--- a/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts
+++ b/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts
@@ -49,6 +49,7 @@ export interface DatabaseContainerSectionProps {
containerDisabled?: boolean;
containerOnChange: (ev: React.FormEvent, option: DropdownOptionType) => void;
handleOnDemandCreateContainer?: () => void;
+ sectionType: "source" | "target";
}
export interface CopyJobContextState {
@@ -56,14 +57,14 @@ export interface CopyJobContextState {
migrationType: CopyJobMigrationType;
sourceReadAccessFromTarget?: boolean;
source: {
- subscription: Subscription;
- account: DatabaseAccount;
+ subscription: Subscription | null;
+ account: DatabaseAccount | null;
databaseId: string;
containerId: string;
};
target: {
subscriptionId: string;
- account: DatabaseAccount;
+ account: DatabaseAccount | null;
databaseId: string;
containerId: string;
};
diff --git a/src/Explorer/ContainerCopy/containerCopyStyles.less b/src/Explorer/ContainerCopy/containerCopyStyles.less
index 05d9facec..6f99f4055 100644
--- a/src/Explorer/ContainerCopy/containerCopyStyles.less
+++ b/src/Explorer/ContainerCopy/containerCopyStyles.less
@@ -1,6 +1,30 @@
@import "../../../less/Common/Constants.less";
+// Common theme-aware classes
+.themeText {
+ color: var(--colorNeutralForeground1);
+}
+
+.themeTextSecondary {
+ color: var(--colorNeutralForeground2);
+}
+
+.themeLinkText {
+ color: var(--colorBrandForeground1);
+}
+
+.themeBackground {
+ background-color: var(--colorNeutralBackground1);
+}
+
+.themeBackgroundSecondary {
+ background-color: var(--colorNeutralBackground2);
+}
+
#containerCopyWrapper {
+ background-color: var(--colorNeutralBackground1);
+ color: var(--colorNeutralForeground1);
+
.centerContent {
justify-content: center;
align-items: center;
@@ -9,20 +33,30 @@
.noCopyJobsMessage {
font-weight: 600;
margin: 0 auto;
- color: @FocusColor;
+ color: var(--colorNeutralForeground2);
}
button.createCopyJobButton {
- color: @LinkColor;
+ color: var(--colorBrandForeground1);
}
}
}
.createCopyJobScreensContainer {
height: 100%;
padding: 1em 1.5em;
+ background-color: var(--colorNeutralBackground1);
+ color: var(--colorNeutralForeground1);
.pointInTimeRestoreContainer, .onlineCopyContainer {
position: relative;
}
+
+ .toggle-label {
+ color: var(--colorNeutralForeground1);
+ }
+
+ .accordionHeaderText {
+ color: var(--colorNeutralForeground1);
+ }
label {
padding: 0;
@@ -71,7 +105,7 @@
}
.foreground {
z-index: 10;
- background-color: #f9f9f9;
+ background-color: var(--colorNeutralBackground2);
padding: 20px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transform: translate(0%, -9%);
@@ -80,14 +114,48 @@
.createCopyJobErrorMessageBar {
margin-bottom: 2em;
}
+ body.isDarkMode & {
+ .ms-TooltipHost .ms-Image {
+ filter: invert(1);
+ }
+
+ .ms-TextField {
+ .ms-TextField-fieldGroup {
+ background-color: var(--colorNeutralBackground1);
+ border-color: var(--colorNeutralStroke1);
+ }
+
+ .ms-TextField-field {
+ color: var(--colorNeutralForeground1);
+ background-color: var(--colorNeutralBackground1);
+
+ &::placeholder {
+ color: var(--colorNeutralForeground4);
+ }
+ }
+
+ .ms-Label {
+ color: var(--colorNeutralForeground1);
+ }
+ }
+ .migrationTypeDescription {
+ p {
+ color: var(--colorNeutralForeground1);
+ }
+ a {
+ color: var(--colorBrandForeground1);
+ }
+ }
+ }
.create-container-link-btn {
padding: 0;
height: 25px;
- color: @LinkColor;
+ color: var(--colorBrandForeground1);
&:focus {
outline: none;
}
+
}
/* Create collection panel */
@@ -105,7 +173,6 @@
width: 100%;
max-width: 100%;
margin: 0 auto;
-
.ms-DetailsList {
width: 100%;
@@ -114,33 +181,36 @@
padding: @DefaultSpace 20px;
font-weight: 600;
font-size: @DefaultFontSize;
- color: @BaseHigh;
- background-color: @BaseLow;
- border-bottom: @ButtonBorderWidth solid @BaseMedium;
+ color: var(--colorNeutralForeground1);
+ background-color: var(--colorNeutralBackground2);
+ border-bottom: @ButtonBorderWidth solid var(--colorNeutralStroke1);
&:hover {
- background-color: @BaseMediumLow;
+ background-color: var(--colorNeutralBackground3);
}
}
+ .ms-DetailsHeader-cellTitle {
+ padding-left: 20px;
+ }
}
.ms-DetailsRow {
- border-bottom: @ButtonBorderWidth solid @BaseMedium;
+ border-bottom: @ButtonBorderWidth solid var(--colorNeutralStroke1);
&:hover {
- background-color: @BaseMediumLow;
+ background-color: var(--colorNeutralBackground2);
}
.ms-DetailsRow-cell {
padding: @MediumSpace 20px;
font-size: @DefaultFontSize;
- color: @BaseHigh;
+ color: var(--colorNeutralForeground1);
min-height: 48px;
display: flex;
align-items: center;
.jobNameLink {
- color: @LinkColor;
+ color: var(--colorBrandForeground1);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
@@ -168,7 +238,7 @@
}
.ms-DetailsRow-cell {
font-size: @DefaultFontSize;
- color: @BaseHigh;
+ color: var(--colorNeutralForeground1);
}
}
}
diff --git a/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx b/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx
index 3f0fa6d2c..7d7510626 100644
--- a/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx
+++ b/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx
@@ -58,7 +58,7 @@ export class CollapsibleSectionComponent extends React.Component
- {this.props.title}
+ {this.props.title}
{this.props.tooltipContent && (
{
event.stopPropagation();
this.props.onDelete();
diff --git a/src/Explorer/Controls/CollapsiblePanel/__snapshots__/CollapsibleSectionComponent.test.tsx.snap b/src/Explorer/Controls/CollapsiblePanel/__snapshots__/CollapsibleSectionComponent.test.tsx.snap
index 538ab00e7..ed981c5d6 100644
--- a/src/Explorer/Controls/CollapsiblePanel/__snapshots__/CollapsibleSectionComponent.test.tsx.snap
+++ b/src/Explorer/Controls/CollapsiblePanel/__snapshots__/CollapsibleSectionComponent.test.tsx.snap
@@ -20,7 +20,15 @@ exports[`CollapsibleSectionComponent renders 1`] = `
-
+
Sample title
diff --git a/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx b/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx
index b6e847d54..168962312 100644
--- a/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx
+++ b/src/Explorer/Controls/CommandButton/CommandButtonComponent.tsx
@@ -58,6 +58,26 @@ export interface CommandButtonComponentProps {
*/
tooltipText?: string;
+ /**
+ * Custom styles to apply to the button using Fluent UI theme tokens
+ */
+ styles?: {
+ root?: {
+ backgroundColor?: string;
+ color?: string;
+ selectors?: {
+ ":hover"?: {
+ backgroundColor?: string;
+ color?: string;
+ };
+ ":active"?: {
+ backgroundColor?: string;
+ color?: string;
+ };
+ };
+ };
+ };
+
/**
* tabindex for the command button
*/
@@ -250,6 +270,8 @@ export class CommandButtonComponent extends React.Component
) => this.commandClickCallback(e)}
>
diff --git a/src/Explorer/Controls/Dialog.tsx b/src/Explorer/Controls/Dialog.tsx
index a4c50a3fd..d02eb1120 100644
--- a/src/Explorer/Controls/Dialog.tsx
+++ b/src/Explorer/Controls/Dialog.tsx
@@ -179,8 +179,18 @@ export const Dialog: FC = () => {
title,
subText,
styles: {
- title: { fontSize: DIALOG_TITLE_FONT_SIZE, fontWeight: DIALOG_TITLE_FONT_WEIGHT },
- subText: { fontSize: DIALOG_SUBTEXT_FONT_SIZE },
+ title: {
+ fontSize: DIALOG_TITLE_FONT_SIZE,
+ fontWeight: DIALOG_TITLE_FONT_WEIGHT,
+ },
+ subText: {
+ fontSize: DIALOG_SUBTEXT_FONT_SIZE,
+ color: "var(--colorNeutralForeground2)",
+ },
+ content: {
+ backgroundColor: "var(--colorNeutralBackground1)",
+ color: "var(--colorNeutralForeground1)",
+ },
},
showCloseButton: showCloseButton || false,
onDismiss,
@@ -188,18 +198,60 @@ export const Dialog: FC = () => {
modalProps: { isBlocking: isModal, isDarkOverlay: false },
minWidth: DIALOG_MIN_WIDTH,
maxWidth: DIALOG_MAX_WIDTH,
+ styles: {
+ main: {
+ backgroundColor: "var(--colorNeutralBackground1)",
+ selectors: {
+ ".ms-Dialog-title": { color: "var(--colorNeutralForeground1)" },
+ },
+ },
+ },
};
const primaryButtonProps: IButtonProps = {
text: primaryButtonText,
disabled: primaryButtonDisabled || false,
onClick: onPrimaryButtonClick,
+ styles: {
+ root: {
+ backgroundColor: "var(--colorBrandBackground)",
+ color: "var(--colorNeutralForegroundOnBrand)",
+ selectors: {
+ ":hover": {
+ backgroundColor: "var(--colorBrandBackgroundHover)",
+ color: "var(--colorNeutralForegroundOnBrand)",
+ },
+ ":active": {
+ backgroundColor: "var(--colorBrandBackgroundPressed)",
+ color: "var(--colorNeutralForegroundOnBrand)",
+ },
+ },
+ },
+ },
};
const secondaryButtonProps: IButtonProps =
secondaryButtonText && onSecondaryButtonClick
? {
text: secondaryButtonText,
onClick: onSecondaryButtonClick,
+ styles: {
+ root: {
+ backgroundColor: "var(--colorNeutralBackground2)",
+ color: "var(--colorNeutralForeground1)",
+ borderColor: "var(--colorNeutralStroke1)",
+ selectors: {
+ ":hover": {
+ backgroundColor: "var(--colorNeutralBackground3)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ ":active": {
+ backgroundColor: "var(--colorNeutralBackground3)",
+ color: "var(--colorNeutralForeground1)",
+ borderColor: "var(--colorCompoundBrandStroke1)",
+ },
+ },
+ },
+ },
}
: undefined;
return visible ? (
diff --git a/src/Explorer/Controls/Editor/EditorReact.tsx b/src/Explorer/Controls/Editor/EditorReact.tsx
index 9ca544631..bfd3f103a 100644
--- a/src/Explorer/Controls/Editor/EditorReact.tsx
+++ b/src/Explorer/Controls/Editor/EditorReact.tsx
@@ -1,4 +1,5 @@
import { Spinner, SpinnerSize } from "@fluentui/react";
+import { monacoTheme, useThemeStore } from "hooks/useTheme";
import * as React from "react";
import { loadMonaco, monaco } from "../../LazyMonaco";
// import "./EditorReact.less";
@@ -66,6 +67,7 @@ export class EditorReact extends React.Component
void;
monacoApi: {
default: typeof monaco;
Emitter: typeof monaco.Emitter;
@@ -94,6 +96,13 @@ export class EditorReact extends React.Component {
+ if (this.editor) {
+ const newTheme = state.isDarkMode ? "vs-dark" : "vs";
+ this.monacoApi?.editor.setTheme(newTheme);
+ }
+ });
+
setTimeout(() => {
const suggestionWidget = this.editor?.getDomNode()?.querySelector(".suggest-widget") as HTMLElement;
if (suggestionWidget) {
@@ -128,6 +137,7 @@ export class EditorReact extends React.Component = {
fieldGroup: {
height: 27,
+ backgroundColor: "var(--colorNeutralBackground2)",
+ borderColor: "var(--colorNeutralStroke1)",
},
field: {
fontSize: 12,
padding: "0 8px",
+ color: "var(--colorNeutralForeground1)",
+ backgroundColor: "var(--colorNeutralBackground2)",
+ },
+ root: {
+ selectors: {
+ input: {
+ backgroundColor: "var(--colorNeutralBackground2)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ "input:hover": {
+ backgroundColor: "var(--colorNeutralBackground2)",
+ borderColor: "var(--colorNeutralStroke1)",
+ },
+ "input:focus": {
+ backgroundColor: "var(--colorNeutralBackground2)",
+ borderColor: "var(--colorBrandBackground)",
+ },
+ },
},
};
-const dropdownStyles = {
- title: {
- height: 27,
- lineHeight: "24px",
- fontSize: 12,
+const dropdownStyles: Partial = {
+ root: {
+ width: "40%",
+ marginTop: "10px",
+ selectors: {
+ "&:hover .ms-Dropdown-title": {
+ color: "var(--colorNeutralForeground1)",
+ backgroundColor: "var(--colorNeutralBackground2)",
+ borderColor: "var(--colorNeutralStroke1)",
+ },
+ "&:hover span.ms-Dropdown-title": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ "&:focus .ms-Dropdown-title": {
+ color: "var(--colorNeutralForeground1)",
+ backgroundColor: "var(--colorNeutralBackground2)",
+ },
+ "&:focus span.ms-Dropdown-title": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ label: {
+ color: "var(--colorNeutralForeground1)",
},
dropdown: {
- height: 27,
- lineHeight: "24px",
+ backgroundColor: "var(--colorNeutralBackground2)",
+ borderColor: "var(--colorNeutralStroke1)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ title: {
+ backgroundColor: "var(--colorNeutralBackground2)",
+ color: "var(--colorNeutralForeground1)",
+ borderColor: "var(--colorNeutralStroke1)",
+ selectors: {
+ "&:hover": {
+ backgroundColor: "var(--colorNeutralBackground2)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ "&:focus": {
+ backgroundColor: "var(--colorNeutralBackground2)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ "&:hover .ms-Dropdown-titleText": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ "&:focus .ms-Dropdown-titleText": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ "& .ms-Dropdown-titleText": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ "&.ms-Dropdown-title--hasPlaceholder": {
+ color: "var(--colorNeutralForeground2)",
+ },
+ },
+ },
+ errorMessage: {
+ color: "var(--colorNeutralForeground1)",
+ },
+ caretDown: {
+ color: "var(--colorNeutralForeground1)",
+ },
+ callout: {
+ backgroundColor: "var(--colorNeutralBackground2)",
+ border: "1px solid var(--colorNeutralStroke1)",
+ },
+ dropdownItems: {
+ backgroundColor: "var(--colorNeutralBackground2)",
},
dropdownItem: {
- fontSize: 12,
+ backgroundColor: "transparent",
+ color: "var(--colorNeutralForeground1)",
+ minHeight: "36px",
+ lineHeight: "36px",
+ selectors: {
+ "&:hover": {
+ backgroundColor: "rgba(255, 255, 255, 0.1)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ "&:hover .ms-Dropdown-optionText": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ "&:focus": {
+ backgroundColor: "rgba(255, 255, 255, 0.1)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ "&:active": {
+ backgroundColor: "rgba(255, 255, 255, 0.15)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ "& .ms-Dropdown-optionText": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ dropdownItemSelected: {
+ backgroundColor: "rgba(255, 255, 255, 0.08)",
+ color: "var(--colorNeutralForeground1)",
+ minHeight: "36px",
+ lineHeight: "36px",
+ selectors: {
+ "&:hover": {
+ backgroundColor: "rgba(255, 255, 255, 0.1)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ "&:hover .ms-Dropdown-optionText": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ "&:focus": {
+ backgroundColor: "rgba(255, 255, 255, 0.1)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ "&:active": {
+ backgroundColor: "rgba(255, 255, 255, 0.15)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ "& .ms-Dropdown-optionText": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ dropdownOptionText: {
+ color: "var(--colorNeutralForeground1)",
+ },
+ dropdownItemHeader: {
+ color: "var(--colorNeutralForeground1)",
},
};
@@ -226,7 +363,32 @@ export const FullTextPoliciesComponent: React.FunctionComponent
))}
-
+
Add full text path
diff --git a/src/Explorer/Controls/Settings/SettingsComponent.less b/src/Explorer/Controls/Settings/SettingsComponent.less
index 5eb2b13be..85b4b4373 100644
--- a/src/Explorer/Controls/Settings/SettingsComponent.less
+++ b/src/Explorer/Controls/Settings/SettingsComponent.less
@@ -4,6 +4,8 @@
height: 100%;
overflow-y: auto;
width: 100%;
+ background-color: var(--colorNeutralBackground1);
+ color: var(--colorNeutralForeground1);
}
.settingsV2ToolTip {
@@ -23,6 +25,8 @@
overflow-y: auto;
width: 100%;
font-family: @DataExplorerFont;
+ background-color: var(--colorNeutralBackground1);
+ color: var(--colorNeutralForeground1);
}
.settingsV2Editor {
diff --git a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx
index e6e57f1c7..7dfd49b11 100644
--- a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx
+++ b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx
@@ -1,5 +1,8 @@
+import { IndexingPolicy } from "@azure/cosmos";
+import { act } from "@testing-library/react";
import { AuthType } from "AuthType";
import { shallow } from "enzyme";
+import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
import ko from "knockout";
import React from "react";
import { updateCollection } from "../../../Common/dataAccess/updateCollection";
@@ -444,3 +447,49 @@ describe("SettingsComponent", () => {
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true);
});
});
+
+describe("SettingsComponent - indexing policy subscription", () => {
+ const baseProps: SettingsComponentProps = {
+ settingsTab: new CollectionSettingsTabV2({
+ collection: collection,
+ tabKind: ViewModels.CollectionTabKind.CollectionSettingsV2,
+ title: "Scale & Settings",
+ tabPath: "",
+ node: undefined,
+ }),
+ };
+
+ it("subscribes to the correct container's indexing policy and updates state on change", async () => {
+ const containerId = collection.id();
+ const mockIndexingPolicy: IndexingPolicy = {
+ automatic: false,
+ indexingMode: "lazy",
+ includedPaths: [{ path: "/foo/*" }],
+ excludedPaths: [{ path: "/bar/*" }],
+ compositeIndexes: [],
+ spatialIndexes: [],
+ vectorIndexes: [],
+ fullTextIndexes: [],
+ };
+
+ const wrapper = shallow( );
+ const instance = wrapper.instance() as SettingsComponent;
+
+ await act(async () => {
+ useIndexingPolicyStore.setState({
+ indexingPolicies: {
+ [containerId]: mockIndexingPolicy,
+ },
+ });
+ // Wait for the async refreshCollectionData to complete
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ wrapper.update();
+
+ expect(wrapper.state("indexingPolicyContent")).toEqual(mockIndexingPolicy);
+ expect(wrapper.state("indexingPolicyContentBaseline")).toEqual(mockIndexingPolicy);
+ // @ts-expect-error: rawDataModel is intentionally accessed for test validation
+ expect(instance.collection.rawDataModel.indexingPolicy).toEqual(mockIndexingPolicy);
+ });
+});
diff --git a/src/Explorer/Controls/Settings/SettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsComponent.tsx
index c3c7be1cb..95f7159cc 100644
--- a/src/Explorer/Controls/Settings/SettingsComponent.tsx
+++ b/src/Explorer/Controls/Settings/SettingsComponent.tsx
@@ -1,4 +1,4 @@
-import { IPivotItemProps, IPivotProps, Pivot, PivotItem } from "@fluentui/react";
+import { IPivotItemProps, IPivotProps, Pivot, PivotItem, Stack } from "@fluentui/react";
import { sendMessage } from "Common/MessageHandler";
import { FabricMessageTypes } from "Contracts/FabricMessageTypes";
import {
@@ -13,6 +13,7 @@ import {
ThroughputBucketsComponent,
ThroughputBucketsComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent";
+import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
import { useDatabases } from "Explorer/useDatabases";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
@@ -73,7 +74,6 @@ import {
parseConflictResolutionMode,
parseConflictResolutionProcedure,
} from "./SettingsUtils";
-
interface SettingsV2TabInfo {
tab: SettingsV2TabTypes;
content: JSX.Element;
@@ -182,7 +182,7 @@ export class SettingsComponent extends React.Component void;
constructor(props: SettingsComponentProps) {
super(props);
@@ -312,6 +312,13 @@ export class SettingsComponent extends React.Component {
+ this.refreshCollectionData();
+ },
+ (state) => state.indexingPolicies[this.collection?.id()],
+ );
+ this.refreshCollectionData();
}
this.setBaseline();
@@ -319,7 +326,11 @@ export class SettingsComponent extends React.Component => {
+ const containerId = this.collection.id();
+ const latestIndexingPolicy = useIndexingPolicyStore.getState().indexingPolicies[containerId];
+ const rawPolicy = latestIndexingPolicy ?? this.collection.indexingPolicy();
+
+ const latestCollection: DataModels.IndexingPolicy = {
+ automatic: rawPolicy?.automatic ?? true,
+ indexingMode: rawPolicy?.indexingMode ?? "consistent",
+ includedPaths: rawPolicy?.includedPaths ?? [],
+ excludedPaths: rawPolicy?.excludedPaths ?? [],
+ compositeIndexes: rawPolicy?.compositeIndexes ?? [],
+ spatialIndexes: rawPolicy?.spatialIndexes ?? [],
+ vectorIndexes: rawPolicy?.vectorIndexes ?? [],
+ fullTextIndexes: rawPolicy?.fullTextIndexes ?? [],
+ };
+
+ this.collection.rawDataModel.indexingPolicy = latestCollection;
+ this.setState({
+ indexingPolicyContent: latestCollection,
+ indexingPolicyContentBaseline: latestCollection,
+ });
+ };
private saveCollectionSettings = async (startKey: number): Promise => {
const newCollection: DataModels.Collection = { ...this.collection.rawDataModel };
-
if (
this.state.isSubSettingsSaveable ||
this.state.isContainerPolicyDirty ||
@@ -1252,7 +1283,6 @@ export class SettingsComponent extends React.Component
@@ -1477,28 +1507,111 @@ export class SettingsComponent extends React.Component {
- const pivotItemProps: IPivotItemProps = {
- itemKey: SettingsV2TabTypes[tab.tab],
- style: { marginTop: 20 },
- headerText: getTabTitle(tab.tab),
- };
+ const pivotStyles = {
+ root: {
+ backgroundColor: "var(--colorNeutralBackground1)",
+ color: "var(--colorNeutralForeground1)",
+ selectors: {
+ "& .ms-Pivot-link": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ "& .ms-Pivot-link.is-selected::before": {
+ backgroundColor: "var(--colorCompoundBrandBackground)",
+ },
+ },
+ },
+ link: {
+ backgroundColor: "var(--colorNeutralBackground1)",
+ color: "var(--colorNeutralForeground1)",
+ selectors: {
+ "&:hover": {
+ backgroundColor: "var(--colorNeutralBackground1)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ "&:active": {
+ backgroundColor: "var(--colorNeutralBackground1)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ '&[aria-selected="true"]': {
+ backgroundColor: "var(--colorNeutralBackground1)",
+ color: "var(--colorNeutralForeground1)",
+ selectors: {
+ "&:hover": {
+ backgroundColor: "var(--colorNeutralBackground1)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ "&:active": {
+ backgroundColor: "var(--colorNeutralBackground1)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ },
+ },
- return (
-
- {tab.content}
-
- );
- });
+ itemContainer: {
+ // padding: '20px 24px',
+ backgroundColor: "var(--colorNeutralBackground1)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ };
+
+ const contentStyles = {
+ root: {
+ backgroundColor: "var(--colorNeutralBackground1)",
+ color: "var(--colorNeutralForeground1)",
+ // padding: '20px 24px'
+ },
+ };
return (
-
+
{this.shouldShowKeyspaceSharedThroughputMessage() && (
This table shared throughput is configured at the keyspace
)}
-
-
{pivotItems}
+
+
+ {tabs.map((tab) => {
+ const pivotItemProps: IPivotItemProps = {
+ itemKey: SettingsV2TabTypes[tab.tab],
+ style: {
+ marginTop: 20,
+ backgroundColor: "var(--colorNeutralBackground1)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ headerText: getTabTitle(tab.tab),
+ headerButtonProps: {
+ "data-test": `settings-tab-header/${SettingsV2TabTypes[tab.tab]}`,
+ },
+ };
+
+ return (
+
+ {tab.content}
+
+ );
+ })}
+
);
diff --git a/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx b/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx
index a703771c7..730372b8b 100644
--- a/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx
+++ b/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx
@@ -63,7 +63,7 @@ export interface PriceBreakdown {
export type editorType = "indexPolicy" | "computedProperties" | "dataMasking";
-export const infoAndToolTipTextStyle: ITextStyles = { root: { fontSize: 14, color: "windowtext" } };
+export const infoAndToolTipTextStyle: ITextStyles = { root: { fontSize: 14, color: "var(--colorNeutralForeground1)" } };
export const noLeftPaddingCheckBoxStyle: ICheckboxStyles = {
label: {
@@ -119,15 +119,89 @@ export const addMongoIndexSubElementsTokens: IStackTokens = {
export const mediumWidthStackStyles: IStackStyles = { root: { width: 600 } };
-export const shortWidthTextFieldStyles: Partial
= { root: { paddingLeft: 10, width: 210 } };
+export const shortWidthTextFieldStyles: Partial = {
+ root: { paddingLeft: 10, width: 210 },
+ fieldGroup: {
+ backgroundColor: "var(--colorNeutralBackground2)",
+ borderColor: "var(--colorNeutralStroke1)",
+ },
+ field: {
+ color: "var(--colorNeutralForeground1)",
+ backgroundColor: "var(--colorNeutralBackground2)",
+ },
+};
-export const shortWidthDropDownStyles: Partial = { dropdown: { paddingleft: 10, width: 202 } };
+export const shortWidthDropDownStyles: Partial = {
+ dropdown: { paddingLeft: 10, width: 202 },
+ title: {
+ backgroundColor: "var(--colorNeutralBackground2)",
+ color: "var(--colorNeutralForeground1)",
+ borderColor: "var(--colorNeutralStroke1)",
+ },
+ caretDown: {
+ color: "var(--colorNeutralForeground1)",
+ },
+ callout: {
+ backgroundColor: "var(--colorNeutralBackground2)",
+ border: "1px solid var(--colorNeutralStroke1)",
+ },
+ dropdownItems: {
+ backgroundColor: "var(--colorNeutralBackground2)",
+ },
+ dropdownItem: {
+ backgroundColor: "transparent",
+ color: "var(--colorNeutralForeground1)",
+ selectors: {
+ "&:hover": {
+ backgroundColor: "rgba(255, 255, 255, 0.1)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ "&:focus": {
+ backgroundColor: "rgba(255, 255, 255, 0.1)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ dropdownItemSelected: {
+ backgroundColor: "rgba(255, 255, 255, 0.08)",
+ color: "var(--colorNeutralForeground1)",
+ selectors: {
+ "&:hover": {
+ backgroundColor: "rgba(255, 255, 255, 0.1)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ dropdownOptionText: {
+ color: "var(--colorNeutralForeground1)",
+ },
+};
export const transparentDetailsRowStyles: Partial = {
root: {
+ backgroundColor: "var(--colorNeutralBackground1)",
+ color: "var(--colorNeutralForeground1)",
selectors: {
":hover": {
- background: "transparent",
+ backgroundColor: "var(--colorNeutralBackground1Hover)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ ":hover .ms-DetailsRow-cell": {
+ backgroundColor: "var(--colorNeutralBackground1Hover)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ "&.ms-DetailsRow": {
+ backgroundColor: "var(--colorNeutralBackground1)",
+ },
+ },
+ },
+ cell: {
+ backgroundColor: "var(--colorNeutralBackground1)",
+ color: "var(--colorNeutralForeground1)",
+ selectors: {
+ ":hover": {
+ backgroundColor: "var(--colorNeutralBackground1Hover)",
+ color: "var(--colorNeutralForeground1)",
},
},
},
@@ -135,9 +209,11 @@ export const transparentDetailsRowStyles: Partial = {
export const transparentDetailsHeaderStyle: Partial = {
root: {
+ color: "var(--colorNeutralForeground1)",
selectors: {
":hover": {
- background: "transparent",
+ background: "var(--colorNeutralBackground1Hover)",
+ color: "var(--colorNeutralForeground1)",
},
},
},
@@ -149,6 +225,35 @@ export const customDetailsListStyles: Partial = {
".ms-FocusZone": {
paddingTop: 0,
},
+ ".ms-DetailsHeader": {
+ backgroundColor: "var(--colorNeutralBackground1)",
+ },
+ ".ms-DetailsHeader-cell": {
+ color: "var(--colorNeutralForeground1)",
+ backgroundColor: "var(--colorNeutralBackground1)",
+ selectors: {
+ ":hover": {
+ backgroundColor: "var(--colorNeutralBackground1Hover)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ ".ms-DetailsHeader-cellTitle": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ ".ms-DetailsRow": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ ".ms-DetailsRow-cell": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ // Tooltip styling for cells
+ ".ms-TooltipHost": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ ".ms-DetailsRow-cell .ms-TooltipHost": {
+ color: "var(--colorNeutralForeground1)",
+ },
},
},
};
@@ -166,7 +271,18 @@ export const separatorStyles: Partial = {
};
export const messageBarStyles: Partial = {
- root: { marginTop: "5px", backgroundColor: "white" },
+ root: {
+ marginTop: "5px",
+ backgroundColor: "var(--colorNeutralBackground1)",
+ selectors: {
+ "&.ms-MessageBar--severeWarning": {
+ backgroundColor: "var(--colorNeutralBackground4)",
+ },
+ "&.ms-MessageBar--warning": {
+ backgroundColor: "var(--colorNeutralBackground3)",
+ },
+ },
+ },
text: { fontSize: 14 },
};
@@ -222,9 +338,11 @@ export const getEstimatedSpendingElement = (
const ruRange: string = isAutoscale ? throughput / 10 + " RU/s - " : "";
return (
- Cost estimate*
+ Cost estimate*
{costElement}
- How we calculate this
+
+ How we calculate this
+
{numberOfRegions} region{numberOfRegions > 1 && s }
@@ -238,7 +356,7 @@ export const getEstimatedSpendingElement = (
{priceBreakdown.pricePerRu}/RU
-
+
*{estimatedCostDisclaimer}
@@ -285,7 +403,7 @@ export const updateThroughputDelayedApplyWarningMessage: JSX.Element = (
export const getUpdateThroughputBeyondInstantLimitMessage = (instantMaximumThroughput: number): JSX.Element => {
return (
-
+
Scaling up will take 4-6 hours as it exceeds what Azure Cosmos DB can instantly support currently based on your
number of physical partitions. You can increase your throughput to {instantMaximumThroughput} instantly or proceed
with this value and wait until the scale-up is completed.
@@ -303,7 +421,7 @@ export const getUpdateThroughputBeyondSupportLimitMessage = (
Your request to increase throughput exceeds the pre-allocated capacity which may take longer than expected.
There are three options you can choose from to proceed:
-
+
You can instantly scale up to {instantMaximumThroughput} RU/s.
{instantMaximumThroughput < maximumThroughput && (
You can asynchronously scale up to any value under {maximumThroughput} RU/s in 4-6 hours.
@@ -339,7 +457,7 @@ export const getUpdateThroughputBelowMinimumMessage = (minimum: number): JSX.Ele
};
export const saveThroughputWarningMessage: JSX.Element = (
-
+
Your bill will be affected as you update your throughput settings. Please review the updated cost estimate below
before saving your changes
@@ -459,9 +577,13 @@ export const changeFeedPolicyToolTip: JSX.Element = (
);
export const mongoIndexingPolicyDisclaimer: JSX.Element = (
-
+
For queries that filter on multiple properties, create multiple single field indexes instead of a compound index.
-
+
{` Compound indexes `}
are only used for sorting query results. If you need to add a compound index, you can create one using the Mongo
@@ -470,7 +592,7 @@ export const mongoIndexingPolicyDisclaimer: JSX.Element = (
);
export const mongoCompoundIndexNotSupportedMessage: JSX.Element = (
-
+
Collections with compound indexes are not yet supported in the indexing editor. To modify indexing policy for this
collection, use the Mongo Shell.
@@ -519,14 +641,50 @@ export const getTextFieldStyles = (current: isDirtyTypes, baseline: isDirtyTypes
fieldGroup: {
height: 25,
width: 300,
- borderColor: isDirty(current, baseline) ? StyleConstants.Dirty : "",
+ backgroundColor: "var(--colorNeutralBackground2)",
+ borderColor: isDirty(current, baseline) ? StyleConstants.Dirty : "var(--colorNeutralStroke1)",
selectors: {
":disabled": {
- backgroundColor: StyleConstants.BaseMedium,
- borderColor: StyleConstants.BaseMediumHigh,
+ backgroundColor: "var(--colorNeutralBackground2)",
+ borderColor: "var(--colorNeutralStroke1)",
+ color: "var(--colorNeutralForeground2)",
+ },
+ input: {
+ backgroundColor: "var(--colorNeutralBackground2)",
+ color: "var(--colorNeutralForeground1)",
+ },
+ "input:disabled": {
+ backgroundColor: "var(--colorNeutralBackground2)",
+ color: "var(--colorNeutralForeground2)",
+ },
+ "input#autopilotInput": {
+ backgroundColor: "var(--colorNeutralBackground4)",
+ color: "var(--colorNeutralForeground1)",
},
},
},
+ field: {
+ backgroundColor: "var(--colorNeutralBackground2)",
+ color: "var(--colorNeutralForeground1)",
+ selectors: {
+ ":disabled": {
+ backgroundColor: "var(--colorNeutralBackground2)",
+ color: "var(--colorNeutralForeground2)",
+ },
+ },
+ },
+ subComponentStyles: {
+ label: {
+ root: {
+ color: "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ suffix: {
+ backgroundColor: "var(--colorNeutralBackground2)",
+ color: "var(--colorNeutralForeground1)",
+ border: "1px solid var(--colorNeutralStroke1)",
+ },
});
export const getChoiceGroupStyles = (
@@ -534,6 +692,28 @@ export const getChoiceGroupStyles = (
baseline: isDirtyTypes,
isHorizontal?: boolean,
): Partial => ({
+ label: {
+ color: "var(--colorNeutralForeground1)",
+ },
+ root: {
+ selectors: {
+ ".ms-ChoiceFieldLabel": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceField-innerField": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-innerField": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ },
+ },
flexContainer: [
{
selectors: {
@@ -548,6 +728,16 @@ export const getChoiceGroupStyles = (
fontSize: 14,
fontFamily: StyleConstants.DataExplorerFont,
padding: "2px 5px",
+ color: "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel:hover": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ color: "var(--colorNeutralForeground1)",
},
},
display: isHorizontal ? "inline-flex" : "default",
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx
index c8650b988..039a66304 100644
--- a/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx
+++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx
@@ -1,11 +1,11 @@
-import { FontIcon, Link, MessageBar, MessageBarType, Stack, Text } from "@fluentui/react";
+import { FontIcon, IMessageBarStyles, Link, MessageBar, MessageBarType, Stack, Text } from "@fluentui/react";
import * as DataModels from "Contracts/DataModels";
import { titleAndInputStackProps, unsavedEditorWarningMessage } from "Explorer/Controls/Settings/SettingsRenderUtils";
import { isDirty } from "Explorer/Controls/Settings/SettingsUtils";
import { loadMonaco } from "Explorer/LazyMonaco";
+import { monacoTheme, useThemeStore } from "hooks/useTheme";
import * as monaco from "monaco-editor";
import * as React from "react";
-
export interface ComputedPropertiesComponentProps {
computedPropertiesContent: DataModels.ComputedProperties;
computedPropertiesContentBaseline: DataModels.ComputedProperties;
@@ -27,6 +27,24 @@ export class ComputedPropertiesComponent extends React.Component<
private shouldCheckComponentIsDirty = true;
private computedPropertiesDiv = React.createRef();
private computedPropertiesEditor: monaco.editor.IStandaloneCodeEditor;
+ private themeUnsubscribe: () => void;
+
+ private darkThemeMessageBarStyles: Partial = {
+ 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)",
+ },
+ },
+ },
+ };
constructor(props: ComputedPropertiesComponentProps) {
super(props);
@@ -48,6 +66,10 @@ export class ComputedPropertiesComponent extends React.Component<
this.onComponentUpdate();
}
+ componentWillUnmount(): void {
+ this.themeUnsubscribe && this.themeUnsubscribe();
+ }
+
public resetComputedPropertiesEditor = (): void => {
if (!this.computedPropertiesEditor) {
this.createComputedPropertiesEditor();
@@ -86,8 +108,16 @@ export class ComputedPropertiesComponent extends React.Component<
value: value,
language: "json",
ariaLabel: "Computed properties",
+ theme: monacoTheme(),
});
if (this.computedPropertiesEditor) {
+ // Subscribe to theme changes
+ this.themeUnsubscribe = useThemeStore.subscribe(() => {
+ if (this.computedPropertiesEditor) {
+ monaco.editor.setTheme(monacoTheme());
+ }
+ });
+
const computedPropertiesEditorModel = this.computedPropertiesEditor.getModel();
computedPropertiesEditorModel.onDidChangeContent(this.onEditorContentChange.bind(this));
this.props.logComputedPropertiesSuccessMessage();
@@ -111,17 +141,26 @@ export class ComputedPropertiesComponent extends React.Component<
return (
{isDirty(this.props.computedPropertiesContent, this.props.computedPropertiesContentBaseline) && (
-
+
{unsavedEditorWarningMessage("computedProperties")}
)}
-
+
{"Learn more"}
about how to define computed properties and how to use them.
-
+
);
}
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ConflictResolutionComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ConflictResolutionComponent.tsx
index 46d1d675a..422b61bfe 100644
--- a/src/Explorer/Controls/Settings/SettingsSubComponents/ConflictResolutionComponent.tsx
+++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ConflictResolutionComponent.tsx
@@ -6,7 +6,6 @@ import {
conflictResolutionCustomToolTip,
conflictResolutionLwwTooltip,
getChoiceGroupStyles,
- getTextFieldStyles,
subComponentStackProps,
} from "../SettingsRenderUtils";
import { isDirty } from "../SettingsUtils";
@@ -106,10 +105,46 @@ export class ConflictResolutionComponent extends React.Component
@@ -119,19 +154,57 @@ export class ConflictResolutionComponent extends React.Component
);
- private getConflictResolutionCustomComponent = (): JSX.Element => (
-
- );
+ private getConflictResolutionCustomComponent = (): JSX.Element => {
+ return (
+
+ );
+ };
public render(): JSX.Element {
return (
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ContainerPolicyComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ContainerPolicyComponent.tsx
index 1257d4a65..5fade0420 100644
--- a/src/Explorer/Controls/Settings/SettingsSubComponents/ContainerPolicyComponent.tsx
+++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ContainerPolicyComponent.tsx
@@ -102,11 +102,57 @@ export const ContainerPolicyComponent: React.FC =
return (
-
+
{isVectorSearchEnabled && (
@@ -128,7 +174,7 @@ export const ContainerPolicyComponent: React.FC =
{isFullTextSearchEnabled && (
@@ -144,7 +190,27 @@ export const ContainerPolicyComponent: React.FC =
) : (
{
checkAndSendFullTextPolicyToSettings({
defaultLanguage: getFullTextLanguageOptions()[0].key as never,
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyComponent.tsx
index b2e7e12c2..8990f7c93 100644
--- a/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyComponent.tsx
+++ b/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyComponent.tsx
@@ -1,4 +1,5 @@
-import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
+import { IMessageBarStyles, MessageBar, MessageBarType, Stack } from "@fluentui/react";
+import { monacoTheme, useThemeStore } from "hooks/useTheme";
import * as monaco from "monaco-editor";
import * as React from "react";
import * as DataModels from "../../../../Contracts/DataModels";
@@ -6,7 +7,6 @@ import { loadMonaco } from "../../../LazyMonaco";
import { titleAndInputStackProps, unsavedEditorWarningMessage } from "../SettingsRenderUtils";
import { isDirty, isIndexTransforming } from "../SettingsUtils";
import { IndexingPolicyRefreshComponent } from "./IndexingPolicyRefresh/IndexingPolicyRefreshComponent";
-
export interface IndexingPolicyComponentProps {
shouldDiscardIndexingPolicy: boolean;
resetShouldDiscardIndexingPolicy: () => void;
@@ -31,6 +31,24 @@ export class IndexingPolicyComponent extends React.Component<
private shouldCheckComponentIsDirty = true;
private indexingPolicyDiv = React.createRef();
private indexingPolicyEditor: monaco.editor.IStandaloneCodeEditor;
+ private themeUnsubscribe: () => void;
+
+ private darkThemeMessageBarStyles: Partial = {
+ 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)",
+ },
+ },
+ },
+ };
constructor(props: IndexingPolicyComponentProps) {
super(props);
@@ -52,6 +70,10 @@ export class IndexingPolicyComponent extends React.Component<
this.onComponentUpdate();
}
+ componentWillUnmount(): void {
+ this.themeUnsubscribe && this.themeUnsubscribe();
+ }
+
public resetIndexingPolicyEditor = (): void => {
if (!this.indexingPolicyEditor) {
this.createIndexingPolicyEditor();
@@ -87,18 +109,30 @@ export class IndexingPolicyComponent extends React.Component<
};
private async createIndexingPolicyEditor(): Promise {
+ if (!this.indexingPolicyDiv.current) {
+ return;
+ }
const value: string = JSON.stringify(this.props.indexingPolicyContent, undefined, 4);
const monaco = await loadMonaco();
- this.indexingPolicyEditor = monaco.editor.create(this.indexingPolicyDiv.current, {
- value: value,
- language: "json",
- readOnly: isIndexTransforming(this.props.indexTransformationProgress),
- ariaLabel: "Indexing Policy",
- });
- if (this.indexingPolicyEditor) {
- const indexingPolicyEditorModel = this.indexingPolicyEditor.getModel();
- indexingPolicyEditorModel.onDidChangeContent(this.onEditorContentChange.bind(this));
- this.props.logIndexingPolicySuccessMessage();
+ if (this.indexingPolicyDiv.current) {
+ this.indexingPolicyEditor = monaco.editor.create(this.indexingPolicyDiv.current, {
+ value: value,
+ language: "json",
+ readOnly: isIndexTransforming(this.props.indexTransformationProgress),
+ ariaLabel: "Indexing Policy",
+ theme: monacoTheme(),
+ });
+ if (this.indexingPolicyEditor) {
+ this.themeUnsubscribe = useThemeStore.subscribe(() => {
+ if (this.indexingPolicyEditor) {
+ monaco.editor.setTheme(monacoTheme());
+ }
+ });
+
+ const indexingPolicyEditorModel = this.indexingPolicyEditor.getModel();
+ indexingPolicyEditorModel.onDidChangeContent(this.onEditorContentChange.bind(this));
+ this.props.logIndexingPolicySuccessMessage();
+ }
}
}
@@ -121,7 +155,13 @@ export class IndexingPolicyComponent extends React.Component<
refreshIndexTransformationProgress={this.props.refreshIndexTransformationProgress}
/>
{isDirty(this.props.indexingPolicyContent, this.props.indexingPolicyContentBaseline) && (
- {unsavedEditorWarningMessage("indexPolicy")}
+
+ {unsavedEditorWarningMessage("indexPolicy")}
+
)}
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/IndexingPolicyRefreshComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/IndexingPolicyRefreshComponent.tsx
index d601e3857..509373b8d 100644
--- a/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/IndexingPolicyRefreshComponent.tsx
+++ b/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/IndexingPolicyRefreshComponent.tsx
@@ -1,10 +1,10 @@
-import * as React from "react";
import { MessageBar, MessageBarType } from "@fluentui/react";
+import * as React from "react";
+import { handleError } from "../../../../../Common/ErrorHandlingUtils";
import {
mongoIndexTransformationRefreshingMessage,
renderMongoIndexTransformationRefreshMessage,
} from "../../SettingsRenderUtils";
-import { handleError } from "../../../../../Common/ErrorHandlingUtils";
import { isIndexTransforming } from "../../SettingsUtils";
export interface IndexingPolicyRefreshComponentProps {
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/__snapshots__/IndexingPolicyRefreshComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/__snapshots__/IndexingPolicyRefreshComponent.test.tsx.snap
index c658dd978..813b86e05 100644
--- a/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/__snapshots__/IndexingPolicyRefreshComponent.test.tsx.snap
+++ b/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/__snapshots__/IndexingPolicyRefreshComponent.test.tsx.snap
@@ -8,7 +8,7 @@ exports[`IndexingPolicyRefreshComponent renders 1`] = `
styles={
{
"root": {
- "color": "windowtext",
+ "color": "var(--colorNeutralForeground1)",
"fontSize": 14,
},
}
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/MongoIndexingPolicyComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/MongoIndexingPolicyComponent.tsx
index a55630532..36e999049 100644
--- a/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/MongoIndexingPolicyComponent.tsx
+++ b/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/MongoIndexingPolicyComponent.tsx
@@ -3,6 +3,7 @@ import {
DetailsListLayoutMode,
IColumn,
IconButton,
+ IMessageBarStyles,
MessageBar,
MessageBarType,
SelectionMode,
@@ -30,12 +31,12 @@ import {
} from "../../SettingsRenderUtils";
import {
AddMongoIndexProps,
- MongoIndexIdField,
- MongoIndexTypes,
- MongoNotificationType,
getMongoIndexType,
getMongoIndexTypeText,
isIndexTransforming,
+ MongoIndexIdField,
+ MongoIndexTypes,
+ MongoNotificationType,
} from "../../SettingsUtils";
import { IndexingPolicyRefreshComponent } from "../IndexingPolicyRefresh/IndexingPolicyRefreshComponent";
import { AddMongoIndexComponent } from "./AddMongoIndexComponent";
@@ -63,6 +64,24 @@ interface MongoIndexDisplayProps {
export class MongoIndexingPolicyComponent extends React.Component {
private shouldCheckComponentIsDirty = true;
private addMongoIndexComponentRefs: React.RefObject[] = [];
+
+ private darkThemeMessageBarStyles: Partial = {
+ 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)",
+ },
+ },
+ },
+ };
+
private initialIndexesColumns: IColumn[] = [
{ key: "definition", name: "Definition", fieldName: "definition", minWidth: 100, maxWidth: 200, isResizable: true },
{ key: "type", name: "Type", fieldName: "type", minWidth: 100, maxWidth: 200, isResizable: true },
@@ -171,8 +190,8 @@ export class MongoIndexingPolicyComponent extends React.Component{definition},
- type: {getMongoIndexTypeText(type)} ,
+ definition: {definition} ,
+ type: {getMongoIndexTypeText(type)} ,
actionButton: definition === MongoIndexIdField ? <>> : this.getActionButton(arrayPosition, isCurrentIndex),
};
}
@@ -306,7 +325,15 @@ export class MongoIndexingPolicyComponent extends React.Component
- {warningMessage && {warningMessage} }
+ {warningMessage && (
+
+ {warningMessage}
+
+ )}
>
);
};
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/__snapshots__/AddMongoIndexComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/__snapshots__/AddMongoIndexComponent.test.tsx.snap
index 32fd68bfb..dbe7d78f4 100644
--- a/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/__snapshots__/AddMongoIndexComponent.test.tsx.snap
+++ b/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/__snapshots__/AddMongoIndexComponent.test.tsx.snap
@@ -22,6 +22,14 @@ exports[`AddMongoIndexComponent renders 1`] = `
onChange={[Function]}
styles={
{
+ "field": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "fieldGroup": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
+ },
"root": {
"paddingLeft": 10,
"width": 210,
@@ -49,10 +57,52 @@ exports[`AddMongoIndexComponent renders 1`] = `
selectedKey="Single"
styles={
{
+ "callout": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "border": "1px solid var(--colorNeutralStroke1)",
+ },
+ "caretDown": {
+ "color": "var(--colorNeutralForeground1)",
+ },
"dropdown": {
- "paddingleft": 10,
+ "paddingLeft": 10,
"width": 202,
},
+ "dropdownItem": {
+ "backgroundColor": "transparent",
+ "color": "var(--colorNeutralForeground1)",
+ "selectors": {
+ "&:focus": {
+ "backgroundColor": "rgba(255, 255, 255, 0.1)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "&:hover": {
+ "backgroundColor": "rgba(255, 255, 255, 0.1)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ "dropdownItemSelected": {
+ "backgroundColor": "rgba(255, 255, 255, 0.08)",
+ "color": "var(--colorNeutralForeground1)",
+ "selectors": {
+ "&:hover": {
+ "backgroundColor": "rgba(255, 255, 255, 0.1)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ "dropdownItems": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ },
+ "dropdownOptionText": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "title": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground1)",
+ },
}
}
/>
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/__snapshots__/MongoIndexingPolicyComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/__snapshots__/MongoIndexingPolicyComponent.test.tsx.snap
index 7a200006c..10b87a214 100644
--- a/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/__snapshots__/MongoIndexingPolicyComponent.test.tsx.snap
+++ b/src/Explorer/Controls/Settings/SettingsSubComponents/MongoIndexingPolicy/__snapshots__/MongoIndexingPolicyComponent.test.tsx.snap
@@ -1,7 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MongoIndexingPolicyComponent error shown for collection with compound indexes 1`] = `
-
+
Collections with compound indexes are not yet supported in the indexing editor. To modify indexing policy for this collection, use the Mongo Shell.
`;
@@ -17,10 +23,21 @@ exports[`MongoIndexingPolicyComponent renders 1`] = `
-
+
For queries that filter on multiple properties, create multiple single field indexes instead of a compound index.
Compound indexes
@@ -83,9 +100,37 @@ exports[`MongoIndexingPolicyComponent renders 1`] = `
{
"root": {
"selectors": {
+ ".ms-DetailsHeader": {
+ "backgroundColor": "var(--colorNeutralBackground1)",
+ },
+ ".ms-DetailsHeader-cell": {
+ "backgroundColor": "var(--colorNeutralBackground1)",
+ "color": "var(--colorNeutralForeground1)",
+ "selectors": {
+ ":hover": {
+ "backgroundColor": "var(--colorNeutralBackground1Hover)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ ".ms-DetailsHeader-cellTitle": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-DetailsRow": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-DetailsRow-cell": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-DetailsRow-cell .ms-TooltipHost": {
+ "color": "var(--colorNeutralForeground1)",
+ },
".ms-FocusZone": {
"paddingTop": 0,
},
+ ".ms-TooltipHost": {
+ "color": "var(--colorNeutralForeground1)",
+ },
},
},
}
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx
index 89810bcb6..336a0d972 100644
--- a/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx
+++ b/src/Explorer/Controls/Settings/SettingsSubComponents/PartitionKeyComponent.tsx
@@ -1,6 +1,7 @@
import {
DefaultButton,
FontWeights,
+ IMessageBarStyles,
Link,
MessageBar,
MessageBarType,
@@ -32,6 +33,23 @@ export interface PartitionKeyComponentProps {
isReadOnly?: boolean; // true: cannot change partition key
}
+const darkThemeMessageBarStyles: Partial = {
+ 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)",
+ },
+ },
+ },
+};
+
export const PartitionKeyComponent: React.FC = ({
database,
collection,
@@ -66,13 +84,15 @@ export const PartitionKeyComponent: React.FC = ({
const partitionKeyValue = getPartitionKeyValue();
const textHeadingStyle = {
- root: { fontWeight: FontWeights.semibold, fontSize: 16 },
+ root: { fontWeight: FontWeights.semibold, fontSize: 16, color: "var(--colorNeutralForeground1)" },
};
const textSubHeadingStyle = {
- root: { fontWeight: FontWeights.semibold },
+ root: { fontWeight: FontWeights.semibold, color: "var(--colorNeutralForeground1)" },
+ };
+ const textSubHeadingStyle1 = {
+ root: { color: "var(--colorNeutralForeground1)" },
};
-
const startPollingforUpdate = (currentJob: DataTransferJobGetResults) => {
if (isCurrentJobInProgress(currentJob)) {
const jobName = currentJob?.properties?.jobName;
@@ -167,32 +187,41 @@ export const PartitionKeyComponent: React.FC = ({
Current {partitionKeyName.toLowerCase()}
Partitioning
-
- {partitionKeyValue}
- {isHierarchicalPartitionedContainer() ? "Hierarchical" : "Non-hierarchical"}
+
+ {partitionKeyValue}
+
+ {isHierarchicalPartitionedContainer() ? "Hierarchical" : "Non-hierarchical"}
+
{!isReadOnly && (
<>
-
+
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.
{configContext.platform !== Platform.Emulator && (
{
@@ -185,13 +201,31 @@ export class SubSettingsComponent extends React.Component
- To enable time-to-live (TTL) for your collection/documents,
-
- create a TTL index
-
- .
+
+ To enable time-to-live (TTL) for your collection/documents,{" "}
+
+ create a TTL index
+
+ .
+
) : (
@@ -223,6 +257,7 @@ export class SubSettingsComponent extends React.Component
)}
@@ -267,8 +302,8 @@ export class SubSettingsComponent extends React.Component (
@@ -318,23 +353,34 @@ export class SubSettingsComponent extends React.Component (
{this.getPartitionKeyVisible() && (
-
+
+
+
)}
{userContext.apiType === "SQL" && this.isLargePartitionKeyEnabled() && (
- Large {this.partitionKeyName.toLowerCase()} has been enabled.
+ Large {this.partitionKeyName.toLowerCase()} has been enabled.
)}
{userContext.apiType === "SQL" &&
(this.isHierarchicalPartitionedContainer() ? (
- Hierarchically partitioned container.
+ Hierarchically partitioned container.
) : (
- Non-hierarchically partitioned container.
+ Non-hierarchically partitioned container.
))}
);
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx
index a0b85cabf..bd8f638cd 100644
--- a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx
+++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx
@@ -65,7 +65,7 @@ export const ThroughputBucketsComponent: FC = (
return (
- Throughput Buckets
+ Throughput Buckets
{throughputBuckets?.map((bucket) => (
@@ -77,7 +77,15 @@ export const ThroughputBucketsComponent: FC = (
onChange={(newValue) => handleBucketChange(bucket.id, newValue)}
showValue={false}
label={`Bucket ${bucket.id}${bucket.id === 1 ? " (Data Explorer Query Bucket)" : ""}`}
- styles={{ root: { flex: 2, maxWidth: 400 } }}
+ styles={{
+ root: { flex: 2, maxWidth: 400 },
+ titleLabel: {
+ color:
+ bucket.maxThroughputPercentage === 100
+ ? "var(--colorNeutralForeground4)"
+ : "var(--colorNeutralForeground1)",
+ },
+ }}
disabled={bucket.maxThroughputPercentage === 100}
/>
= (
offText="Inactive"
checked={bucket.maxThroughputPercentage !== 100}
onChange={(event, checked) => onToggle(bucket.id, checked)}
- styles={{ root: { marginBottom: 0 }, text: { fontSize: 12 } }}
+ styles={{
+ root: { marginBottom: 0 },
+ text: { fontSize: 12, color: "var(--colorNeutralForeground1)" },
+ }}
>
))}
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.tsx
index 16ec09f80..ee3bf09de 100644
--- a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.tsx
+++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputInputAutoPilotV3Component.tsx
@@ -3,6 +3,7 @@ import {
ChoiceGroup,
FontIcon,
IChoiceGroupOption,
+ IMessageBarStyles,
IProgressIndicatorStyles,
ISeparatorStyles,
Label,
@@ -37,7 +38,6 @@ import {
getUpdateThroughputBeyondInstantLimitMessage,
getUpdateThroughputBeyondSupportLimitMessage,
manualToAutoscaleDisclaimerElement,
- messageBarStyles,
noLeftPaddingCheckBoxStyle,
relaxedSpacingStackProps,
saveThroughputWarningMessage,
@@ -101,6 +101,13 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
{ key: "false", text: "Manual" },
];
+ // Style constants for theme-aware colors and layout
+ private static readonly TEXT_COLOR_PRIMARY = "var(--colorNeutralForeground1)";
+ private static readonly TEXT_COLOR_SECONDARY = "var(--colorNeutralForeground2)";
+ private static readonly TEXT_WIDTH_50 = "50%";
+ private static readonly TEXT_WIDTH_33 = "33%";
+ private static readonly LOCALE_EN_US = "en-US";
+
componentDidMount(): void {
this.onComponentUpdate();
}
@@ -236,12 +243,24 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
);
return (
-
Updated cost per month
+
+ Updated cost per month
+
-
+
{newPrices.currencySign} {calculateEstimateNumber(newPrices.monthlyPrice / 10)} min
-
+
{newPrices.currencySign} {calculateEstimateNumber(newPrices.monthlyPrice)} max
@@ -254,12 +273,24 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
return (
{newThroughput && newThroughputCostElement()}
- Current cost per month
-
-
+
+ Current cost per month
+
+
+
{prices.currencySign} {calculateEstimateNumber(prices.monthlyPrice / 10)} min
-
+
{prices.currencySign} {calculateEstimateNumber(prices.monthlyPrice)} max
@@ -269,7 +300,12 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
return getEstimatedSpendingElement(costElement(), newThroughput ?? throughput, numberOfRegions, prices, true);
};
-
+ settingsAndScaleStyle = {
+ root: {
+ width: ThroughputInputAutoPilotV3Component.TEXT_WIDTH_33,
+ color: ThroughputInputAutoPilotV3Component.TEXT_COLOR_PRIMARY,
+ },
+ };
private getEstimatedManualSpendElement = (
throughput: number,
serverId: string,
@@ -289,15 +325,17 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
);
return (
-
Updated cost per month
+
+ Updated cost per month
+
-
+
{newPrices.currencySign} {calculateEstimateNumber(newPrices.hourlyPrice)}/hr
-
+
{newPrices.currencySign} {calculateEstimateNumber(newPrices.dailyPrice)}/day
-
+
{newPrices.currencySign} {calculateEstimateNumber(newPrices.monthlyPrice)}/mo
@@ -310,15 +348,17 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
return (
{newThroughput && newThroughputCostElement()}
- Current cost per month
+
+ Current cost per month
+
-
+
{prices.currencySign} {calculateEstimateNumber(prices.hourlyPrice)}/hr
-
+
{prices.currencySign} {calculateEstimateNumber(prices.dailyPrice)}/day
-
+
{prices.currencySign} {calculateEstimateNumber(prices.monthlyPrice)}/mo
@@ -381,7 +421,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
{this.overrideWithProvisionedThroughputSettings() && (
{manualToAutoscaleDisclaimerElement}
@@ -407,8 +447,8 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
const capacity: string = this.props.isFixed ? "Fixed" : "Unlimited";
return (
- Storage capacity
- {capacity}
+ Storage capacity
+ {capacity}
);
};
@@ -418,7 +458,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
{
selectors: {
"::before": {
- backgroundColor: "rgb(200, 200, 200)",
+ backgroundColor: "var(--colorNeutralStroke2)",
height: "3px",
marginTop: "-1px",
},
@@ -457,10 +497,10 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
{
backgroundColor:
this.getCurrentRuRange() === "instant"
- ? "rgb(0, 120, 212)"
+ ? "var(--colorBrandBackground)"
: this.getCurrentRuRange() === "delayed"
- ? "rgb(255 216 109)"
- : "rgb(251, 217, 203)",
+ ? "var(--colorStatusWarningBackground1)"
+ : "var(--colorStatusDangerBackground1)",
},
],
});
@@ -497,13 +537,18 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
- {this.props.minimum.toLocaleString()}
+ {this.props.minimum.toLocaleString(ThroughputInputAutoPilotV3Component.LOCALE_EN_US)}
- {this.props.instantMaximumThroughput.toLocaleString()}
+ {this.props.instantMaximumThroughput.toLocaleString(ThroughputInputAutoPilotV3Component.LOCALE_EN_US)}
+
+
+ {this.props.softAllowedMaximumThroughput.toLocaleString(ThroughputInputAutoPilotV3Component.LOCALE_EN_US)}
+
+
+ {this.props.softAllowedMaximumThroughput.toLocaleString(ThroughputInputAutoPilotV3Component.LOCALE_EN_US)}
- {this.props.softAllowedMaximumThroughput.toLocaleString()}
= {
+ root: {
+ marginTop: "5px",
+ selectors: {
+ "&.ms-MessageBar--severeWarning": {
+ backgroundColor: "var(--colorStatusDangerBackground1)",
+ border: "1px solid var(--colorStatusDangerBorder1)",
+ },
+ "&.ms-MessageBar--warning": {
+ backgroundColor: "var(--colorStatusWarningBackground1)",
+ border: "1px solid var(--colorStatusWarningBorder1)",
+ },
+ "&.ms-MessageBar--info": {
+ backgroundColor: "var(--colorNeutralBackground3)",
+ border: "1px solid var(--colorNeutralStroke1)",
+ },
+ ".ms-MessageBar-icon": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ ".ms-MessageBar-text": {
+ color: "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ };
+
private getThroughputWarningMessageBar = (): JSX.Element => {
const isSevereWarning: boolean =
this.currentThroughputValue() > this.props.softAllowedMaximumThroughput ||
this.currentThroughputValue() < this.props.minimum;
return (
-
+
{this.getThroughputWarningMessageText()}
);
@@ -563,10 +637,13 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
{/* Column 1: Minimum RU/s */}
-
+
Minimum RU/s
-
+
{AutoPilotUtils.getMinRUsBasedOnUserInput(this.props.maxAutoPilotThroughput)}
@@ -594,6 +672,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
fontSize: 12,
fontWeight: 400,
paddingBottom: 6,
+ color: "var(--colorNeutralForeground1)",
}}
>
x 10 =
@@ -602,10 +681,13 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
{/* Column 3: Maximum RU/s */}
-
+
Maximum RU/s
-
+
{
const sanitizedValue = getSanitizedInputValue(value);
- return sanitizedValue % 1000
- ? "Throughput value must be in increments of 1000"
- : this.props.throughputError;
+ const errorMessage: string =
+ sanitizedValue % 1000 ? "Throughput value must be in increments of 1000" : this.props.throughputError;
+ return {errorMessage} ;
}}
validateOnLoad={false}
+ data-test="autopilot-throughput-input"
/>
@@ -650,7 +750,10 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
}
onChange={this.onThroughputChange}
min={this.props.minimum}
- errorMessage={this.props.throughputError}
+ onGetErrorMessage={() => {
+ return {this.props.throughputError} ;
+ }}
+ data-test="manual-throughput-input"
/>
)}
>
@@ -668,7 +771,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
)}
{this.props.isAutoPilotSelected ? (
-
+
Based on usage, your {this.props.collectionName ? "container" : "database"} throughput will scale from{" "}
{AutoPilotUtils.getMinRUsBasedOnUserInput(this.props.maxAutoPilotThroughput)} RU/s (10% of max RU/s) -{" "}
@@ -681,7 +784,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
{this.state.exceedFreeTierThroughput && (
{`Billing will apply if you provision more than ${SharedConstants.FreeTierLimits.RU} RU/s of manual throughput, or if the resource scales beyond ${SharedConstants.FreeTierLimits.RU} RU/s with autoscale.`}
@@ -690,7 +793,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
>
)}
{!this.overrideWithProvisionedThroughputSettings() && (
-
+
Estimate your required RU/s with
{` capacity calculator`}
@@ -731,6 +834,7 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
{warningMessage && (
{warningMessage}
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/__snapshots__/ThroughputInputAutoPilotV3Component.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/__snapshots__/ThroughputInputAutoPilotV3Component.test.tsx.snap
index f4e300d91..e181b166b 100644
--- a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/__snapshots__/ThroughputInputAutoPilotV3Component.test.tsx.snap
+++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/__snapshots__/ThroughputInputAutoPilotV3Component.test.tsx.snap
@@ -16,17 +16,35 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = `
}
}
role="alert"
- >
-
+ }
+ >
+
Your bill will be affected as you update your throughput settings. Please review the updated cost estimate below before saving your changes
@@ -41,7 +59,7 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = `
styles={
{
"root": {
- "color": "windowtext",
+ "color": "var(--colorNeutralForeground1)",
"fontSize": 14,
},
}
@@ -62,11 +80,27 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = `
styles={
{
"root": {
- "backgroundColor": "white",
"marginTop": "5px",
- },
- "text": {
- "fontSize": 14,
+ "selectors": {
+ "&.ms-MessageBar--info": {
+ "backgroundColor": "var(--colorNeutralBackground3)",
+ "border": "1px solid var(--colorNeutralStroke1)",
+ },
+ "&.ms-MessageBar--severeWarning": {
+ "backgroundColor": "var(--colorStatusDangerBackground1)",
+ "border": "1px solid var(--colorStatusDangerBorder1)",
+ },
+ "&.ms-MessageBar--warning": {
+ "backgroundColor": "var(--colorStatusWarningBackground1)",
+ "border": "1px solid var(--colorStatusWarningBorder1)",
+ },
+ ".ms-MessageBar-icon": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-MessageBar-text": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
},
}
}
@@ -76,7 +110,7 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = `
styles={
{
"root": {
- "color": "windowtext",
+ "color": "var(--colorNeutralForeground1)",
"fontSize": 14,
},
}
@@ -121,15 +155,47 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = `
".ms-ChoiceField-field.is-checked::before": {
"borderColor": undefined,
},
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
".ms-ChoiceField-wrapper label": {
+ "color": "var(--colorNeutralForeground1)",
"fontFamily": undefined,
"fontSize": 14,
"padding": "2px 5px",
"whiteSpace": "nowrap",
},
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel:hover": {
+ "color": "var(--colorNeutralForeground1)",
+ },
},
},
],
+ "label": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "root": {
+ "selectors": {
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
/>
@@ -185,6 +251,7 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = `
1,000,000
+
+ 1,000,000
+
-
+
Storage capacity
-
+
Unlimited
@@ -498,6 +642,7 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = `
@@ -752,26 +938,64 @@ exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = `
}
>
1,000,000
+
+ 1,000,000
+
-
+
Estimate your required RU/s with
-
+
Storage capacity
-
+
Unlimited
@@ -986,6 +1265,7 @@ exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = `
@@ -1206,26 +1524,64 @@ exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = `
}
>
1,000,000
+
+ 1,000,000
+
-
+
Estimate your required RU/s with
-
+
Storage capacity
-
+
Unlimited
@@ -1423,6 +1834,7 @@ exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = `
- {this.props.label && {this.props.label} }
+ {this.props.label && (
+ {this.props.label}
+ )}
{this.props.toolTipElement && (
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ConflictResolutionComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ConflictResolutionComponent.test.tsx.snap
index 5e93993f9..76077051b 100644
--- a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ConflictResolutionComponent.test.tsx.snap
+++ b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ConflictResolutionComponent.test.tsx.snap
@@ -37,15 +37,47 @@ exports[`ConflictResolutionComponent Path text field displayed 1`] = `
".ms-ChoiceField-field.is-checked::before": {
"borderColor": undefined,
},
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
".ms-ChoiceField-wrapper label": {
+ "color": "var(--colorNeutralForeground1)",
"fontFamily": undefined,
"fontSize": 14,
"padding": "2px 5px",
"whiteSpace": "nowrap",
},
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel:hover": {
+ "color": "var(--colorNeutralForeground1)",
+ },
},
},
],
+ "label": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "root": {
+ "selectors": {
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
/>
@@ -56,17 +88,44 @@ exports[`ConflictResolutionComponent Path text field displayed 1`] = `
onRenderLabel={[Function]}
styles={
{
+ "field": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ "selectors": {
+ ":disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ },
+ },
"fieldGroup": {
- "borderColor": "",
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
"height": 25,
"selectors": {
":disabled": {
- "backgroundColor": undefined,
- "borderColor": undefined,
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ "input": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "input:disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
},
},
"width": 300,
},
+ "subComponentStyles": {
+ "label": {
+ "root": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
value=""
@@ -111,15 +170,47 @@ exports[`ConflictResolutionComponent Sproc text field displayed 1`] = `
".ms-ChoiceField-field.is-checked::before": {
"borderColor": "",
},
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
".ms-ChoiceField-wrapper label": {
+ "color": "var(--colorNeutralForeground1)",
"fontFamily": undefined,
"fontSize": 14,
"padding": "2px 5px",
"whiteSpace": "nowrap",
},
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel:hover": {
+ "color": "var(--colorNeutralForeground1)",
+ },
},
},
],
+ "label": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "root": {
+ "selectors": {
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
/>
@@ -130,17 +221,44 @@ exports[`ConflictResolutionComponent Sproc text field displayed 1`] = `
onRenderLabel={[Function]}
styles={
{
+ "field": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ "selectors": {
+ ":disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ },
+ },
"fieldGroup": {
- "borderColor": "",
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
"height": 25,
"selectors": {
":disabled": {
- "backgroundColor": undefined,
- "borderColor": undefined,
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ "input": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "input:disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
},
},
"width": 300,
},
+ "subComponentStyles": {
+ "label": {
+ "root": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
value=""
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap
index 95d87da3d..56648ab9d 100644
--- a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap
+++ b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/PartitionKeyComponent.test.tsx.snap
@@ -26,6 +26,7 @@ exports[`PartitionKeyComponent renders default component and matches snapshot 1`
styles={
{
"root": {
+ "color": "var(--colorNeutralForeground1)",
"fontSize": 16,
"fontWeight": 600,
},
@@ -54,6 +55,7 @@ exports[`PartitionKeyComponent renders default component and matches snapshot 1`
styles={
{
"root": {
+ "color": "var(--colorNeutralForeground1)",
"fontWeight": 600,
},
}
@@ -66,6 +68,7 @@ exports[`PartitionKeyComponent renders default component and matches snapshot 1`
styles={
{
"root": {
+ "color": "var(--colorNeutralForeground1)",
"fontWeight": 600,
},
}
@@ -75,35 +78,91 @@ exports[`PartitionKeyComponent renders default component and matches snapshot 1`
-
-
+
+
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.
-
-
+
+
Non-hierarchical
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap
index 9d5471ccc..3a710db53 100644
--- a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap
+++ b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap
@@ -22,14 +22,17 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = `
options={
[
{
+ "ariaLabel": "ttl-off-option",
"key": "off",
"text": "Off",
},
{
+ "ariaLabel": "ttl-on-no-default-option",
"key": "on-nodefault",
"text": "On (no default)",
},
{
+ "ariaLabel": "ttl-on-option",
"key": "on",
"text": "On",
},
@@ -49,20 +52,53 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = `
".ms-ChoiceField-field.is-checked::before": {
"borderColor": "",
},
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
".ms-ChoiceField-wrapper label": {
+ "color": "var(--colorNeutralForeground1)",
"fontFamily": undefined,
"fontSize": 14,
"padding": "2px 5px",
"whiteSpace": "nowrap",
},
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel:hover": {
+ "color": "var(--colorNeutralForeground1)",
+ },
},
},
],
+ "label": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "root": {
+ "selectors": {
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
/>
@@ -141,7 +247,7 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = `
styles={
{
"root": {
- "color": "windowtext",
+ "color": "var(--colorNeutralForeground1)",
"fontSize": 14,
},
}
@@ -182,15 +288,47 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = `
".ms-ChoiceField-field.is-checked::before": {
"borderColor": "",
},
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
".ms-ChoiceField-wrapper label": {
+ "color": "var(--colorNeutralForeground1)",
"fontFamily": undefined,
"fontSize": 14,
"padding": "2px 5px",
"whiteSpace": "nowrap",
},
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel:hover": {
+ "color": "var(--colorNeutralForeground1)",
+ },
},
},
],
+ "label": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "root": {
+ "selectors": {
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
/>
@@ -202,32 +340,83 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = `
}
}
>
-
-
+ >
+
+
+
Large
partition key
has been enabled.
-
+
Non-hierarchically partitioned container.
@@ -244,17 +433,53 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = `
label="Unique keys"
styles={
{
+ "field": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ "selectors": {
+ ":disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ },
+ },
"fieldGroup": {
- "borderColor": "",
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
"height": 25,
"selectors": {
":disabled": {
- "backgroundColor": undefined,
- "borderColor": undefined,
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ "input": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "input#autopilotInput": {
+ "backgroundColor": "var(--colorNeutralBackground4)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "input:disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
},
},
"width": 300,
},
+ "subComponentStyles": {
+ "label": {
+ "root": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ "suffix": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "border": "1px solid var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground1)",
+ },
}
}
/>
@@ -284,14 +509,17 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = `
options={
[
{
+ "ariaLabel": "ttl-off-option",
"key": "off",
"text": "Off",
},
{
+ "ariaLabel": "ttl-on-no-default-option",
"key": "on-nodefault",
"text": "On (no default)",
},
{
+ "ariaLabel": "ttl-on-option",
"key": "on",
"text": "On",
},
@@ -311,20 +539,53 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = `
".ms-ChoiceField-field.is-checked::before": {
"borderColor": "",
},
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
".ms-ChoiceField-wrapper label": {
+ "color": "var(--colorNeutralForeground1)",
"fontFamily": undefined,
"fontSize": 14,
"padding": "2px 5px",
"whiteSpace": "nowrap",
},
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel:hover": {
+ "color": "var(--colorNeutralForeground1)",
+ },
},
},
],
+ "label": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "root": {
+ "selectors": {
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
/>
@@ -434,15 +765,47 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = `
".ms-ChoiceField-field.is-checked::before": {
"borderColor": undefined,
},
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
".ms-ChoiceField-wrapper label": {
+ "color": "var(--colorNeutralForeground1)",
"fontFamily": undefined,
"fontSize": 14,
"padding": "2px 5px",
"whiteSpace": "nowrap",
},
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel:hover": {
+ "color": "var(--colorNeutralForeground1)",
+ },
},
},
],
+ "label": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "root": {
+ "selectors": {
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
/>
@@ -458,7 +821,7 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = `
styles={
{
"root": {
- "color": "windowtext",
+ "color": "var(--colorNeutralForeground1)",
"fontSize": 14,
},
}
@@ -499,15 +862,47 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = `
".ms-ChoiceField-field.is-checked::before": {
"borderColor": "",
},
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
".ms-ChoiceField-wrapper label": {
+ "color": "var(--colorNeutralForeground1)",
"fontFamily": undefined,
"fontSize": 14,
"padding": "2px 5px",
"whiteSpace": "nowrap",
},
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel:hover": {
+ "color": "var(--colorNeutralForeground1)",
+ },
},
},
],
+ "label": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "root": {
+ "selectors": {
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
/>
@@ -519,32 +914,83 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = `
}
}
>
-
-
+ >
+
+
+
Large
partition key
has been enabled.
-
+
Non-hierarchically partitioned container.
@@ -561,17 +1007,53 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = `
label="Unique keys"
styles={
{
+ "field": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ "selectors": {
+ ":disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ },
+ },
"fieldGroup": {
- "borderColor": "",
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
"height": 25,
"selectors": {
":disabled": {
- "backgroundColor": undefined,
- "borderColor": undefined,
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ "input": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "input#autopilotInput": {
+ "backgroundColor": "var(--colorNeutralBackground4)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "input:disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
},
},
"width": 300,
},
+ "subComponentStyles": {
+ "label": {
+ "root": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ "suffix": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "border": "1px solid var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground1)",
+ },
}
}
/>
@@ -601,14 +1083,17 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = `
options={
[
{
+ "ariaLabel": "ttl-off-option",
"key": "off",
"text": "Off",
},
{
+ "ariaLabel": "ttl-on-no-default-option",
"key": "on-nodefault",
"text": "On (no default)",
},
{
+ "ariaLabel": "ttl-on-option",
"key": "on",
"text": "On",
},
@@ -628,20 +1113,53 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = `
".ms-ChoiceField-field.is-checked::before": {
"borderColor": "",
},
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
".ms-ChoiceField-wrapper label": {
+ "color": "var(--colorNeutralForeground1)",
"fontFamily": undefined,
"fontSize": 14,
"padding": "2px 5px",
"whiteSpace": "nowrap",
},
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel:hover": {
+ "color": "var(--colorNeutralForeground1)",
+ },
},
},
],
+ "label": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "root": {
+ "selectors": {
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
/>
@@ -751,15 +1339,47 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = `
".ms-ChoiceField-field.is-checked::before": {
"borderColor": "",
},
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
".ms-ChoiceField-wrapper label": {
+ "color": "var(--colorNeutralForeground1)",
"fontFamily": undefined,
"fontSize": 14,
"padding": "2px 5px",
"whiteSpace": "nowrap",
},
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel:hover": {
+ "color": "var(--colorNeutralForeground1)",
+ },
},
},
],
+ "label": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "root": {
+ "selectors": {
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
/>
@@ -771,17 +1391,53 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = `
required={true}
styles={
{
+ "field": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ "selectors": {
+ ":disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ },
+ },
"fieldGroup": {
- "borderColor": "",
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
"height": 25,
"selectors": {
":disabled": {
- "backgroundColor": undefined,
- "borderColor": undefined,
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ "input": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "input#autopilotInput": {
+ "backgroundColor": "var(--colorNeutralBackground4)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "input:disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
},
},
"width": 300,
},
+ "subComponentStyles": {
+ "label": {
+ "root": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ "suffix": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "border": "1px solid var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground1)",
+ },
}
}
suffix="second(s)"
@@ -796,32 +1452,83 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = `
}
}
>
-
-
+ >
+
+
+
Large
partition key
has been enabled.
-
+
Non-hierarchically partitioned container.
@@ -838,17 +1545,53 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = `
label="Unique keys"
styles={
{
+ "field": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ "selectors": {
+ ":disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ },
+ },
"fieldGroup": {
- "borderColor": "",
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
"height": 25,
"selectors": {
":disabled": {
- "backgroundColor": undefined,
- "borderColor": undefined,
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ "input": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "input#autopilotInput": {
+ "backgroundColor": "var(--colorNeutralBackground4)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "input:disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
},
},
"width": 300,
},
+ "subComponentStyles": {
+ "label": {
+ "root": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ "suffix": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "border": "1px solid var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground1)",
+ },
}
}
/>
@@ -878,14 +1621,17 @@ exports[`SubSettingsComponent renders 1`] = `
options={
[
{
+ "ariaLabel": "ttl-off-option",
"key": "off",
"text": "Off",
},
{
+ "ariaLabel": "ttl-on-no-default-option",
"key": "on-nodefault",
"text": "On (no default)",
},
{
+ "ariaLabel": "ttl-on-option",
"key": "on",
"text": "On",
},
@@ -905,20 +1651,53 @@ exports[`SubSettingsComponent renders 1`] = `
".ms-ChoiceField-field.is-checked::before": {
"borderColor": "",
},
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
".ms-ChoiceField-wrapper label": {
+ "color": "var(--colorNeutralForeground1)",
"fontFamily": undefined,
"fontSize": 14,
"padding": "2px 5px",
"whiteSpace": "nowrap",
},
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel:hover": {
+ "color": "var(--colorNeutralForeground1)",
+ },
},
},
],
+ "label": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "root": {
+ "selectors": {
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
/>
@@ -1028,15 +1877,47 @@ exports[`SubSettingsComponent renders 1`] = `
".ms-ChoiceField-field.is-checked::before": {
"borderColor": "",
},
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
".ms-ChoiceField-wrapper label": {
+ "color": "var(--colorNeutralForeground1)",
"fontFamily": undefined,
"fontSize": 14,
"padding": "2px 5px",
"whiteSpace": "nowrap",
},
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel:hover": {
+ "color": "var(--colorNeutralForeground1)",
+ },
},
},
],
+ "label": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "root": {
+ "selectors": {
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
/>
@@ -1048,17 +1929,53 @@ exports[`SubSettingsComponent renders 1`] = `
required={true}
styles={
{
+ "field": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ "selectors": {
+ ":disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ },
+ },
"fieldGroup": {
- "borderColor": "",
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
"height": 25,
"selectors": {
":disabled": {
- "backgroundColor": undefined,
- "borderColor": undefined,
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ "input": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "input#autopilotInput": {
+ "backgroundColor": "var(--colorNeutralBackground4)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "input:disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
},
},
"width": 300,
},
+ "subComponentStyles": {
+ "label": {
+ "root": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ "suffix": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "border": "1px solid var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground1)",
+ },
}
}
suffix="second(s)"
@@ -1077,7 +1994,7 @@ exports[`SubSettingsComponent renders 1`] = `
styles={
{
"root": {
- "color": "windowtext",
+ "color": "var(--colorNeutralForeground1)",
"fontSize": 14,
},
}
@@ -1118,15 +2035,47 @@ exports[`SubSettingsComponent renders 1`] = `
".ms-ChoiceField-field.is-checked::before": {
"borderColor": "",
},
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
".ms-ChoiceField-wrapper label": {
+ "color": "var(--colorNeutralForeground1)",
"fontFamily": undefined,
"fontSize": 14,
"padding": "2px 5px",
"whiteSpace": "nowrap",
},
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel:hover": {
+ "color": "var(--colorNeutralForeground1)",
+ },
},
},
],
+ "label": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "root": {
+ "selectors": {
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
/>
@@ -1138,32 +2087,83 @@ exports[`SubSettingsComponent renders 1`] = `
}
}
>
-
-
+ >
+
+
+
Large
partition key
has been enabled.
-
+
Non-hierarchically partitioned container.
@@ -1180,17 +2180,53 @@ exports[`SubSettingsComponent renders 1`] = `
label="Unique keys"
styles={
{
+ "field": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ "selectors": {
+ ":disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ },
+ },
"fieldGroup": {
- "borderColor": "",
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
"height": 25,
"selectors": {
":disabled": {
- "backgroundColor": undefined,
- "borderColor": undefined,
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ "input": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "input#autopilotInput": {
+ "backgroundColor": "var(--colorNeutralBackground4)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "input:disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
},
},
"width": 300,
},
+ "subComponentStyles": {
+ "label": {
+ "root": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ "suffix": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "border": "1px solid var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground1)",
+ },
}
}
/>
@@ -1220,14 +2256,17 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
options={
[
{
+ "ariaLabel": "ttl-off-option",
"key": "off",
"text": "Off",
},
{
+ "ariaLabel": "ttl-on-no-default-option",
"key": "on-nodefault",
"text": "On (no default)",
},
{
+ "ariaLabel": "ttl-on-option",
"key": "on",
"text": "On",
},
@@ -1247,15 +2286,47 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
".ms-ChoiceField-field.is-checked::before": {
"borderColor": undefined,
},
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
".ms-ChoiceField-wrapper label": {
+ "color": "var(--colorNeutralForeground1)",
"fontFamily": undefined,
"fontSize": 14,
"padding": "2px 5px",
"whiteSpace": "nowrap",
},
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel:hover": {
+ "color": "var(--colorNeutralForeground1)",
+ },
},
},
],
+ "label": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "root": {
+ "selectors": {
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
/>
@@ -1267,10 +2338,12 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
options={
[
{
+ "ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
+ "ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -1290,15 +2363,47 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
".ms-ChoiceField-field.is-checked::before": {
"borderColor": "",
},
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
".ms-ChoiceField-wrapper label": {
+ "color": "var(--colorNeutralForeground1)",
"fontFamily": undefined,
"fontSize": 14,
"padding": "2px 5px",
"whiteSpace": "nowrap",
},
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel:hover": {
+ "color": "var(--colorNeutralForeground1)",
+ },
},
},
],
+ "label": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "root": {
+ "selectors": {
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
/>
@@ -1344,15 +2449,47 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
".ms-ChoiceField-field.is-checked::before": {
"borderColor": "",
},
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
".ms-ChoiceField-wrapper label": {
+ "color": "var(--colorNeutralForeground1)",
"fontFamily": undefined,
"fontSize": 14,
"padding": "2px 5px",
"whiteSpace": "nowrap",
},
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel:hover": {
+ "color": "var(--colorNeutralForeground1)",
+ },
},
},
],
+ "label": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "root": {
+ "selectors": {
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
/>
@@ -1364,17 +2501,53 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
required={true}
styles={
{
+ "field": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ "selectors": {
+ ":disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ },
+ },
"fieldGroup": {
- "borderColor": "",
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
"height": 25,
"selectors": {
":disabled": {
- "backgroundColor": undefined,
- "borderColor": undefined,
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ "input": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "input#autopilotInput": {
+ "backgroundColor": "var(--colorNeutralBackground4)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "input:disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
},
},
"width": 300,
},
+ "subComponentStyles": {
+ "label": {
+ "root": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ "suffix": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "border": "1px solid var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground1)",
+ },
}
}
suffix="second(s)"
@@ -1393,7 +2566,7 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
styles={
{
"root": {
- "color": "windowtext",
+ "color": "var(--colorNeutralForeground1)",
"fontSize": 14,
},
}
@@ -1434,15 +2607,47 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
".ms-ChoiceField-field.is-checked::before": {
"borderColor": "",
},
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
".ms-ChoiceField-wrapper label": {
+ "color": "var(--colorNeutralForeground1)",
"fontFamily": undefined,
"fontSize": 14,
"padding": "2px 5px",
"whiteSpace": "nowrap",
},
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel:hover": {
+ "color": "var(--colorNeutralForeground1)",
+ },
},
},
],
+ "label": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "root": {
+ "selectors": {
+ ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceField-innerField": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceField:hover .ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ ".ms-ChoiceFieldLabel": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
}
}
/>
@@ -1454,32 +2659,83 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
}
}
>
-
-
+ >
+
+
+
Large
partition key
has been enabled.
-
+
Non-hierarchically partitioned container.
@@ -1496,17 +2752,53 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
label="Unique keys"
styles={
{
+ "field": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ "selectors": {
+ ":disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ },
+ },
"fieldGroup": {
- "borderColor": "",
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
"height": 25,
"selectors": {
":disabled": {
- "backgroundColor": undefined,
- "borderColor": undefined,
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "borderColor": "var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground2)",
+ },
+ "input": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "input#autopilotInput": {
+ "backgroundColor": "var(--colorNeutralBackground4)",
+ "color": "var(--colorNeutralForeground1)",
+ },
+ "input:disabled": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "color": "var(--colorNeutralForeground2)",
},
},
"width": 300,
},
+ "subComponentStyles": {
+ "label": {
+ "root": {
+ "color": "var(--colorNeutralForeground1)",
+ },
+ },
+ },
+ "suffix": {
+ "backgroundColor": "var(--colorNeutralBackground2)",
+ "border": "1px solid var(--colorNeutralStroke1)",
+ "color": "var(--colorNeutralForeground1)",
+ },
}
}
/>
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ToolTipLabelComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ToolTipLabelComponent.test.tsx.snap
index fa801675f..1c082a553 100644
--- a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ToolTipLabelComponent.test.tsx.snap
+++ b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ToolTipLabelComponent.test.tsx.snap
@@ -14,6 +14,7 @@ exports[`ToolTipLabelComponent renders 1`] = `
-
+ >
+
+
-
+ >
+
+
-
+
+
+
-
+
+ indexingPolicyContentBaseline={
+ {
+ "automatic": true,
+ "excludedPaths": [],
+ "includedPaths": [],
+ "indexingMode": "consistent",
+ }
+ }
+ isVectorSearchEnabled={false}
+ logIndexingPolicySuccessMessage={[Function]}
+ onIndexingPolicyContentChange={[Function]}
+ onIndexingPolicyDirtyChange={[Function]}
+ refreshIndexTransformationProgress={[Function]}
+ resetShouldDiscardIndexingPolicy={[Function]}
+ shouldDiscardIndexingPolicy={false}
+ />
+
-
+
+ isReadOnly={false}
+ />
+
-
+ >
+
+
-
+
+ />
+
diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsRenderUtils.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsRenderUtils.test.tsx.snap
index 3399ab9a3..baae4ef02 100644
--- a/src/Explorer/Controls/Settings/__snapshots__/SettingsRenderUtils.test.tsx.snap
+++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsRenderUtils.test.tsx.snap
@@ -6,6 +6,7 @@ exports[`SettingsUtils functions render 1`] = `
Enable change feed log retention policy to retain last 10 minutes of history for items in the container by default. To support this, the request unit (RU) charge for this container will be multiplied by a factor of two for writes. Reads are unaffected.
-
+
For queries that filter on multiple properties, create multiple single field indexes instead of a compound index.
Compound indexes
@@ -256,7 +270,7 @@ exports[`SettingsUtils functions render 1`] = `
styles={
{
"root": {
- "color": "windowtext",
+ "color": "var(--colorNeutralForeground1)",
"fontSize": 14,
},
}
@@ -272,7 +286,7 @@ exports[`SettingsUtils functions render 1`] = `
styles={
{
"root": {
- "color": "windowtext",
+ "color": "var(--colorNeutralForeground1)",
"fontSize": 14,
},
}
@@ -289,7 +303,7 @@ exports[`SettingsUtils functions render 1`] = `
styles={
{
"root": {
- "color": "windowtext",
+ "color": "var(--colorNeutralForeground1)",
"fontSize": 14,
},
}
diff --git a/src/Explorer/Controls/ThroughputInput/CostEstimateText/CostEstimateText.tsx b/src/Explorer/Controls/ThroughputInput/CostEstimateText/CostEstimateText.tsx
index 4667a1a74..d5a4b9291 100644
--- a/src/Explorer/Controls/ThroughputInput/CostEstimateText/CostEstimateText.tsx
+++ b/src/Explorer/Controls/ThroughputInput/CostEstimateText/CostEstimateText.tsx
@@ -54,8 +54,8 @@ export const CostEstimateText: FunctionComponent = ({
if (isAutoscale) {
return (
-
-
+
+
{estimatedMonthlyCost} ({currency}){iconWithEstimatedCostDisclaimer}:{" "}
{currencySign + calculateEstimateNumber(monthlyPrice / 10)} -{" "}
@@ -70,7 +70,7 @@ export const CostEstimateText: FunctionComponent = ({
return (
-
+
Estimated cost ({currency}){iconWithEstimatedCostDisclaimer}:{" "}
{currencySign + calculateEstimateNumber(hourlyPrice)} hourly /{" "}
diff --git a/src/Explorer/Controls/ThroughputInput/ThroughputInput.less b/src/Explorer/Controls/ThroughputInput/ThroughputInput.less
index 059b210f6..816145a00 100644
--- a/src/Explorer/Controls/ThroughputInput/ThroughputInput.less
+++ b/src/Explorer/Controls/ThroughputInput/ThroughputInput.less
@@ -10,9 +10,13 @@
font-size: @mediumFontSize;
padding: 0 @LargeSpace 0 @SmallSpace;
}
+// .throughputInputSpacing{
+// color: "var(--colorNeutralForeground1)";
+// }
.throughputInputSpacing > :not(:last-child) {
margin-bottom: @DefaultSpace;
+ color: "var(--colorNeutralForeground1)";
}
.capacitycalculator-link:focus {
@@ -28,3 +32,16 @@
.deleteQuery:focus::after {
outline: none !important;
}
+
+// Override Fluent UI TextField focus styles
+.throughputInputContainer {
+ :global {
+ .ms-TextField {
+ .ms-TextField-fieldGroup {
+ &:focus-within {
+ border-color: var(--colorCompoundBrandStroke1, @SelectionColor);
+ }
+ }
+ }
+ }
+}
diff --git a/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx b/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx
index 13a0923f3..6d71eb9c2 100644
--- a/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx
+++ b/src/Explorer/Controls/ThroughputInput/ThroughputInput.tsx
@@ -193,7 +193,11 @@ export const ThroughputInput: FunctionComponent = ({
*
-
+
{getThroughputLabelText()}
{PricingUtils.getRuToolTipText()}
@@ -236,14 +240,17 @@ export const ThroughputInput: FunctionComponent = ({
{isAutoscaleSelected && (
-
+
Your container throughput will automatically scale up to the maximum value you select, from a minimum of 10%
of that value.
-
+
Minimum RU/s
The minimum RU/s your container will scale to
@@ -260,6 +267,7 @@ export const ThroughputInput: FunctionComponent = ({
display: "flex",
alignItems: "center",
justifyContent: "center",
+ color: "var(--colorNeutralForeground1)",
}}
>
{Math.round(throughput / 10).toString()}
@@ -272,6 +280,7 @@ export const ThroughputInput: FunctionComponent = ({
fontSize: 12,
fontWeight: 400,
paddingBottom: 6,
+ color: "var(--colorNeutralForeground1)",
}}
>
x 10 =
@@ -279,17 +288,21 @@ export const ThroughputInput: FunctionComponent = ({
-
+
Maximum RU/s
{getAutoScaleTooltip()}
onThroughputValueChange(newInput)}
step={AutoPilotUtils.autoPilotIncrementStep}
@@ -305,7 +318,7 @@ export const ThroughputInput: FunctionComponent = ({
-