From e27cff0553e6bb9a7004c09753bb978a58f0d152 Mon Sep 17 00:00:00 2001 From: sakshigupta12feb Date: Thu, 8 Jan 2026 13:27:57 +0530 Subject: [PATCH 1/2] Copy jobs dark theme (#2308) * added a dark theme toggle button on Copyjobs next to refresh button and covered full feature * Fix formatting in Utils.test.ts * updated infor icon , error icon and text on jobs details page * rebased from master * updated the conflicts * updated the conflicts * fixed the test suit * fixed review comments * test fix --------- Co-authored-by: Sakshi Gupta --- less/documentDB.less | 6 +- src/Common/LoadingOverlay.tsx | 10 ++- src/Common/Pager/Pager.css | 11 +++ src/Common/Pager/index.tsx | 4 +- .../CommandBar/CopyJobCommandBar.test.tsx | 8 +- .../CommandBar/CopyJobCommandBar.tsx | 24 ++--- .../ContainerCopy/CommandBar/Utils.test.ts | 60 +++++++------ .../ContainerCopy/CommandBar/Utils.ts | 17 +++- .../AssignPermissions/AddManagedIdentity.tsx | 9 +- .../AddReadPermissionToDefaultIdentity.tsx | 7 +- .../AssignPermissions/AssignPermissions.tsx | 10 +-- .../DefaultManagedIdentity.tsx | 7 +- .../AssignPermissions/PointInTimeRestore.tsx | 7 +- .../AddManagedIdentity.test.tsx.snap | 14 +-- .../Screens/Components/PopoverContainer.tsx | 4 +- .../PopoverContainer.test.tsx.snap | 28 +++--- .../AddCollectionPanelWrapper.tsx | 2 +- .../AddCollectionPanelWrapper.test.tsx.snap | 8 +- .../Screens/PreviewCopyJob/PreviewCopyJob.tsx | 12 ++- .../PreviewCopyJob.test.tsx.snap | 68 +++++++-------- .../Components/MigrationTypeCheckbox.tsx | 29 +++++-- .../Screens/SelectAccount/SelectAccount.tsx | 2 +- .../__snapshots__/SelectAccount.test.tsx.snap | 2 +- .../SelectSourceAndTargetContainers.tsx | 2 +- .../components/DatabaseContainerSection.tsx | 6 +- .../Components/CopyJobDetails.tsx | 30 +++++-- .../Components/CopyJobStatusWithIcon.tsx | 39 ++++----- .../Components/CopyJobsList.tsx | 33 +++++-- .../CopyJobStatusWithIcon.test.tsx.snap | 34 ++++---- .../ContainerCopy/containerCopyStyles.less | 87 ++++++++++++++++--- .../ThroughputBucketsComponent.tsx | 5 +- .../VectorEmbeddingPoliciesComponent.tsx | 3 + .../AddCollectionPanel/AddCollectionPanel.tsx | 2 +- .../AddCollectionPanel.test.tsx.snap | 5 ++ src/Explorer/Panes/PanelLoadingScreen.tsx | 2 +- src/less/DarkModeMenus.less | 6 -- src/less/ThemeSystem.less | 2 + 37 files changed, 392 insertions(+), 213 deletions(-) diff --git a/less/documentDB.less b/less/documentDB.less index 3950aad9c..83a2f1a2b 100644 --- a/less/documentDB.less +++ b/less/documentDB.less @@ -406,7 +406,11 @@ body { width: 440px; min-height: 565px; } - +.dataExplorerLoaderforcopyJobs{ + width: 100%; + min-height: 565px; + right: 0; +} .dataExplorerTabLoaderContainer { left: initial; top: initial; diff --git a/src/Common/LoadingOverlay.tsx b/src/Common/LoadingOverlay.tsx index 2cbf34213..96bf1943a 100644 --- a/src/Common/LoadingOverlay.tsx +++ b/src/Common/LoadingOverlay.tsx @@ -1,4 +1,5 @@ import { Overlay, Spinner, SpinnerSize } from "@fluentui/react"; +import { useThemeStore } from "hooks/useTheme"; import React from "react"; interface LoadingOverlayProps { @@ -7,6 +8,7 @@ interface LoadingOverlayProps { } const LoadingOverlay: React.FC = ({ isLoading, label }) => { + const isDarkMode = useThemeStore((state) => state.isDarkMode); if (!isLoading) { return null; } @@ -16,7 +18,7 @@ const LoadingOverlay: React.FC = ({ isLoading, label }) => data-test="loading-overlay" styles={{ root: { - backgroundColor: "rgba(255,255,255,0.9)", + backgroundColor: isDarkMode ? "rgba(32, 31, 30, 0.9)" : "rgba(255,255,255,0.9)", zIndex: 9999, display: "flex", alignItems: "center", @@ -24,7 +26,11 @@ const LoadingOverlay: React.FC = ({ isLoading, label }) => }, }} > - + ); }; diff --git a/src/Common/Pager/Pager.css b/src/Common/Pager/Pager.css index a29b6f50a..3379c2094 100644 --- a/src/Common/Pager/Pager.css +++ b/src/Common/Pager/Pager.css @@ -11,3 +11,14 @@ gap: 8px; align-items: center; } + +/* Override dark mode inherit for pagination icons */ +body.isDarkMode .pager-container .ms-Button .ms-Button-icon, +body.isDarkMode .pager-container .ms-Button i { + color: var(--colorBrandForeground1); +} + +body.isDarkMode .pager-container .ms-Button:disabled .ms-Button-icon, +body.isDarkMode .pager-container .ms-Button:disabled i { + color: var(--colorNeutralForegroundDisabled); +} \ No newline at end of file diff --git a/src/Common/Pager/index.tsx b/src/Common/Pager/index.tsx index 06ff7f2be..f85209c24 100644 --- a/src/Common/Pager/index.tsx +++ b/src/Common/Pager/index.tsx @@ -59,7 +59,7 @@ const Pager: React.FC = ({ return (
{showItemCount && ( - + Showing {startIndex + 1} - {endIndex} of {totalCount} items )} @@ -82,7 +82,7 @@ const Pager: React.FC = ({ disabled={disabled || currentPage === 1} styles={iconButtonStyles} /> - + Page {currentPage} of {totalPages} { render(); - expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer); + expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer, false); expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(1); }); @@ -163,7 +163,7 @@ describe("CopyJobCommandBar", () => { render(); - expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer); + expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer, false); expect(mockConvertButton.mock.calls[0][0]).toEqual(mockCommandButtonProps); }); @@ -175,11 +175,11 @@ describe("CopyJobCommandBar", () => { mockConvertButton.mockReturnValue([]); const { rerender } = render(); - expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer1); + expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer1, false); rerender(); - expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer2); + expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer2, false); expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(2); }); }); diff --git a/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx index 92b1107c9..9cd2f5002 100644 --- a/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx +++ b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx @@ -1,24 +1,28 @@ import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react"; import React from "react"; -import { StyleConstants } from "../../../Common/StyleConstants"; +import { useThemeStore } from "../../../hooks/useTheme"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import * as CommandBarUtil from "../../Menus/CommandBar/CommandBarUtil"; +import { getThemeTokens } from "../../Theme/ThemeUtil"; import { ContainerCopyProps } from "../Types/CopyJobTypes"; import { getCommandBarButtons } from "./Utils"; -const backgroundColor = StyleConstants.BaseLight; -const rootStyle = { - root: { - backgroundColor: backgroundColor, - }, -}; - const CopyJobCommandBar: React.FC = ({ explorer }) => { - const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer); + const isDarkMode = useThemeStore((state) => state.isDarkMode); + const themeTokens = getThemeTokens(isDarkMode); + const backgroundColor = themeTokens.colorNeutralBackground1; + + const rootStyle = { + root: { + backgroundColor: backgroundColor, + }, + }; + + const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer, isDarkMode); const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor); return ( -
+
{ describe("getCommandBarButtons", () => { it("should return an array of command button props", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); expect(buttons).toBeDefined(); expect(Array.isArray(buttons)).toBe(true); @@ -58,7 +58,7 @@ describe("CommandBar Utils", () => { }); it("should include create copy job button", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); const createButton = buttons[0]; expect(createButton).toBeDefined(); @@ -70,7 +70,7 @@ describe("CommandBar Utils", () => { }); it("should include refresh button", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); const refreshButton = buttons[1]; expect(refreshButton).toBeDefined(); @@ -80,11 +80,11 @@ describe("CommandBar Utils", () => { }); it("should include feedback button when platform is Portal", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); - expect(buttons.length).toBe(3); + expect(buttons.length).toBe(4); - const feedbackButton = buttons[2]; + const feedbackButton = buttons[3]; expect(feedbackButton).toBeDefined(); expect(feedbackButton.ariaLabel).toBe("Provide feedback on copy jobs"); expect(feedbackButton.tooltipText).toBe("Feedback"); @@ -105,13 +105,13 @@ describe("CommandBar Utils", () => { })); const { getCommandBarButtons: getCommandBarButtonsEmulator } = await import("./Utils"); - const buttons = getCommandBarButtonsEmulator(mockExplorer); + const buttons = getCommandBarButtonsEmulator(mockExplorer, false); - expect(buttons.length).toBe(2); + expect(buttons.length).toBe(3); }); it("should call openCreateCopyJobPanel when create button is clicked", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); const createButton = buttons[0]; createButton.onCommandClick({} as React.SyntheticEvent); @@ -121,7 +121,7 @@ describe("CommandBar Utils", () => { }); it("should call refreshJobList when refresh button is clicked", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); const refreshButton = buttons[1]; refreshButton.onCommandClick({} as React.SyntheticEvent); @@ -130,8 +130,8 @@ describe("CommandBar Utils", () => { }); it("should call openContainerCopyFeedbackBlade when feedback button is clicked", () => { - const buttons = getCommandBarButtons(mockExplorer); - const feedbackButton = buttons[2]; + const buttons = getCommandBarButtons(mockExplorer, false); + const feedbackButton = buttons[3]; feedbackButton.onCommandClick({} as React.SyntheticEvent); @@ -139,7 +139,7 @@ describe("CommandBar Utils", () => { }); it("should return buttons with correct icon sources", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); expect(buttons[0].iconSrc).toBeDefined(); expect(buttons[0].iconAlt).toBe("Create Copy Job"); @@ -148,7 +148,10 @@ describe("CommandBar Utils", () => { expect(buttons[1].iconAlt).toBe("Refresh"); expect(buttons[2].iconSrc).toBeDefined(); - expect(buttons[2].iconAlt).toBe("Feedback"); + 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", () => { @@ -157,14 +160,14 @@ describe("CommandBar Utils", () => { return selector(state); }); - const buttons = getCommandBarButtons(mockExplorer); + 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); + const buttons = getCommandBarButtons(mockExplorer, false); buttons.forEach((button) => { expect(button.hasPopup).toBe(false); @@ -172,7 +175,7 @@ describe("CommandBar Utils", () => { }); it("should set commandButtonLabel to undefined for all buttons", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); buttons.forEach((button) => { expect(button.commandButtonLabel).toBeUndefined(); @@ -180,7 +183,7 @@ describe("CommandBar Utils", () => { }); it("should respect disabled state when provided", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); buttons.forEach((button) => { expect(button.disabled).toBe(false); @@ -188,7 +191,7 @@ describe("CommandBar Utils", () => { }); it("should return CommandButtonComponentProps with all required properties", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); buttons.forEach((button: CommandButtonComponentProps) => { expect(button).toHaveProperty("iconSrc"); @@ -202,18 +205,19 @@ describe("CommandBar Utils", () => { }); }); - it("should maintain button order: create, refresh, feedback", () => { - const buttons = getCommandBarButtons(mockExplorer); + 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("Feedback"); + 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); + const buttons = getCommandBarButtons(mockExplorer, false); buttons.forEach((button) => { expect(() => button.onCommandClick({} as React.SyntheticEvent)).not.toThrow(); @@ -221,7 +225,7 @@ describe("CommandBar Utils", () => { }); it("should call correct action for each button", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); buttons[0].onCommandClick({} as React.SyntheticEvent); expect(Actions.openCreateCopyJobPanel).toHaveBeenCalledWith(mockExplorer); @@ -229,14 +233,14 @@ describe("CommandBar Utils", () => { buttons[1].onCommandClick({} as React.SyntheticEvent); expect(mockRefreshJobList).toHaveBeenCalled(); - buttons[2].onCommandClick({} as React.SyntheticEvent); + buttons[3].onCommandClick({} as React.SyntheticEvent); expect(mockOpenContainerCopyFeedbackBlade).toHaveBeenCalled(); }); }); describe("Accessibility", () => { it("should have aria labels for all buttons", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); buttons.forEach((button) => { expect(button.ariaLabel).toBeDefined(); @@ -246,7 +250,7 @@ describe("CommandBar Utils", () => { }); it("should have tooltip text for all buttons", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); buttons.forEach((button) => { expect(button.tooltipText).toBeDefined(); @@ -256,7 +260,7 @@ describe("CommandBar Utils", () => { }); it("should have icon alt text for all buttons", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); buttons.forEach((button) => { expect(button.iconAlt).toBeDefined(); diff --git a/src/Explorer/ContainerCopy/CommandBar/Utils.ts b/src/Explorer/ContainerCopy/CommandBar/Utils.ts index 152c2dfbd..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[] = [ { @@ -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/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.tsx index 76dca972b..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} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx index 051471e11..5af5630d7 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx @@ -13,7 +13,12 @@ import useToggle from "./hooks/useToggle"; const TooltipContent = ( {ContainerCopyMessages.readPermissionAssigned.tooltip.content}   - + {ContainerCopyMessages.readPermissionAssigned.tooltip.hrefText} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx index f3cb98d75..40a657f59 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx @@ -48,8 +48,8 @@ const PermissionGroup: React.FC = ({ id, title, descripti tokens={{ childrenGap: 15 }} styles={{ root: { - background: "#fafafa", - border: "1px solid #e1e1e1", + background: "var(--colorNeutralBackground2)", + border: "1px solid var(--colorNeutralStroke1)", borderRadius: 8, padding: 16, boxShadow: "0 1px 3px rgba(0,0,0,0.1)", @@ -57,11 +57,11 @@ const PermissionGroup: React.FC = ({ id, title, descripti }} > - + {title} {description && ( - + {description} )} @@ -105,7 +105,7 @@ const AssignPermissions = () => { className="assignPermissionsContainer" tokens={{ childrenGap: 20 }} > - + {isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online ? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription( copyJobState?.source?.account?.name || "", diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx index 3eeb60bbf..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} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx index 85fbc769a..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} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddManagedIdentity.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddManagedIdentity.test.tsx.snap index 12060d2f0..5e565200b 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddManagedIdentity.test.tsx.snap +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddManagedIdentity.test.tsx.snap @@ -5,7 +5,7 @@ exports[`AddManagedIdentity Snapshot Tests renders initial state correctly 1`] = class="ms-Stack addManagedIdentityContainer css-109" > 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.   @@ -93,7 +93,7 @@ exports[`AddManagedIdentity Snapshot Tests renders loading state 1`] = ` class="ms-Stack addManagedIdentityContainer css-109" > 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.   @@ -196,13 +196,13 @@ exports[`AddManagedIdentity Snapshot Tests renders loading state 1`] = `
Enable system assigned managed identity Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button. @@ -265,7 +265,7 @@ exports[`AddManagedIdentity Snapshot Tests renders with toggle on and popover vi class="ms-Stack addManagedIdentityContainer css-109" > 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.   @@ -351,13 +351,13 @@ exports[`AddManagedIdentity Snapshot Tests renders with toggle on and popover vi style="max-width: 450px;" > Enable system assigned managed identity Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button. diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx index f00cfa858..8ec30dedc 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx @@ -23,10 +23,10 @@ const PopoverContainer: React.FC = React.memo( style={{ maxWidth: 450 }} > - + {title} - {children} + {children} 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 index fbe0ca8c1..7a6897fd6 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/PopoverContainer.test.tsx.snap +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/PopoverContainer.test.tsx.snap @@ -8,11 +8,11 @@ exports[`PopoverMessage Component Edge Cases should handle empty string title 1` style="max-width: 450px;" >
Test content @@ -76,7 +76,7 @@ exports[`PopoverMessage Component Edge Cases should handle null children 1`] = ` style="max-width: 450px;" > Test Title @@ -139,7 +139,7 @@ exports[`PopoverMessage Component Edge Cases should handle undefined children 1` style="max-width: 450px;" > Test Title @@ -202,13 +202,13 @@ exports[`PopoverMessage Component Edge Cases should handle very long title 1`] = style="max-width: 450px;" > This is a very long title that might cause layout issues or text wrapping in the popover component
Test content @@ -274,13 +274,13 @@ exports[`PopoverMessage Component Rendering should render correctly when visible style="max-width: 450px;" > Test Title
Test content @@ -344,13 +344,13 @@ exports[`PopoverMessage Component Rendering should render correctly with differe style="max-width: 450px;" > Test Title

@@ -419,13 +419,13 @@ exports[`PopoverMessage Component Rendering should render correctly with differe style="max-width: 450px;" > Custom Title

Test content @@ -493,13 +493,13 @@ exports[`PopoverMessage Component Rendering should render correctly with loading data-testid="loading-overlay" /> Test Title
Test content 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 index 6824baba9..ae6d7b4ec 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap @@ -9,7 +9,7 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot 1`] class="ms-StackItem addCollectionPanelHeader css-110" > Select the properties for your container. @@ -50,7 +50,7 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot wit class="ms-StackItem addCollectionPanelHeader css-110" > Select the properties for your container. @@ -91,7 +91,7 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot wit class="ms-StackItem addCollectionPanelHeader css-110" > Select the properties for your container. @@ -132,7 +132,7 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot wit class="ms-StackItem addCollectionPanelHeader css-110" > Select the properties for your container. diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx index 4abf891f1..84abc0ece 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx @@ -36,12 +36,16 @@ const PreviewCopyJob: React.FC = () => { - {ContainerCopyMessages.sourceSubscriptionLabel} - {copyJobState.source?.subscription?.displayName} + {ContainerCopyMessages.sourceSubscriptionLabel} + + {copyJobState.source?.subscription?.displayName} + - {ContainerCopyMessages.sourceAccountLabel} - {copyJobState.source?.account?.name} + {ContainerCopyMessages.sourceAccountLabel} + + {copyJobState.source?.account?.name} + Source subscription Test Subscription @@ -62,12 +62,12 @@ exports[`PreviewCopyJob should handle special characters in database and contain class="ms-Stack css-124" > Source account test-account @@ -369,12 +369,12 @@ exports[`PreviewCopyJob should render component with cross-subscription setup 1` class="ms-Stack css-124" > Source subscription Test Subscription @@ -384,12 +384,12 @@ exports[`PreviewCopyJob should render component with cross-subscription setup 1` class="ms-Stack css-124" > Source account test-account @@ -691,12 +691,12 @@ exports[`PreviewCopyJob should render with default state and empty job name 1`] class="ms-Stack css-124" > Source subscription Test Subscription @@ -706,12 +706,12 @@ exports[`PreviewCopyJob should render with default state and empty job name 1`] class="ms-Stack css-124" > Source account test-account @@ -1013,12 +1013,12 @@ exports[`PreviewCopyJob should render with long subscription and account names 1 class="ms-Stack css-124" > Source subscription This is a very long subscription name that might cause display issues if not handled properly @@ -1028,12 +1028,12 @@ exports[`PreviewCopyJob should render with long subscription and account names 1 class="ms-Stack css-124" > Source account this-is-a-very-long-database-account-name-that-might-cause-display-issues @@ -1335,12 +1335,12 @@ exports[`PreviewCopyJob should render with missing source account information 1` class="ms-Stack css-124" > Source subscription Test Subscription @@ -1350,7 +1350,7 @@ exports[`PreviewCopyJob should render with missing source account information 1` class="ms-Stack css-124" > Source account @@ -1651,7 +1651,7 @@ exports[`PreviewCopyJob should render with missing source subscription informati class="ms-Stack css-124" > Source subscription @@ -1660,12 +1660,12 @@ exports[`PreviewCopyJob should render with missing source subscription informati class="ms-Stack css-124" > Source account test-account @@ -1967,12 +1967,12 @@ exports[`PreviewCopyJob should render with online migration type 1`] = ` class="ms-Stack css-124" > Source subscription Test Subscription @@ -1982,12 +1982,12 @@ exports[`PreviewCopyJob should render with online migration type 1`] = ` class="ms-Stack css-124" > Source account test-account @@ -2289,12 +2289,12 @@ exports[`PreviewCopyJob should render with pre-filled job name 1`] = ` class="ms-Stack css-124" > Source subscription Test Subscription @@ -2304,12 +2304,12 @@ exports[`PreviewCopyJob should render with pre-filled job name 1`] = ` class="ms-Stack css-124" > Source account test-account @@ -2611,12 +2611,12 @@ exports[`PreviewCopyJob should render with undefined database and container name class="ms-Stack css-124" > Source subscription Test Subscription @@ -2626,12 +2626,12 @@ exports[`PreviewCopyJob should render with undefined database and container name class="ms-Stack css-124" > Source account test-account diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx index 092c5ad67..4e8ae6946 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/prop-types */ /* eslint-disable react/display-name */ -import { Checkbox, Stack } from "@fluentui/react"; +import { Checkbox, ICheckboxStyles, Stack } from "@fluentui/react"; import React from "react"; import ContainerCopyMessages from "../../../../ContainerCopyMessages"; @@ -9,8 +9,25 @@ interface MigrationTypeCheckboxProps { onChange: (_ev?: React.FormEvent, checked?: boolean) => void; } -export const MigrationTypeCheckbox: React.FC = React.memo(({ checked, onChange }) => ( - - - -)); +const checkboxStyles: ICheckboxStyles = { + text: { color: "var(--colorNeutralForeground1)" }, + checkbox: { borderColor: "var(--colorNeutralStroke1)" }, + root: { + selectors: { + ":hover .ms-Checkbox-text": { color: "var(--colorNeutralForeground1)" }, + }, + }, +}; + +export const MigrationTypeCheckbox: React.FC = React.memo(({ checked, onChange }) => { + return ( + + + + ); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx index ba1072de7..1d7715f48 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx @@ -21,7 +21,7 @@ const SelectAccount = React.memo(() => { return ( - {ContainerCopyMessages.selectAccountDescription} + {ContainerCopyMessages.selectAccountDescription} 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 index 90a8ddc2b..b84b677cc 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap @@ -6,7 +6,7 @@ exports[`SelectAccount Component Rendering should render correctly with snapshot data-test="Panel:SelectAccountContainer" > Please select a source account from which to copy. diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx index d235b40d6..6a0ad3715 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx @@ -52,7 +52,7 @@ const SelectSourceAndTargetContainers = ({ showAddCollectionPanel }: SelectSourc className="selectSourceAndTargetContainers" tokens={{ childrenGap: 25 }} > - {ContainerCopyMessages.selectSourceAndTargetContainersDescription} + {ContainerCopyMessages.selectSourceAndTargetContainersDescription} {handleOnDemandCreateContainer && ( - handleOnDemandCreateContainer()}> + handleOnDemandCreateContainer()} + > {ContainerCopyMessages.createContainerButtonLabel} )} 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.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx index f4061a3b5..1fb996639 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx @@ -1,30 +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", @@ -35,6 +19,17 @@ const iconMap: Partial> = { [CopyJobStatusType.Completed]: "CompletedSolid", }; +// 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; } @@ -47,19 +42,17 @@ const CopyJobStatusWithIcon: React.FC = React.memo(( CopyJobStatusType.InProgress, CopyJobStatusType.Partitioning, ].includes(status); + const iconColor = statusIconColors[status] || "var(--colorNeutralForeground2)"; + const iconStyle = mergeStyles(iconClass, { color: iconColor }); return ( {isSpinnerStatus ? ( ) : ( - + )} - {statusText} + {statusText} ); }); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx index 86da3a03e..a263ac137 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx @@ -15,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"; @@ -26,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); @@ -88,11 +92,28 @@ const CopyJobsList: React.FC = ({ 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 index 9940ee7e9..d2e4482ce 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/__snapshots__/CopyJobStatusWithIcon.test.tsx.snap +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/__snapshots__/CopyJobStatusWithIcon.test.tsx.snap @@ -13,7 +13,7 @@ exports[`CopyJobStatusWithIcon Spinner Status Types renders InProgress with spin />
Running @@ -33,7 +33,7 @@ exports[`CopyJobStatusWithIcon Spinner Status Types renders Partitioning with sp />
Running @@ -53,7 +53,7 @@ exports[`CopyJobStatusWithIcon Spinner Status Types renders Running with spinner />
Running @@ -66,7 +66,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders > Cancelled @@ -87,7 +87,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders > Completed @@ -108,7 +108,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders > Failed @@ -129,7 +129,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders > Failed @@ -150,7 +150,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders > Paused @@ -171,7 +171,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders > Queued @@ -192,7 +192,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders > Cancelled diff --git a/src/Explorer/ContainerCopy/containerCopyStyles.less b/src/Explorer/ContainerCopy/containerCopyStyles.less index 05d9facec..c86986c62 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,40 @@ .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); + } + } + } .create-container-link-btn { padding: 0; height: 25px; - color: @LinkColor; + color: var(--colorBrandForeground1); &:focus { outline: none; } + } /* Create collection panel */ @@ -105,7 +165,6 @@ width: 100%; max-width: 100%; margin: 0 auto; - .ms-DetailsList { width: 100%; @@ -114,33 +173,33 @@ 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-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 +227,7 @@ } .ms-DetailsRow-cell { font-size: @DefaultFontSize; - color: @BaseHigh; + color: var(--colorNeutralForeground1); } } } diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx index f49d60967..bd8f638cd 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx @@ -103,7 +103,10 @@ export const ThroughputBucketsComponent: FC = ( 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/VectorSearch/VectorEmbeddingPoliciesComponent.tsx b/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx index ba0c5b62b..21aa1483c 100644 --- a/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx +++ b/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx @@ -53,6 +53,7 @@ type VectorEmbeddingPolicyProperty = "dataType" | "distanceFunction" | "indexTyp const labelStyles = { root: { fontSize: 12, + color: "var(--colorNeutralForeground1)", }, }; @@ -63,6 +64,8 @@ const textFieldStyles: IStyleFunctionOrObject - + Azure Synapse Link is required for creating an analytical store{" "} {getCollectionName().toLocaleLowerCase()}. Enable Synapse Link for this Cosmos DB account.
Azure Synapse Link is required for creating an analytical store diff --git a/src/Explorer/Panes/PanelLoadingScreen.tsx b/src/Explorer/Panes/PanelLoadingScreen.tsx index 8068b7945..a8b7856a8 100644 --- a/src/Explorer/Panes/PanelLoadingScreen.tsx +++ b/src/Explorer/Panes/PanelLoadingScreen.tsx @@ -2,7 +2,7 @@ import React from "react"; import LoadingIndicator_3Squares from "../../../images/LoadingIndicator_3Squares.gif"; export const PanelLoadingScreen: React.FunctionComponent = () => ( -
+
); diff --git a/src/less/DarkModeMenus.less b/src/less/DarkModeMenus.less index a0724ea5d..c0cda8932 100644 --- a/src/less/DarkModeMenus.less +++ b/src/less/DarkModeMenus.less @@ -316,11 +316,6 @@ body.isDarkMode { background-color: transparent; } - // High specificity override for any nested elements - * { - color: var(--colorNeutralForeground1); - } - // Ensure links maintain proper colors .ms-Link { color: var(--colorBrandForeground1); @@ -438,7 +433,6 @@ body.isDarkMode { button { &:not(.ms-Button):not(.ms-IconButton) { - background-color: var(--colorNeutralBackground1); color: var(--colorNeutralForeground1); &:hover { diff --git a/src/less/ThemeSystem.less b/src/less/ThemeSystem.less index 6fdeed790..05298d2e6 100644 --- a/src/less/ThemeSystem.less +++ b/src/less/ThemeSystem.less @@ -12,6 +12,7 @@ --colorCompoundBrandStroke1: @SelectionColor; --colorBrandForeground1: @LinkColor; --colorPaletteRedForeground1: @ErrorColor; + --colorSuccessGreen: #107c10; --overlayBackground: rgba(0, 0, 0, 0.4); --colorBrandBackground: @SelectionColor; --colorBrandBackgroundHover: @AccentMediumHigh; @@ -32,6 +33,7 @@ body.isDarkMode { --colorCompoundBrandStroke1: #4db6e8; --colorBrandForeground1: #4db6e8; --colorPaletteRedForeground1: #f25d5d; + --colorSuccessGreen: #107c10; --overlayBackground: rgba(0, 0, 0, 0.4); --colorBrandBackground: #0078d4; --colorBrandBackgroundHover: #106ebe; From b71ea50972156ac57bad3f9b2917c9191c6230ee Mon Sep 17 00:00:00 2001 From: BChoudhury-ms Date: Thu, 8 Jan 2026 17:19:16 +0530 Subject: [PATCH 2/2] Add test infrastructure and data-test attributes for Container Copy e2e tests (#2280) * Add test infrastructure and data-test attributes for Container Copy e2e testing * fix container copy FTs --- .github/workflows/ci.yml | 5 + .../Actions/CopyJobActions.test.tsx | 268 +++++----- .../ContainerCopy/Actions/CopyJobActions.tsx | 10 +- .../MonitorCopyJobs/MonitorCopyJobs.tsx | 4 +- test/README.md | 3 + test/fx.ts | 174 ++++++- test/sql/containercopy.spec.ts | 493 ++++++++++++++++++ .../changePartitionKey.spec.ts | 10 +- test/testData.ts | 63 +++ test/testExplorer/TestExplorer.ts | 7 +- 10 files changed, 878 insertions(+), 159 deletions(-) create mode 100644 test/sql/containercopy.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d8db8a78..dac26c32d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,6 +192,9 @@ jobs: NOSQL_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-readonly.documents.azure.com/.default" -o tsv --query accessToken) echo "::add-mask::$NOSQL_READONLY_TESTACCOUNT_TOKEN" echo NOSQL_READONLY_TESTACCOUNT_TOKEN=$NOSQL_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV + NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-containercopyonly.documents.azure.com/.default" -o tsv --query accessToken) + echo "::add-mask::$NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN" + echo NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=$NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN >> $GITHUB_ENV TABLE_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-tables.documents.azure.com/.default" -o tsv --query accessToken) echo "::add-mask::$TABLE_TESTACCOUNT_TOKEN" echo TABLE_TESTACCOUNT_TOKEN=$TABLE_TESTACCOUNT_TOKEN >> $GITHUB_ENV @@ -210,6 +213,8 @@ jobs: # MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken) # echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN" # echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV + - name: List test files for shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}} + run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --list - name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}} run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --workers=3 - name: Upload blob report to GitHub Actions Artifacts diff --git a/src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx b/src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx index a1885e062..9bb7fe3ea 100644 --- a/src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx +++ b/src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx @@ -1,5 +1,6 @@ import "@testing-library/jest-dom"; import Explorer from "Explorer/Explorer"; +import { getDataTransferJobs } from "../../../Common/dataAccess/dataTransfers"; import * as Logger from "../../../Common/Logger"; import { useSidePanel } from "../../../hooks/useSidePanel"; import * as dataTransferService from "../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs"; @@ -30,6 +31,7 @@ jest.mock("../../../Common/Logger"); jest.mock("../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs"); jest.mock("../MonitorCopyJobs/MonitorCopyJobRefState"); jest.mock("../CopyJobUtils"); +jest.mock("../../../Common/dataAccess/dataTransfers"); describe("CopyJobActions", () => { beforeEach(() => { @@ -154,33 +156,31 @@ describe("CopyJobActions", () => { }); it("should fetch and format copy jobs successfully", async () => { - const mockResponse = { - value: [ - { - properties: { - jobName: "job-1", - status: "InProgress", - lastUpdatedUtcTime: "2025-01-01T10:00:00Z", - processedCount: 50, - totalCount: 100, - mode: "online", - duration: "01:30:45", - source: { - component: "CosmosDBSql", - databaseName: "source-db", - containerName: "source-container", - }, - destination: { - component: "CosmosDBSql", - databaseName: "target-db", - containerName: "target-container", - }, + const mockResponse = [ + { + properties: { + jobName: "job-1", + status: "InProgress", + lastUpdatedUtcTime: "2025-01-01T10:00:00Z", + processedCount: 50, + totalCount: 100, + mode: "online", + duration: "01:30:45", + source: { + component: "CosmosDBSql", + databaseName: "source-db", + containerName: "source-container", + }, + destination: { + component: "CosmosDBSql", + databaseName: "target-db", + containerName: "target-container", }, }, - ], - }; + }, + ]; - (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse); + (getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse); (CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({ formattedDateTime: "1/1/2025, 10:00:00 AM", timestamp: 1704106800000, @@ -201,38 +201,36 @@ describe("CopyJobActions", () => { }); it("should filter jobs by CosmosDBSql component", async () => { - const mockResponse = { - value: [ - { - properties: { - jobName: "sql-job", - status: "Completed", - lastUpdatedUtcTime: "2025-01-01T10:00:00Z", - processedCount: 100, - totalCount: 100, - mode: "offline", - duration: "02:00:00", - source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, - destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, - }, + const mockResponse = [ + { + properties: { + jobName: "sql-job", + status: "Completed", + lastUpdatedUtcTime: "2025-01-01T10:00:00Z", + processedCount: 100, + totalCount: 100, + mode: "offline", + duration: "02:00:00", + source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, + destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, }, - { - properties: { - jobName: "other-job", - status: "Completed", - lastUpdatedUtcTime: "2025-01-01T11:00:00Z", - processedCount: 100, - totalCount: 100, - mode: "offline", - duration: "01:00:00", - source: { component: "OtherComponent", databaseName: "db1", containerName: "c1" }, - destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, - }, + }, + { + properties: { + jobName: "other-job", + status: "Completed", + lastUpdatedUtcTime: "2025-01-01T11:00:00Z", + processedCount: 100, + totalCount: 100, + mode: "offline", + duration: "01:00:00", + source: { component: "OtherComponent", databaseName: "db1", containerName: "c1" }, + destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, }, - ], - }; + }, + ]; - (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse); + (getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse); (CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({ formattedDateTime: "1/1/2025, 10:00:00 AM", timestamp: 1704106800000, @@ -247,38 +245,36 @@ describe("CopyJobActions", () => { }); it("should sort jobs by last updated time (newest first)", async () => { - const mockResponse = { - value: [ - { - properties: { - jobName: "older-job", - status: "Completed", - lastUpdatedUtcTime: "2025-01-01T10:00:00Z", - processedCount: 100, - totalCount: 100, - mode: "offline", - duration: "01:00:00", - source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, - destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, - }, + const mockResponse = [ + { + properties: { + jobName: "older-job", + status: "Completed", + lastUpdatedUtcTime: "2025-01-01T10:00:00Z", + processedCount: 100, + totalCount: 100, + mode: "offline", + duration: "01:00:00", + source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, + destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, }, - { - properties: { - jobName: "newer-job", - status: "InProgress", - lastUpdatedUtcTime: "2025-01-02T10:00:00Z", - processedCount: 50, - totalCount: 100, - mode: "online", - duration: "00:30:00", - source: { component: "CosmosDBSql", databaseName: "db3", containerName: "c3" }, - destination: { component: "CosmosDBSql", databaseName: "db4", containerName: "c4" }, - }, + }, + { + properties: { + jobName: "newer-job", + status: "InProgress", + lastUpdatedUtcTime: "2025-01-02T10:00:00Z", + processedCount: 50, + totalCount: 100, + mode: "online", + duration: "00:30:00", + source: { component: "CosmosDBSql", databaseName: "db3", containerName: "c3" }, + destination: { component: "CosmosDBSql", databaseName: "db4", containerName: "c4" }, }, - ], - }; + }, + ]; - (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse); + (getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse); (CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({ formattedDateTime: "1/1/2025, 10:00:00 AM", timestamp: 1704106800000, @@ -293,25 +289,23 @@ describe("CopyJobActions", () => { }); it("should calculate completion percentage correctly", async () => { - const mockResponse = { - value: [ - { - properties: { - jobName: "job-1", - status: "InProgress", - lastUpdatedUtcTime: "2025-01-01T10:00:00Z", - processedCount: 75, - totalCount: 100, - mode: "online", - duration: "01:00:00", - source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, - destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, - }, + const mockResponse = [ + { + properties: { + jobName: "job-1", + status: "InProgress", + lastUpdatedUtcTime: "2025-01-01T10:00:00Z", + processedCount: 75, + totalCount: 100, + mode: "online", + duration: "01:00:00", + source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, + destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, }, - ], - }; + }, + ]; - (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse); + (getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse); (CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({ formattedDateTime: "1/1/2025, 10:00:00 AM", timestamp: 1704106800000, @@ -325,25 +319,23 @@ describe("CopyJobActions", () => { }); it("should handle zero total count gracefully", async () => { - const mockResponse = { - value: [ - { - properties: { - jobName: "job-1", - status: "Pending", - lastUpdatedUtcTime: "2025-01-01T10:00:00Z", - processedCount: 0, - totalCount: 0, - mode: "online", - duration: "00:00:00", - source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, - destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, - }, + const mockResponse = [ + { + properties: { + jobName: "job-1", + status: "Pending", + lastUpdatedUtcTime: "2025-01-01T10:00:00Z", + processedCount: 0, + totalCount: 0, + mode: "online", + duration: "00:00:00", + source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, + destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, }, - ], - }; + }, + ]; - (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse); + (getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse); (CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({ formattedDateTime: "1/1/2025, 10:00:00 AM", timestamp: 1704106800000, @@ -361,26 +353,24 @@ describe("CopyJobActions", () => { message: "Error message line 1\r\n\r\nError message line 2", code: "ErrorCode123", }; - const mockResponse = { - value: [ - { - properties: { - jobName: "failed-job", - status: "Failed", - lastUpdatedUtcTime: "2025-01-01T10:00:00Z", - processedCount: 50, - totalCount: 100, - mode: "offline", - duration: "00:30:00", - source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, - destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, - error: mockError, - }, + const mockResponse = [ + { + properties: { + jobName: "failed-job", + status: "Failed", + lastUpdatedUtcTime: "2025-01-01T10:00:00Z", + processedCount: 50, + totalCount: 100, + mode: "offline", + duration: "00:30:00", + source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, + destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, + error: mockError, }, - ], - }; + }, + ]; - (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse); + (getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse); (CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({ formattedDateTime: "1/1/2025, 10:00:00 AM", timestamp: 1704106800000, @@ -408,7 +398,7 @@ describe("CopyJobActions", () => { }; (global as any).AbortController = jest.fn(() => mockAbortController); - (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue({ value: [] }); + (getDataTransferJobs as jest.Mock).mockResolvedValue([]); getCopyJobs(); expect(mockAbortController.abort).not.toHaveBeenCalled(); @@ -418,9 +408,7 @@ describe("CopyJobActions", () => { }); it("should throw error for invalid response format", async () => { - (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue({ - value: "not-an-array", - }); + (getDataTransferJobs as jest.Mock).mockResolvedValue("not-an-array"); await expect(getCopyJobs()).rejects.toThrow("Invalid migration job status response: Expected an array of jobs."); }); @@ -430,7 +418,7 @@ describe("CopyJobActions", () => { message: "Aborted", content: JSON.stringify({ message: "signal is aborted without reason" }), }; - (dataTransferService.listByDatabaseAccount as jest.Mock).mockRejectedValue(abortError); + (getDataTransferJobs as jest.Mock).mockRejectedValue(abortError); await expect(getCopyJobs()).rejects.toMatchObject({ message: expect.stringContaining("Previous copy job request was cancelled."), @@ -439,7 +427,7 @@ describe("CopyJobActions", () => { it("should handle generic errors", async () => { const genericError = new Error("Network error"); - (dataTransferService.listByDatabaseAccount as jest.Mock).mockRejectedValue(genericError); + (getDataTransferJobs as jest.Mock).mockRejectedValue(genericError); await expect(getCopyJobs()).rejects.toThrow("Network error"); }); diff --git a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx index e2e6b6fc7..821f87bc9 100644 --- a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx +++ b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx @@ -1,13 +1,13 @@ import Explorer from "Explorer/Explorer"; import React from "react"; import { userContext } from "UserContext"; +import { getDataTransferJobs } from "../../../Common/dataAccess/dataTransfers"; import { logError } from "../../../Common/Logger"; import { useSidePanel } from "../../../hooks/useSidePanel"; import { cancel, complete, create, - listByDatabaseAccount, pause, resume, } from "../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs"; @@ -63,14 +63,8 @@ export const getCopyJobs = async (): Promise => { const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId( userContext.databaseAccount?.id || "", ); - const response = await listByDatabaseAccount( - subscriptionId, - resourceGroup, - accountName, - copyJobsAbortController.signal, - ); + const jobs = await getDataTransferJobs(subscriptionId, resourceGroup, accountName, copyJobsAbortController.signal); - const jobs = response.value || []; if (!Array.isArray(jobs)) { throw new Error("Invalid migration job status response: Expected an array of jobs."); } diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx index 56ec498f8..c89488cbc 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx @@ -10,7 +10,7 @@ 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 { @@ -57,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]); diff --git a/test/README.md b/test/README.md index 06c695120..ba5acc22e 100644 --- a/test/README.md +++ b/test/README.md @@ -164,6 +164,9 @@ $ENV:NOSQL_TESTACCOUNT_TOKEN=az account get-access-token --scope "https://.documents.azure.com/.default" -o tsv --query accessToken +# NoSQL API (Container Copy) +$ENV:NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=az account get-access-token --scope "https://.documents.azure.com/.default" -o tsv --query accessToken + # Tables API $ENV:TABLE_TESTACCOUNT_TOKEN=az account get-access-token --scope "https://.documents.azure.com/.default" -o tsv --query accessToken diff --git a/test/fx.ts b/test/fx.ts index 393eb59d7..c1c2b6a47 100644 --- a/test/fx.ts +++ b/test/fx.ts @@ -11,7 +11,7 @@ export interface TestNameOptions { prefixed?: boolean; } -export function generateUniqueName(baseName, options?: TestNameOptions): string { +export function generateUniqueName(baseName: string, options?: TestNameOptions): string { const length = options?.length ?? 1; const timestamp = options?.timestampped === undefined ? true : options.timestampped; const prefixed = options?.prefixed === undefined ? true : options.prefixed; @@ -40,6 +40,7 @@ export enum TestAccount { Mongo32 = "Mongo32", SQL = "SQL", SQLReadOnly = "SQLReadOnly", + SQLContainerCopyOnly = "SQLContainerCopyOnly", } export const defaultAccounts: Record = { @@ -51,6 +52,7 @@ export const defaultAccounts: Record = { [TestAccount.Mongo32]: "github-e2etests-mongo32", [TestAccount.SQL]: "github-e2etests-sql", [TestAccount.SQLReadOnly]: "github-e2etests-sql-readonly", + [TestAccount.SQLContainerCopyOnly]: "github-e2etests-sql-containercopyonly", }; export const resourceGroupName = process.env.DE_TEST_RESOURCE_GROUP ?? "de-e2e-tests"; @@ -77,7 +79,14 @@ export function getAccountName(accountType: TestAccount) { ); } -export async function getTestExplorerUrl(accountType: TestAccount, iframeSrc?: string): Promise { +type TestExplorerUrlOptions = { + iframeSrc?: string; + enablecontainercopy?: boolean; +}; + +export async function getTestExplorerUrl(accountType: TestAccount, options?: TestExplorerUrlOptions): Promise { + const { iframeSrc, enablecontainercopy } = options ?? {}; + // We can't retrieve AZ CLI credentials from the browser so we get them here. const token = await getAzureCLICredentialsToken(); const accountName = getAccountName(accountType); @@ -93,6 +102,7 @@ export async function getTestExplorerUrl(accountType: TestAccount, iframeSrc?: s const nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN; const nosqlReadOnlyRbacToken = process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN; + const nosqlContainerCopyRbacToken = process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN; const tableRbacToken = process.env.TABLE_TESTACCOUNT_TOKEN; const gremlinRbacToken = process.env.GREMLIN_TESTACCOUNT_TOKEN; const cassandraRbacToken = process.env.CASSANDRA_TESTACCOUNT_TOKEN; @@ -108,6 +118,16 @@ export async function getTestExplorerUrl(accountType: TestAccount, iframeSrc?: s } break; + case TestAccount.SQLContainerCopyOnly: + if (nosqlContainerCopyRbacToken) { + params.set("nosqlRbacToken", nosqlContainerCopyRbacToken); + params.set("enableaaddataplane", "true"); + } + if (enablecontainercopy) { + params.set("enablecontainercopy", "true"); + } + break; + case TestAccount.SQLReadOnly: if (nosqlReadOnlyRbacToken) { params.set("nosqlReadOnlyRbacToken", nosqlReadOnlyRbacToken); @@ -165,6 +185,39 @@ export async function getTestExplorerUrl(accountType: TestAccount, iframeSrc?: s return `https://localhost:1234/testExplorer.html?${params.toString()}`; } +type DropdownItemExpectations = { + ariaLabel?: string; + itemCount?: number; +}; + +type DropdownItemMatcher = { + name?: string; + position?: number; +}; + +export async function getDropdownItemByNameOrPosition( + frame: Frame, + matcher?: DropdownItemMatcher, + expectedOptions?: DropdownItemExpectations, +): Promise { + const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items"); + if (expectedOptions?.ariaLabel) { + expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(expectedOptions.ariaLabel); + } + if (expectedOptions?.itemCount) { + const items = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']"); + await expect(items).toHaveCount(expectedOptions.itemCount); + } + const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']"); + if (matcher?.name) { + return containerDropdownItems.filter({ hasText: matcher.name }); + } else if (matcher?.position !== undefined) { + return containerDropdownItems.nth(matcher.position); + } + // Return first item if no matcher is provided + return containerDropdownItems.first(); +} + /** Helper class that provides locator methods for TreeNode elements, on top of a Locator */ class TreeNode { constructor( @@ -515,7 +568,7 @@ export class DataExplorer { } /** Waits for the Data Explorer app to load */ - static async waitForExplorer(page: Page) { + static async waitForExplorer(page: Page, options?: TestExplorerUrlOptions): Promise { const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle(); if (iframeElement === null) { throw new Error("Explorer iframe not found"); @@ -527,15 +580,126 @@ export class DataExplorer { throw new Error("Explorer frame not found"); } - await explorerFrame?.getByTestId("DataExplorerRoot").waitFor(); + if (!options?.enablecontainercopy) { + await explorerFrame?.getByTestId("DataExplorerRoot").waitFor(); + } return new DataExplorer(explorerFrame); } /** Opens the Data Explorer app using the specified test account (and optionally, the provided IFRAME src url). */ static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise { - const url = await getTestExplorerUrl(testAccount, iframeSrc); + const url = await getTestExplorerUrl(testAccount, { iframeSrc }); await page.goto(url); return DataExplorer.waitForExplorer(page); } } + +export async function waitForApiResponse( + page: Page, + urlPattern: string, + method?: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payloadValidator?: (payload: any) => boolean, +) { + try { + // Check if page is still valid before waiting + if (page.isClosed()) { + throw new Error(`Page is closed, cannot wait for API response: ${urlPattern}`); + } + + return page.waitForResponse( + async (response) => { + const request = response.request(); + + if (!request.url().includes(urlPattern)) { + return false; + } + + if (method && request.method() !== method) { + return false; + } + + if (payloadValidator && (request.method() === "POST" || request.method() === "PUT")) { + const postData = request.postData(); + if (postData) { + try { + const payload = JSON.parse(postData); + return payloadValidator(payload); + } catch { + return false; + } + } + } + return true; + }, + { timeout: 60 * 1000 }, + ); + } catch (error) { + if (error instanceof Error && error.message.includes("Target page, context or browser has been closed")) { + console.warn("Page was closed while waiting for API response:", urlPattern); + throw new Error(`Page closed while waiting for API response: ${urlPattern}`); + } + throw error; + } +} +export async function interceptAndInspectApiRequest( + page: Page, + urlPattern: string, + method: string = "POST", + error: Error, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + errorValidator: (url?: string, payload?: any) => boolean, +): Promise { + await page.route( + (url) => url.pathname.includes(urlPattern), + async (route, request) => { + if (request.method() !== method) { + await route.continue(); + return; + } + const postData = request.postData(); + if (postData) { + try { + const payload = JSON.parse(postData); + if (errorValidator && errorValidator(request.url(), payload)) { + await route.fulfill({ + status: 409, + contentType: "application/json", + body: JSON.stringify({ + code: "Conflict", + message: error.message, + }), + }); + return; + } + } catch (err) { + if (err instanceof Error && err.message.includes("not allowed")) { + throw err; + } + } + } + + await route.continue(); + }, + ); +} + +export class ContainerCopy { + constructor( + public frame: Frame, + public wrapper: Locator, + ) {} + + static async waitForContainerCopy(page: Page): Promise { + const explorerFrame = await DataExplorer.waitForExplorer(page, { enablecontainercopy: true }); + const containerCopyWrapper = explorerFrame.frame.locator("div#containerCopyWrapper"); + return new ContainerCopy(explorerFrame.frame, containerCopyWrapper); + } + + static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise { + const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true }); + await page.goto(url); + return ContainerCopy.waitForContainerCopy(page); + } +} diff --git a/test/sql/containercopy.spec.ts b/test/sql/containercopy.spec.ts new file mode 100644 index 000000000..c019b99b7 --- /dev/null +++ b/test/sql/containercopy.spec.ts @@ -0,0 +1,493 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect, Frame, Locator, Page, test } from "@playwright/test"; +import { set } from "lodash"; +import { truncateName } from "../../src/Explorer/ContainerCopy/CopyJobUtils"; +import { + ContainerCopy, + getAccountName, + getDropdownItemByNameOrPosition, + interceptAndInspectApiRequest, + TestAccount, + waitForApiResponse, +} from "../fx"; +import { createMultipleTestContainers } from "../testData"; + +let page: Page; +let wrapper: Locator = null!; +let panel: Locator = null!; +let frame: Frame = null!; +let expectedCopyJobNameInitial: string = null!; +let expectedJobName: string = ""; +let targetAccountName: string = ""; +let expectedSourceAccountName: string = ""; +let expectedSubscriptionName: string = ""; +const VISIBLE_TIMEOUT_MS = 30 * 1000; + +test.describe.configure({ mode: "serial" }); + +test.describe("Container Copy", () => { + test.beforeAll("Container Copy - Before All", async ({ browser }) => { + await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 3 }); + + page = await browser.newPage(); + ({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly)); + expectedJobName = `test_job_${Date.now()}`; + targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly); + }); + + test.afterEach("Container Copy - After Each", async () => { + await page.unroute(/.*/, (route) => route.continue()); + }); + + test("Loading and verifying the content of the page", async () => { + expect(wrapper).not.toBeNull(); + await expect(wrapper.getByTestId("CommandBar/Button:Create Copy Job")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS }); + await expect(wrapper.getByTestId("CommandBar/Button:Refresh")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS }); + await expect(wrapper.getByTestId("CommandBar/Button:Feedback")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS }); + }); + + test("Successfully create a copy job for offline migration", async () => { + expect(wrapper).not.toBeNull(); + // Loading and verifying subscription & account dropdown + + const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job"); + await createCopyJobButton.click(); + panel = frame.getByTestId("Panel:Create copy job"); + await expect(panel).toBeVisible(); + + await page.waitForTimeout(10 * 1000); + + const subscriptionDropdown = panel.getByTestId("subscription-dropdown"); + + const expectedAccountName = targetAccountName; + expectedSubscriptionName = await subscriptionDropdown.locator("span.ms-Dropdown-title").innerText(); + + await subscriptionDropdown.click(); + const subscriptionItem = await getDropdownItemByNameOrPosition( + frame, + { name: expectedSubscriptionName }, + { ariaLabel: "Subscription" }, + ); + await subscriptionItem.click(); + + // Load account dropdown based on selected subscription + + const accountDropdown = panel.getByTestId("account-dropdown"); + await expect(accountDropdown).toHaveText(new RegExp(expectedAccountName)); + await accountDropdown.click(); + + const accountItem = await getDropdownItemByNameOrPosition( + frame, + { name: expectedAccountName }, + { ariaLabel: "Account" }, + ); + await accountItem.click(); + + // Verifying online or offline checkbox functionality + /** + * This test verifies the functionality of the migration type checkbox that toggles between + * online and offline container copy modes. It ensures that: + * 1. When online mode is selected, the user is directed to a permissions screen + * 2. When offline mode is selected, the user bypasses the permissions screen + * 3. The UI correctly reflects the selected migration type throughout the workflow + */ + const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox"); + await fluentUiCheckboxContainer.click(); + await panel.getByRole("button", { name: "Next" }).click(); + await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).toBeVisible(); + await expect(panel.getByText("Online container copy", { exact: true })).toBeVisible(); + await panel.getByRole("button", { name: "Previous" }).click(); + await fluentUiCheckboxContainer.click(); + await panel.getByRole("button", { name: "Next" }).click(); + await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible(); + await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible(); + + // Verifying source and target container selection + + const sourceContainerDropdown = panel.getByTestId("source-containerDropdown"); + expect(sourceContainerDropdown).toBeVisible(); + await expect(sourceContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/); + + const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown"); + await sourceDatabaseDropdown.click(); + + const sourceDbDropdownItem = await getDropdownItemByNameOrPosition( + frame, + { position: 0 }, + { ariaLabel: "Database" }, + ); + await sourceDbDropdownItem.click(); + + await expect(sourceContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); + await sourceContainerDropdown.click(); + const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition( + frame, + { position: 0 }, + { ariaLabel: "Container" }, + ); + await sourceContainerDropdownItem.click(); + + const targetContainerDropdown = panel.getByTestId("target-containerDropdown"); + expect(targetContainerDropdown).toBeVisible(); + await expect(targetContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/); + + const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown"); + await targetDatabaseDropdown.click(); + const targetDbDropdownItem = await getDropdownItemByNameOrPosition( + frame, + { position: 0 }, + { ariaLabel: "Database" }, + ); + await targetDbDropdownItem.click(); + + await expect(targetContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); + await targetContainerDropdown.click(); + const targetContainerDropdownItem1 = await getDropdownItemByNameOrPosition( + frame, + { position: 0 }, + { ariaLabel: "Container" }, + ); + await targetContainerDropdownItem1.click(); + + await panel.getByRole("button", { name: "Next" }).click(); + + const errorContainer = panel.getByTestId("Panel:ErrorContainer"); + await expect(errorContainer).toBeVisible(); + await expect(errorContainer).toHaveText(/Source and destination containers cannot be the same/i); + + // Reselect target container to be different from source container + await targetContainerDropdown.click(); + const targetContainerDropdownItem2 = await getDropdownItemByNameOrPosition( + frame, + { position: 1 }, + { ariaLabel: "Container" }, + ); + await targetContainerDropdownItem2.click(); + + const selectedSourceDatabase = await sourceDatabaseDropdown.innerText(); + const selectedSourceContainer = await sourceContainerDropdown.innerText(); + const selectedTargetDatabase = await targetDatabaseDropdown.innerText(); + const selectedTargetContainer = await targetContainerDropdown.innerText(); + expectedCopyJobNameInitial = `${truncateName(selectedSourceDatabase)}.${truncateName( + selectedSourceContainer, + )}_${truncateName(selectedTargetDatabase)}.${truncateName(selectedTargetContainer)}`; + + await panel.getByRole("button", { name: "Next" }).click(); + + await expect(errorContainer).not.toBeVisible(); + await expect(panel.getByTestId("Panel:PreviewCopyJob")).toBeVisible(); + + // Verifying the preview of the copy job + const previewContainer = panel.getByTestId("Panel:PreviewCopyJob"); + await expect(previewContainer).toBeVisible(); + await expect(previewContainer.getByTestId("source-subscription-name")).toHaveText(expectedSubscriptionName); + await expect(previewContainer.getByTestId("source-account-name")).toHaveText(expectedAccountName); + const jobNameInput = previewContainer.getByTestId("job-name-textfield"); + await expect(jobNameInput).toHaveValue(new RegExp(expectedCopyJobNameInitial)); + const primaryBtn = panel.getByRole("button", { name: "Copy", exact: true }); + await expect(primaryBtn).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); + + await jobNameInput.fill("test job name"); + await expect(primaryBtn).toHaveClass(/(^|\s)is-disabled(\s|$)/); + + // Testing API request interception with duplicate job name + const duplicateJobName = "test-job-name-1"; + await jobNameInput.fill(duplicateJobName); + + const copyButton = panel.getByRole("button", { name: "Copy", exact: true }); + const expectedErrorMessage = `Duplicate job name '${duplicateJobName}'`; + await interceptAndInspectApiRequest( + page, + `${expectedAccountName}/dataTransferJobs/${duplicateJobName}`, + "PUT", + new Error(expectedErrorMessage), + (url?: string) => url?.includes(duplicateJobName) ?? false, + ); + + let errorThrown = false; + try { + await copyButton.click(); + await page.waitForTimeout(2000); + } catch (error: any) { + errorThrown = true; + expect(error.message).toContain("not allowed"); + } + if (!errorThrown) { + const errorContainer = panel.getByTestId("Panel:ErrorContainer"); + await expect(errorContainer).toBeVisible(); + await expect(errorContainer).toHaveText(new RegExp(expectedErrorMessage, "i")); + } + + await expect(panel).toBeVisible(); + + // Testing API request success with valid job name and verifying copy job creation + + const validJobName = expectedJobName; + + const copyJobCreationPromise = waitForApiResponse( + page, + `${expectedAccountName}/dataTransferJobs/${validJobName}`, + "PUT", + ); + + await jobNameInput.fill(validJobName); + await expect(copyButton).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); + + await copyButton.click(); + + const response = await copyJobCreationPromise; + expect(response.ok()).toBe(true); + + await expect(panel).not.toBeVisible({ timeout: 10000 }); + + const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page"); + await jobsListContainer.waitFor({ state: "visible" }); + + const jobItem = jobsListContainer.getByText(validJobName); + await jobItem.waitFor({ state: "visible" }); + await expect(jobItem).toBeVisible(); + }); + + test("Verify Online or Offline Container Copy Permissions Panel", async () => { + expect(wrapper).not.toBeNull(); + + // Opening the Create Copy Job panel again to verify initial state + const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job"); + await createCopyJobButton.click(); + panel = frame.getByTestId("Panel:Create copy job"); + await expect(panel).toBeVisible(); + await expect(panel.getByRole("heading", { name: "Create copy job" })).toBeVisible(); + + // select different account dropdown + + const accountDropdown = panel.getByTestId("account-dropdown"); + await accountDropdown.click(); + + const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items"); + expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Account"); + + const allDropdownItems = await dropdownItemsWrapper.locator(`button.ms-Dropdown-item[role='option']`).all(); + + const filteredItems = []; + for (const item of allDropdownItems) { + const testContent = (await item.textContent()) ?? ""; + if (testContent.trim() !== targetAccountName.trim()) { + filteredItems.push(item); + } + } + + if (filteredItems.length > 0) { + const firstDropdownItem = filteredItems[0]; + expectedSourceAccountName = (await firstDropdownItem.textContent()) ?? ""; + await firstDropdownItem.click(); + } else { + throw new Error("No dropdown items available after filtering"); + } + + const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox"); + await fluentUiCheckboxContainer.click(); + + await panel.getByRole("button", { name: "Next" }).click(); + + // Verifying Assign Permissions panel for online copy + + const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer"); + await expect(permissionScreen).toBeVisible(); + + await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible(); + await expect(permissionScreen.getByText("Cross-account container copy", { exact: true })).toBeVisible(); + + // Verify Point-in-Time Restore timer and refresh button workflow + + await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}**`, async (route) => { + const mockData = { + identity: { + type: "SystemAssigned", + principalId: "00-11-22-33", + }, + properties: { + defaultIdentity: "SystemAssignedIdentity", + backupPolicy: { + type: "Continuous", + }, + capabilities: [{ name: "EnableOnlineContainerCopy" }], + }, + }; + if (route.request().method() === "GET") { + const response = await route.fetch(); + const actualData = await response.json(); + const mergedData = { ...actualData }; + + set(mergedData, "identity", mockData.identity); + set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity); + set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy); + set(mergedData, "properties.capabilities", mockData.properties.capabilities); + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(mergedData), + }); + } else { + await route.continue(); + } + }); + + await expect(permissionScreen).toBeVisible(); + + const expandedOnlineAccordionHeader = permissionScreen + .getByTestId("permission-group-container-onlineConfigs") + .locator("button[aria-expanded='true']"); + await expect(expandedOnlineAccordionHeader).toBeVisible(); + + const accordionItem = expandedOnlineAccordionHeader + .locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]") + .first(); + + const accordionPanel = accordionItem + .locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']") + .first(); + + await page.clock.install({ time: new Date("2024-01-01T10:00:00Z") }); + + const pitrBtn = accordionPanel.getByTestId("pointInTimeRestore:PrimaryBtn"); + await expect(pitrBtn).toBeVisible(); + await pitrBtn.click(); + + page.context().on("page", async (newPage) => { + const expectedUrlEndPattern = new RegExp( + `/providers/Microsoft.(DocumentDB|DocumentDb)/databaseAccounts/${expectedSourceAccountName}/backupRestore`, + ); + expect(newPage.url()).toMatch(expectedUrlEndPattern); + await newPage.close(); + }); + + const loadingOverlay = frame.locator("[data-test='loading-overlay']"); + await expect(loadingOverlay).toBeVisible(); + + const refreshBtn = accordionPanel.getByTestId("pointInTimeRestore:RefreshBtn"); + await expect(refreshBtn).not.toBeVisible(); + + // Fast forward time by 11 minutes (11 * 60 * 1000ms = 660000ms) + await page.clock.fastForward(11 * 60 * 1000); + + await expect(refreshBtn).toBeVisible(); + await expect(pitrBtn).not.toBeVisible(); + + // Veify Popover & Loading Overlay on permission screen with API mocks and accordion interactions + + await page.route( + `**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/sqlRoleAssignments*`, + async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + value: [ + { + principalId: "00-11-22-33", + roleDefinitionId: `Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/77-88-99`, + }, + ], + }), + }); + }, + ); + + await page.route("**/Microsoft.DocumentDB/databaseAccounts/*/77-88-99**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + value: [ + { + name: "00000000-0000-0000-0000-000000000001", + }, + ], + }), + }); + }); + + await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${targetAccountName}**`, async (route) => { + const mockData = { + identity: { + type: "SystemAssigned", + principalId: "00-11-22-33", + }, + properties: { + defaultIdentity: "SystemAssignedIdentity", + backupPolicy: { + type: "Continuous", + }, + capabilities: [{ name: "EnableOnlineContainerCopy" }], + }, + }; + + if (route.request().method() === "PATCH") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ status: "Succeeded" }), + }); + } else if (route.request().method() === "GET") { + // Get the actual response and merge with mock data + const response = await route.fetch(); + const actualData = await response.json(); + const mergedData = { ...actualData }; + set(mergedData, "identity", mockData.identity); + set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity); + set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy); + set(mergedData, "properties.capabilities", mockData.properties.capabilities); + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(mergedData), + }); + } else { + await route.continue(); + } + }); + + await expect(permissionScreen).toBeVisible(); + + const expandedCrossAccordionHeader = permissionScreen + .getByTestId("permission-group-container-crossAccountConfigs") + .locator("button[aria-expanded='true']"); + await expect(expandedCrossAccordionHeader).toBeVisible(); + + const crossAccordionItem = expandedCrossAccordionHeader + .locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]") + .first(); + + const crossAccordionPanel = crossAccordionItem + .locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']") + .first(); + + const toggleButton = crossAccordionPanel.getByTestId("btn-toggle"); + await expect(toggleButton).toBeVisible(); + await toggleButton.click(); + + const popover = frame.locator("[data-test='popover-container']"); + await expect(popover).toBeVisible(); + + const yesButton = popover.getByRole("button", { name: /Yes/i }); + const noButton = popover.getByRole("button", { name: /No/i }); + await expect(yesButton).toBeVisible(); + await expect(noButton).toBeVisible(); + + await yesButton.click(); + + await expect(loadingOverlay).toBeVisible(); + + await expect(loadingOverlay).toBeHidden({ timeout: 10 * 1000 }); + await expect(popover).toBeHidden({ timeout: 10 * 1000 }); + + await panel.getByRole("button", { name: "Cancel" }).click(); + }); + + test.afterAll("Container Copy - After All", async () => { + await page.unroute(/.*/, (route) => route.continue()); + await page.close(); + }); +}); diff --git a/test/sql/scaleAndSettings/changePartitionKey.spec.ts b/test/sql/scaleAndSettings/changePartitionKey.spec.ts index 82341bbdc..95f5a957a 100644 --- a/test/sql/scaleAndSettings/changePartitionKey.spec.ts +++ b/test/sql/scaleAndSettings/changePartitionKey.spec.ts @@ -1,5 +1,5 @@ // import { expect, test } from "@playwright/test"; -// import { DataExplorer, TestAccount } from "../../fx"; +// import { DataExplorer, getDropdownItemByNameOrPosition, TestAccount } from "../../fx"; // import { createTestSQLContainer, TestContainerContext } from "../../testData"; // test.describe("Change Partition Key", () => { @@ -83,8 +83,12 @@ // await changePkPanel.getByLabel("Use existing container").check(); // await changePkPanel.getByText("Choose an existing container").click(); -// const containerDropdownItem = await explorer.getDropdownItemByName(newContainerId, "Existing Containers"); -// await containerDropdownItem.click(); +// const containerDropdownItem = await getDropdownItemByNameOrPosition( +// explorer.frame, +// { name: newContainerId }, +// { ariaLabel: "Existing Containers" }, +// ); +// await containerDropdownItem.click(); // await changePkPanel.getByTestId("Panel/OkButton").click(); // await explorer.frame.getByRole("button", { name: "Cancel" }).click(); diff --git a/test/testData.ts b/test/testData.ts index 9729a90b4..b440f565c 100644 --- a/test/testData.ts +++ b/test/testData.ts @@ -80,6 +80,69 @@ type createTestSqlContainerConfig = { databaseName?: string; }; +type createMultipleTestSqlContainerConfig = { + containerCount?: number; + partitionKey?: string; + databaseName?: string; + accountType: TestAccount.SQLContainerCopyOnly | TestAccount.SQL; +}; + +export async function createMultipleTestContainers({ + partitionKey = "/partitionKey", + databaseName = "", + containerCount = 1, + accountType = TestAccount.SQL, +}: createMultipleTestSqlContainerConfig): Promise { + const creationPromises: Promise[] = []; + + const databaseId = databaseName ? databaseName : generateUniqueName("db"); + const credentials = getAzureCLICredentials(); + const adaptedCredentials = new AzureIdentityCredentialAdapter(credentials); + const armClient = new CosmosDBManagementClient(adaptedCredentials, subscriptionId); + const accountName = getAccountName(accountType); + const account = await armClient.databaseAccounts.get(resourceGroupName, accountName); + + const clientOptions: CosmosClientOptions = { + endpoint: account.documentEndpoint!, + }; + + const rbacToken = + accountType === TestAccount.SQL + ? process.env.NOSQL_TESTACCOUNT_TOKEN + : accountType === TestAccount.SQLContainerCopyOnly + ? process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN + : ""; + if (rbacToken) { + clientOptions.tokenProvider = async (): Promise => { + const AUTH_PREFIX = `type=aad&ver=1.0&sig=`; + const authorizationToken = `${AUTH_PREFIX}${rbacToken}`; + return authorizationToken; + }; + } else { + const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName); + clientOptions.key = keys.primaryMasterKey; + } + + const client = new CosmosClient(clientOptions); + const { database } = await client.databases.createIfNotExists({ id: databaseId }); + + try { + for (let i = 0; i < containerCount; i++) { + const containerId = `testcontainer_${Date.now()}_${Math.random().toString(36).substring(6)}_${i}`; + creationPromises.push( + database.containers.createIfNotExists({ id: containerId, partitionKey }).then(({ container }) => { + return new TestContainerContext(armClient, client, database, container, new Map()); + }), + ); + } + const contexts = await Promise.all(creationPromises); + return contexts; + } catch (e) { + await database.delete(); + throw e; + } +} + export async function createTestSQLContainer({ includeTestData = false, partitionKey = "/partitionKey", diff --git a/test/testExplorer/TestExplorer.ts b/test/testExplorer/TestExplorer.ts index 7e6dc9c24..2a8d5b115 100644 --- a/test/testExplorer/TestExplorer.ts +++ b/test/testExplorer/TestExplorer.ts @@ -11,8 +11,12 @@ const accountName = urlSearchParams.get("accountName") || "portal-sql-runner-wes const selfServeType = urlSearchParams.get("selfServeType") || "example"; const iframeSrc = urlSearchParams.get("iframeSrc") || "explorer.html?platform=Portal&disablePortalInitCache"; const authToken = urlSearchParams.get("token"); +const enablecontainercopy = urlSearchParams.get("enablecontainercopy"); -const nosqlRbacToken = urlSearchParams.get("nosqlRbacToken") || process.env.NOSQL_TESTACCOUNT_TOKEN || ""; +const nosqlRbacToken = + urlSearchParams.get("nosqlRbacToken") || + (enablecontainercopy ? process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN : process.env.NOSQL_TESTACCOUNT_TOKEN) || + ""; const nosqlReadOnlyRbacToken = urlSearchParams.get("nosqlReadOnlyRbacToken") || process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN || ""; const tableRbacToken = urlSearchParams.get("tableRbacToken") || process.env.TABLE_TESTACCOUNT_TOKEN || ""; @@ -83,6 +87,7 @@ const initTestExplorer = async (): Promise => { authorizationToken: `Bearer ${authToken}`, aadToken: rbacToken, features: {}, + containerCopyEnabled: enablecontainercopy === "true", hasWriteAccess: true, csmEndpoint: "https://management.azure.com", dnsSuffix: "documents.azure.com",