Compare commits

...

9 Commits

Author SHA1 Message Date
Bikram Choudhury
57c51f462a update account only if allVersion feature is not enabled 2025-11-25 17:12:52 +05:30
Bikram Choudhury
337379b180 lint fix 2025-11-25 17:12:52 +05:30
Bikram Choudhury
eabc0e85c5 set intra-account copy as the default one 2025-11-25 17:12:52 +05:30
Bikram Choudhury
5df45c0861 show default copy job name 2025-11-25 17:12:52 +05:30
Bikram Choudhury
a9cc819534 Adding loading overlay for the permission sections 2025-11-25 17:12:52 +05:30
Bikram Choudhury
b1b72cd293 grouped permissions and added styles 2025-11-25 17:12:52 +05:30
Bikram Choudhury
5a0f016cdf Integrate container creation screen to copy job flow 2025-11-25 17:12:51 +05:30
Bikram Choudhury
fb4b2e3fe7 ts & eslint fix 2025-11-25 17:12:51 +05:30
Bikram Choudhury
a537d958ca removal of multiple useMemo & useCallbacks to reduce the over optimization 2025-11-25 17:12:51 +05:30
40 changed files with 628 additions and 261 deletions

View File

@@ -44,8 +44,8 @@ export const getDatabaseEndpoint = (apiType: ApiType): string => {
return "gremlinDatabases";
case "Tables":
return "tables";
default:
case "SQL":
default:
return "sqlDatabases";
}
};
@@ -58,8 +58,8 @@ export const getCollectionEndpoint = (apiType: ApiType): string => {
return "tables";
case "Gremlin":
return "graphs";
default:
case "SQL":
default:
return "containers";
}
};

View File

@@ -0,0 +1,31 @@
import { Overlay, Spinner, SpinnerSize } from "@fluentui/react";
import React from "react";
interface LoadingOverlayProps {
isLoading: boolean;
label: string;
}
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading, label }) => {
if (!isLoading) {
return null;
}
return (
<Overlay
styles={{
root: {
backgroundColor: "rgba(255,255,255,0.9)",
zIndex: 9999,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
}}
>
<Spinner size={SpinnerSize.large} label={label} styles={{ label: { fontWeight: 600 } }} />
</Overlay>
);
};
export default LoadingOverlay;

View File

@@ -73,6 +73,7 @@ export interface DatabaseAccountExtendedProperties {
publicNetworkAccess?: string;
enablePriorityBasedExecution?: boolean;
vcoreMongoEndpoint?: string;
enableAllVersionsAndDeletesChangeFeed?: boolean;
}
export interface DatabaseAccountResponseLocation {

View File

@@ -1,3 +1,4 @@
import Explorer from "Explorer/Explorer";
import React from "react";
import { userContext } from "UserContext";
import { logError } from "../../../Common/Logger";
@@ -22,6 +23,7 @@ import {
extractErrorMessage,
formatUTCDateTime,
getAccountDetailsFromResourceId,
isIntraAccountCopy,
} from "../CopyJobUtils";
import CreateCopyJobScreensProvider from "../CreateCopyJob/Screens/CreateCopyJobScreensProvider";
import { CopyJobActions, CopyJobStatusType } from "../Enums/CopyJobEnums";
@@ -29,12 +31,12 @@ import CopyJobDetails from "../MonitorCopyJobs/Components/CopyJobDetails";
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
import { CopyJobContextState, CopyJobError, CopyJobErrorType, CopyJobType } from "../Types/CopyJobTypes";
export const openCreateCopyJobPanel = () => {
export const openCreateCopyJobPanel = (explorer: Explorer) => {
const sidePanelState = useSidePanel.getState();
sidePanelState.setPanelHasConsole(false);
sidePanelState.openSidePanel(
ContainerCopyMessages.createCopyJobPanelTitle,
<CreateCopyJobScreensProvider />,
<CreateCopyJobScreensProvider explorer={explorer} />,
"650px",
);
};
@@ -74,7 +76,6 @@ export const getCopyJobs = async (): Promise<CopyJobType[]> => {
}
copyJobsAbortController = null;
/* added a lower bound to "0" and upper bound to "100" */
const calculateCompletionPercentage = (processed: number, total: number): number => {
if (
typeof processed !== "number" ||
@@ -138,11 +139,12 @@ export const submitCreateCopyJob = async (state: CopyJobContextState, onSuccess:
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
userContext.databaseAccount?.id || "",
);
const isSameAccount = isIntraAccountCopy(source?.account?.id, target?.account?.id);
const body = {
properties: {
source: {
component: "CosmosDBSql",
remoteAccountName: source?.account?.name,
...(isSameAccount ? {} : { accountName: source?.account?.name }),
databaseName: source?.databaseId,
containerName: source?.containerId,
},

View File

@@ -13,8 +13,8 @@ const rootStyle = {
},
};
const CopyJobCommandBar: React.FC<ContainerCopyProps> = ({ container }) => {
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(container);
const CopyJobCommandBar: React.FC<ContainerCopyProps> = ({ explorer }) => {
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer);
const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor);
return (

View File

@@ -9,7 +9,7 @@ import ContainerCopyMessages from "../ContainerCopyMessages";
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
import { CopyJobCommandBarBtnType } from "../Types/CopyJobTypes";
function getCopyJobBtns(container: Explorer): CopyJobCommandBarBtnType[] {
function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] {
const monitorCopyJobsRef = MonitorCopyJobsRefState((state) => state.ref);
const buttons: CopyJobCommandBarBtnType[] = [
{
@@ -17,7 +17,7 @@ function getCopyJobBtns(container: Explorer): CopyJobCommandBarBtnType[] {
iconSrc: AddIcon,
label: ContainerCopyMessages.createCopyJobButtonLabel,
ariaLabel: ContainerCopyMessages.createCopyJobButtonAriaLabel,
onClick: Actions.openCreateCopyJobPanel,
onClick: Actions.openCreateCopyJobPanel.bind(null, explorer),
},
{
key: "refresh",
@@ -34,7 +34,7 @@ function getCopyJobBtns(container: Explorer): CopyJobCommandBarBtnType[] {
label: ContainerCopyMessages.feedbackButtonLabel,
ariaLabel: ContainerCopyMessages.feedbackButtonAriaLabel,
onClick: () => {
container.openContainerCopyFeedbackBlade();
explorer.openContainerCopyFeedbackBlade();
},
});
}
@@ -54,6 +54,6 @@ function btnMapper(config: CopyJobCommandBarBtnType): CommandButtonComponentProp
};
}
export function getCommandBarButtons(container: Explorer): CommandButtonComponentProps[] {
return getCopyJobBtns(container).map(btnMapper);
export function getCommandBarButtons(explorer: Explorer): CommandButtonComponentProps[] {
return getCopyJobBtns(explorer).map(btnMapper);
}

View File

@@ -36,6 +36,9 @@ export default {
databaseDropdownPlaceholder: "Select a database",
containerDropdownLabel: "Container",
containerDropdownPlaceholder: "Select a container",
createNewContainerSubHeading: "Select the properties for your container.",
createContainerButtonLabel: "Create a new container",
createContainerHeading: "Create new container",
// Preview and Create Screen
jobNameLabel: "Job name",
@@ -52,11 +55,22 @@ export default {
"To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.",
intraAccountOnlineDescription: (accountName: string) =>
`Follow the steps below to enable online copy on your "${accountName}" account.`,
crossAccountConfiguration: {
title: "Cross-account container copy",
description: (sourceAccount: string, destinationAccount: string) =>
`Please follow the instruction below to grant requisite permissions to copy data from "${sourceAccount}" to "${destinationAccount}".`,
},
onlineConfiguration: {
title: "Online container copy",
description: (accountName: string) =>
`Please follow the instructions below to enable online copy on your "${accountName}" account.`,
},
},
toggleBtn: {
onText: "On",
offText: "Off",
},
popoverOverlaySpinnerLabel: "Please wait while we process your request...",
addManagedIdentity: {
title: "System-assigned managed identity enabled.",
description:
@@ -117,10 +131,17 @@ export default {
},
onlineCopyEnabled: {
title: "Online copy enabled",
description: (accountName: string) => `Enable Online copy on "${accountName}".`,
description: (accountName: string) =>
`Enable online container copy by clicking the button below on your "${accountName}" account.`,
hrefText: "Learn more about online copy jobs",
href: "https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy",
buttonText: "Enable Online Copy",
validateAllVersionsAndDeletesChangeFeedSpinnerLabel:
"Validating All versions and deletes change feed mode (preview)...",
enablingAllVersionsAndDeletesChangeFeedSpinnerLabel:
"Enabling All versions and deletes change feed mode (preview)...",
enablingOnlineCopySpinnerLabel: (accountName: string) =>
`Enabling online copy on your "${accountName}" account ...`,
},
MonitorJobs: {
Columns: {

View File

@@ -5,7 +5,7 @@ import { MonitorCopyJobsRefState } from "./MonitorCopyJobs/MonitorCopyJobRefStat
import MonitorCopyJobs, { MonitorCopyJobsRef } from "./MonitorCopyJobs/MonitorCopyJobs";
import { ContainerCopyProps } from "./Types/CopyJobTypes";
const ContainerCopyPanel: React.FC<ContainerCopyProps> = ({ container }) => {
const ContainerCopyPanel: React.FC<ContainerCopyProps> = ({ explorer }) => {
const monitorCopyJobsRef = React.useRef<MonitorCopyJobsRef>();
useEffect(() => {
if (monitorCopyJobsRef.current) {
@@ -14,8 +14,8 @@ const ContainerCopyPanel: React.FC<ContainerCopyProps> = ({ container }) => {
}, [monitorCopyJobsRef.current]);
return (
<div id="containerCopyWrapper" className="flexContainer hideOverflows">
<CopyJobCommandBar container={container} />
<MonitorCopyJobs ref={monitorCopyJobsRef} />
<CopyJobCommandBar explorer={explorer} />
<MonitorCopyJobs ref={monitorCopyJobsRef} explorer={explorer} />
</div>
);
};

View File

@@ -1,3 +1,5 @@
import { Subscription } from "Contracts/DataModels";
import Explorer from "Explorer/Explorer";
import React from "react";
import { userContext } from "UserContext";
import { CopyJobMigrationType } from "../Enums/CopyJobEnums";
@@ -14,6 +16,7 @@ export const useCopyJobContext = (): CopyJobContextProviderType => {
interface CopyJobContextProviderProps {
children: React.ReactNode;
explorer: Explorer;
}
const getInitialCopyJobState = (): CopyJobContextState => {
@@ -21,8 +24,10 @@ const getInitialCopyJobState = (): CopyJobContextState => {
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: null,
account: null,
subscription: {
subscriptionId: userContext.subscriptionId || "",
} as Subscription,
account: userContext.databaseAccount || null,
databaseId: "",
containerId: "",
},
@@ -53,6 +58,7 @@ const CopyJobContextProvider: React.FC<CopyJobContextProviderProps> = (props) =>
flow,
setFlow,
resetCopyJobState,
explorer: props.explorer,
};
return <CopyJobContext.Provider value={contextValue}>{props.children}</CopyJobContext.Provider>;

View File

@@ -1,5 +1,5 @@
import { DatabaseAccount } from "Contracts/DataModels";
import { CopyJobErrorType } from "./Types/CopyJobTypes";
import { CopyJobContextState, CopyJobErrorType, CopyJobType } from "./Types/CopyJobTypes";
const azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
@@ -115,6 +115,14 @@ export function getAccountDetailsFromResourceId(accountId: string | undefined) {
return { subscriptionId, resourceGroup, accountName };
}
export function getContainerIdentifiers(container: CopyJobContextState["source"] | CopyJobContextState["target"]) {
return {
accountId: container?.account?.id || "",
databaseId: container?.databaseId || "",
containerId: container?.containerId || "",
};
}
export function isIntraAccountCopy(sourceAccountId: string | undefined, targetAccountId: string | undefined): boolean {
const sourceAccountDetails = getAccountDetailsFromResourceId(sourceAccountId);
const targetAccountDetails = getAccountDetailsFromResourceId(targetAccountId);
@@ -124,3 +132,39 @@ export function isIntraAccountCopy(sourceAccountId: string | undefined, targetAc
sourceAccountDetails?.accountName === targetAccountDetails?.accountName
);
}
export function isEqual(prevJobs: CopyJobType[], newJobs: CopyJobType[]): boolean {
if (prevJobs.length !== newJobs.length) {
return false;
}
return prevJobs.every((prevJob: CopyJobType) => {
const newJob = newJobs.find((job) => job.Name === prevJob.Name);
if (!newJob) {
return false;
}
return prevJob.Status === newJob.Status;
});
}
const truncateLength = 5;
const truncateName = (name: string, length: number = truncateLength): string => {
return name.length <= length ? name : name.slice(0, length);
};
export function getDefaultJobName(
selectedDatabaseAndContainers: {
sourceDatabaseName?: string;
sourceContainerName?: string;
targetDatabaseName?: string;
targetContainerName?: string;
}[],
): string {
if (selectedDatabaseAndContainers.length === 1) {
const { sourceDatabaseName, sourceContainerName, targetDatabaseName, targetContainerName } =
selectedDatabaseAndContainers[0];
const timestamp = new Date().getTime().toString();
const sourcePart = `${truncateName(sourceDatabaseName)}.${truncateName(sourceContainerName)}`;
const targetPart = `${truncateName(targetDatabaseName)}.${truncateName(targetContainerName)}`;
return `${sourcePart}_${targetPart}_${timestamp}`;
}
return "";
}

View File

@@ -1,5 +1,5 @@
import { Link, Stack, Text, Toggle } from "@fluentui/react";
import React, { useCallback } from "react";
import React from "react";
import { logError } from "../../../../../Common/Logger";
import { assignRole } from "../../../../../Utils/arm/RbacUtils";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
@@ -25,7 +25,7 @@ const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIde
const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext();
const [readPermissionAssigned, onToggle] = useToggle(false);
const handleAddReadPermission = useCallback(async () => {
const handleAddReadPermission = async () => {
const { source, target } = copyJobState;
const selectedSourceAccount = source?.account;
try {
@@ -53,10 +53,9 @@ const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIde
error.message || "Error assigning read permission to default identity. Please try again later.";
logError(errorMessage, "CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission");
setContextError(errorMessage);
} finally {
setLoading(false);
}
}, [copyJobState, setCopyJobState, setContextError]);
};
return (
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>

View File

@@ -8,7 +8,8 @@ import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { isIntraAccountCopy } from "../../../CopyJobUtils";
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
import usePermissionSections, { PermissionSectionConfig } from "./hooks/usePermissionsSection";
import { useCopyJobPrerequisitesCache } from "../../Utils/useCopyJobPrerequisitesCache";
import usePermissionSections, { PermissionGroupConfig, PermissionSectionConfig } from "./hooks/usePermissionsSection";
const PermissionSection: React.FC<PermissionSectionConfig> = ({ id, title, Component, completed, disabled }) => (
<AccordionItem key={id} value={id} disabled={disabled}>
@@ -30,43 +31,92 @@ const PermissionSection: React.FC<PermissionSectionConfig> = ({ id, title, Compo
</AccordionItem>
);
const AssignPermissions = () => {
const { copyJobState } = useCopyJobContext();
const permissionSections = usePermissionSections(copyJobState);
const PermissionGroup: React.FC<PermissionGroupConfig> = ({ id, title, description, sections }) => {
const [openItems, setOpenItems] = React.useState<string[]>([]);
useEffect(() => {
const firstIncompleteSection = sections.find((section) => !section.completed);
const nextOpenItems = firstIncompleteSection ? [firstIncompleteSection.id] : [];
if (JSON.stringify(openItems) !== JSON.stringify(nextOpenItems)) {
setOpenItems(nextOpenItems);
}
}, [sections]);
return (
<Stack
tokens={{ childrenGap: 15 }}
styles={{
root: {
background: "#fafafa",
border: "1px solid #e1e1e1",
borderRadius: 8,
padding: 16,
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
},
}}
>
<Stack tokens={{ childrenGap: 5 }}>
<Text variant="medium" style={{ fontWeight: 600 }}>
{title}
</Text>
{description && (
<Text variant="small" styles={{ root: { color: "#605E5C" } }}>
{description}
</Text>
)}
</Stack>
<Accordion className="permissionsAccordion" collapsible openItems={openItems}>
{sections.map((section) => (
<PermissionSection key={section.id} {...section} />
))}
</Accordion>
</Stack>
);
};
const AssignPermissions = () => {
const { setValidationCache } = useCopyJobPrerequisitesCache();
const { copyJobState } = useCopyJobContext();
const permissionGroups = usePermissionSections(copyJobState);
const totalSectionsCount = React.useMemo(
() => permissionGroups.reduce((total, group) => total + group.sections.length, 0),
[permissionGroups],
);
const indentLevels = React.useMemo<IndentLevel[]>(
() => Array(copyJobState.migrationType === CopyJobMigrationType.Online ? 5 : 3).fill({ level: 0, width: "100%" }),
[],
[copyJobState.migrationType],
);
const isSameAccount = isIntraAccountCopy(copyJobState?.source?.account?.id, copyJobState?.target?.account?.id);
useEffect(() => {
const firstIncompleteSection = permissionSections.find((section) => !section.completed);
const nextOpenItems = firstIncompleteSection ? [firstIncompleteSection.id] : [];
if (JSON.stringify(openItems) !== JSON.stringify(nextOpenItems)) {
setOpenItems(nextOpenItems);
}
}, [permissionSections]);
return () => {
setValidationCache(new Map<string, boolean>());
};
}, []);
return (
<Stack className="assignPermissionsContainer" tokens={{ childrenGap: 15 }}>
<span>
<Stack className="assignPermissionsContainer" tokens={{ childrenGap: 20 }}>
{/* <Text variant="medium">{ContainerCopyMessages.assignPermissions.crossAccountDescription}</Text> */}
<Text variant="medium">
{isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online
? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription(
copyJobState?.source?.account?.name || "",
)
: ContainerCopyMessages.assignPermissions.crossAccountDescription}
</span>
{permissionSections?.length === 0 ? (
</Text>
{totalSectionsCount === 0 ? (
<ShimmerTree indentLevels={indentLevels} style={{ width: "100%" }} />
) : (
<Accordion className="permissionsAccordion" collapsible openItems={openItems}>
{permissionSections.map((section) => (
<PermissionSection key={section.id} {...section} />
<Stack tokens={{ childrenGap: 25 }}>
{permissionGroups.map((group) => (
<PermissionGroup key={group.id} {...group} />
))}
</Accordion>
</Stack>
)}
</Stack>
);

View File

@@ -1,8 +1,9 @@
import { Link, PrimaryButton, Stack } from "@fluentui/react";
import { CapabilityNames } from "Common/Constants";
import { DatabaseAccount } from "Contracts/DataModels";
import React from "react";
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
import { CapabilityNames } from "../../../../../Common/Constants";
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
import { logError } from "../../../../../Common/Logger";
import { update as updateDatabaseAccount } from "../../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
@@ -19,6 +20,7 @@ const validatorFn: AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAc
const OnlineCopyEnabled: React.FC = () => {
const [loading, setLoading] = React.useState(false);
const [loaderMessage, setLoaderMessage] = React.useState("");
const [showRefreshButton, setShowRefreshButton] = React.useState(false);
const intervalRef = React.useRef<NodeJS.Timeout | null>(null);
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
@@ -74,12 +76,21 @@ const OnlineCopyEnabled: React.FC = () => {
setShowRefreshButton(false);
try {
await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
properties: {
enableAllVersionsAndDeletesChangeFeed: true,
},
});
setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.validateAllVersionsAndDeletesChangeFeedSpinnerLabel);
const sourAccountBeforeUpdate = await fetchDatabaseAccount(
sourceSubscriptionId,
sourceResourceGroup,
sourceAccountName,
);
if (!sourAccountBeforeUpdate?.properties.enableAllVersionsAndDeletesChangeFeed) {
setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.enablingAllVersionsAndDeletesChangeFeedSpinnerLabel);
await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
properties: {
enableAllVersionsAndDeletesChangeFeed: true,
},
});
}
setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.enablingOnlineCopySpinnerLabel(sourceAccountName));
await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
properties: {
capabilities: [...sourceAccountCapabilities, { name: CapabilityNames.EnableOnlineCopyFeature }],
@@ -119,6 +130,7 @@ const OnlineCopyEnabled: React.FC = () => {
return (
<Stack className="onlineCopyContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<LoadingOverlay isLoading={loading} label={loaderMessage} />
<Stack.Item className="info-message">
{ContainerCopyMessages.onlineCopyEnabled.description(source?.account?.name || "")}&ensp;
<Link href={ContainerCopyMessages.onlineCopyEnabled.href} target="_blank" rel="noopener noreferrer">

View File

@@ -2,6 +2,7 @@ import { Link, PrimaryButton, Stack, Text } from "@fluentui/react";
import { DatabaseAccount } from "Contracts/DataModels";
import React, { useEffect, useRef, useState } from "react";
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
import { logError } from "../../../../../Common/Logger";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
@@ -84,9 +85,10 @@ const PointInTimeRestore: React.FC = () => {
setShowRefreshButton(true);
};
const handleRefresh = () => {
const handleRefresh = async () => {
setLoading(true);
handleFetchAccount();
await handleFetchAccount();
setLoading(false);
};
const openWindowAndMonitor = () => {
@@ -108,6 +110,7 @@ const PointInTimeRestore: React.FC = () => {
return (
<Stack className="pointInTimeRestoreContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<LoadingOverlay isLoading={loading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
<Stack.Item className="toggle-label">
{ContainerCopyMessages.pointInTimeRestore.description(source.account?.name ?? "")}
{tooltipContent && (

View File

@@ -44,10 +44,9 @@ const useManagedIdentity = (
const errorMessage = error.message || "Error enabling system-assigned managed identity. Please try again later.";
logError(errorMessage, "CopyJob/useManagedIdentity.handleAddSystemIdentity");
setContextError(errorMessage);
} finally {
setLoading(false);
}
}, [copyJobState, updateIdentityFn, setCopyJobState]);
}, [updateIdentityFn]);
return { loading, handleAddSystemIdentity };
};

View File

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { CapabilityNames } from "../../../../../../Common/Constants";
import { fetchRoleAssignments, fetchRoleDefinitions, RoleDefinitionType } from "../../../../../../Utils/arm/RbacUtils";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { getAccountDetailsFromResourceId, isIntraAccountCopy } from "../../../../CopyJobUtils";
import { getAccountDetailsFromResourceId, getContainerIdentifiers, isIntraAccountCopy } from "../../../../CopyJobUtils";
import {
BackupPolicyType,
CopyJobMigrationType,
@@ -26,6 +26,13 @@ export interface PermissionSectionConfig {
validate?: (state: CopyJobContextState) => boolean | Promise<boolean>;
}
export interface PermissionGroupConfig {
id: string;
title: string;
description: string;
sections: PermissionSectionConfig[];
}
export const SECTION_IDS = {
addManagedIdentity: "addManagedIdentity",
defaultManagedIdentity: "defaultManagedIdentity",
@@ -127,26 +134,86 @@ export function checkTargetHasReaderRoleOnSource(roleDefinitions: RoleDefinition
}
/**
* Returns the permission sections configuration for the Assign Permissions screen.
* Memoizes derived values for performance and decouples logic for testability.
* Validates sections within a group sequentially.
*/
const usePermissionSections = (state: CopyJobContextState): PermissionSectionConfig[] => {
const sourceAccountId = state?.source?.account?.id || "";
const targetAccountId = state?.target?.account?.id || "";
const validateSectionsInGroup = async (
sections: PermissionSectionConfig[],
state: CopyJobContextState,
validationCache: Map<string, boolean>,
): Promise<PermissionSectionConfig[]> => {
const result: PermissionSectionConfig[] = [];
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
if (validationCache.has(section.id) && validationCache.get(section.id) === true) {
result.push({ ...section, completed: true });
continue;
}
if (section.validate) {
const isValid = await section.validate(state);
validationCache.set(section.id, isValid);
result.push({ ...section, completed: isValid });
if (!isValid) {
// Mark remaining sections in this group as incomplete
for (let j = i + 1; j < sections.length; j++) {
result.push({ ...sections[j], completed: false });
}
break;
}
} else {
validationCache.set(section.id, false);
result.push({ ...section, completed: false });
}
}
return result;
};
/**
* Returns the permission groups configuration for the Assign Permissions screen.
* Groups validate independently but sections within each group validate sequentially.
*/
const usePermissionSections = (state: CopyJobContextState): PermissionGroupConfig[] => {
const sourceAccount = getContainerIdentifiers(state.source);
const targetAccount = getContainerIdentifiers(state.target);
const { validationCache, setValidationCache } = useCopyJobPrerequisitesCache();
const [permissionSections, setPermissionSections] = useState<PermissionSectionConfig[] | null>(null);
const [permissionGroups, setPermissionGroups] = useState<PermissionGroupConfig[] | null>(null);
const isValidatingRef = useRef(false);
const sectionToValidate = useMemo(() => {
const isSameAccount = isIntraAccountCopy(sourceAccountId, targetAccountId);
const groupsToValidate = useMemo(() => {
const isSameAccount = isIntraAccountCopy(sourceAccount.accountId, targetAccount.accountId);
const crossAccountSections = isSameAccount ? [] : [...PERMISSION_SECTIONS_CONFIG];
const groups: PermissionGroupConfig[] = [];
const sourceAccountName = state.source?.account?.name || "";
const targetAccountName = state.target?.account?.name || "";
const baseSections = isSameAccount ? [] : [...PERMISSION_SECTIONS_CONFIG];
if (state.migrationType === CopyJobMigrationType.Online) {
return [...baseSections, ...PERMISSION_SECTIONS_FOR_ONLINE_JOBS];
if (crossAccountSections.length > 0) {
groups.push({
id: "crossAccountConfigs",
title: ContainerCopyMessages.assignPermissions.crossAccountConfiguration.title,
description: ContainerCopyMessages.assignPermissions.crossAccountConfiguration.description(
sourceAccountName,
targetAccountName,
),
sections: crossAccountSections,
});
}
return baseSections;
}, [sourceAccountId, targetAccountId, state.migrationType]);
if (state.migrationType === CopyJobMigrationType.Online) {
groups.push({
id: "onlineConfigs",
title: ContainerCopyMessages.assignPermissions.onlineConfiguration.title,
description: ContainerCopyMessages.assignPermissions.onlineConfiguration.description(sourceAccountName),
sections: [...PERMISSION_SECTIONS_FOR_ONLINE_JOBS],
});
}
return groups;
}, [sourceAccount.accountId, targetAccount.accountId, state.migrationType]);
const memoizedValidationCache = useMemo(() => {
if (state.migrationType === CopyJobMigrationType.Offline) {
@@ -157,52 +224,39 @@ const usePermissionSections = (state: CopyJobContextState): PermissionSectionCon
}, [state.migrationType]);
useEffect(() => {
const validateSections = async () => {
const validateGroups = async () => {
if (isValidatingRef.current) {
return;
}
isValidatingRef.current = true;
const result: PermissionSectionConfig[] = [];
const newValidationCache = new Map(memoizedValidationCache);
for (let i = 0; i < sectionToValidate.length; i++) {
const section = sectionToValidate[i];
// Validate all groups independently (in parallel)
const validatedGroups = await Promise.all(
groupsToValidate.map(async (group) => {
const validatedSections = await validateSectionsInGroup(group.sections, state, newValidationCache);
if (newValidationCache.has(section.id) && newValidationCache.get(section.id) === true) {
result.push({ ...section, completed: true });
continue;
}
if (section.validate) {
const isValid = await section.validate(state);
newValidationCache.set(section.id, isValid);
result.push({ ...section, completed: isValid });
if (!isValid) {
for (let j = i + 1; j < sectionToValidate.length; j++) {
result.push({ ...sectionToValidate[j], completed: false });
}
break;
}
} else {
newValidationCache.set(section.id, false);
result.push({ ...section, completed: false });
}
}
return {
...group,
sections: validatedSections,
};
}),
);
setValidationCache(newValidationCache);
setPermissionSections(result);
setPermissionGroups(validatedGroups);
isValidatingRef.current = false;
};
validateSections();
validateGroups();
return () => {
isValidatingRef.current = false;
};
}, [state, sectionToValidate]);
}, [state, groupsToValidate]);
return permissionSections ?? [];
return permissionGroups ?? [];
};
export default usePermissionSections;

View File

@@ -2,6 +2,8 @@
/* eslint-disable react/display-name */
import { DefaultButton, PrimaryButton, Stack, Text } from "@fluentui/react";
import React from "react";
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
interface PopoverContainerProps {
isLoading?: boolean;
@@ -19,17 +21,13 @@ const PopoverContainer: React.FC<PopoverContainerProps> = React.memo(
tokens={{ childrenGap: 20 }}
style={{ maxWidth: 450 }}
>
<LoadingOverlay isLoading={isLoading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
<Text variant="mediumPlus" style={{ fontWeight: 600 }}>
{title}
</Text>
<Text>{children}</Text>
<Stack horizontal tokens={{ childrenGap: 20 }}>
<PrimaryButton
text={isLoading ? "" : "Yes"}
{...(isLoading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
onClick={onPrimary}
disabled={isLoading}
/>
<PrimaryButton text={"Yes"} onClick={onPrimary} disabled={isLoading} />
<DefaultButton text="No" onClick={onCancel} disabled={isLoading} />
</Stack>
</Stack>

View File

@@ -0,0 +1,53 @@
import { Stack, Text } from "@fluentui/react";
import Explorer from "Explorer/Explorer";
import { useSidePanel } from "hooks/useSidePanel";
import { produce } from "immer";
import React, { useCallback, useEffect } from "react";
import { AddCollectionPanel } from "../../../../Panes/AddCollectionPanel/AddCollectionPanel";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
type AddCollectionPanelWrapperProps = {
explorer?: Explorer;
goBack?: () => void;
};
const AddCollectionPanelWrapper: React.FunctionComponent<AddCollectionPanelWrapperProps> = ({ explorer, goBack }) => {
const { setCopyJobState } = useCopyJobContext();
useEffect(() => {
const sidePanelStore = useSidePanel.getState();
if (sidePanelStore.headerText !== ContainerCopyMessages.createContainerHeading) {
sidePanelStore.setHeaderText(ContainerCopyMessages.createContainerHeading);
}
return () => {
sidePanelStore.setHeaderText(ContainerCopyMessages.createCopyJobPanelTitle);
};
}, []);
const handleAddCollectionSuccess = useCallback(
(collectionData: { databaseId: string; collectionId: string }) => {
setCopyJobState(
produce((state) => {
state.target.databaseId = collectionData.databaseId;
state.target.containerId = collectionData.collectionId;
}),
);
goBack?.();
},
[goBack],
);
return (
<Stack className="addCollectionPanelWrapper">
<Stack.Item className="addCollectionPanelHeader">
<Text>{ContainerCopyMessages.createNewContainerSubHeading}</Text>
</Stack.Item>
<Stack.Item className="addCollectionPanelBody">
<AddCollectionPanel explorer={explorer} isCopyJobFlow={true} onSubmitSuccess={handleAddCollectionSuccess} />
</Stack.Item>
</Stack>
);
};
export default AddCollectionPanelWrapper;

View File

@@ -13,6 +13,7 @@ const CreateCopyJobScreens: React.FC = () => {
handlePrevious,
handleCancel,
primaryBtnText,
showAddCollectionPanel,
} = useCopyJobNavigation();
const { contextError, setContextError } = useCopyJobContext();
@@ -32,7 +33,7 @@ const CreateCopyJobScreens: React.FC = () => {
{contextError}
</MessageBar>
)}
{currentScreen?.component}
{React.cloneElement(currentScreen?.component as React.ReactElement, { showAddCollectionPanel })}
</Stack.Item>
<Stack.Item className="createCopyJobScreensFooter">
<NavigationControls

View File

@@ -1,10 +1,11 @@
import Explorer from "Explorer/Explorer";
import React from "react";
import CopyJobContextProvider from "../../Context/CopyJobContext";
import CreateCopyJobScreens from "./CreateCopyJobScreens";
const CreateCopyJobScreensProvider = () => {
const CreateCopyJobScreensProvider = ({ explorer }: { explorer: Explorer }) => {
return (
<CopyJobContextProvider>
<CopyJobContextProvider explorer={explorer}>
<CreateCopyJobScreens />
</CopyJobContextProvider>
);

View File

@@ -1,8 +1,9 @@
import { DetailsList, DetailsListLayoutMode, Stack, Text, TextField } from "@fluentui/react";
import FieldRow from "Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow";
import React from "react";
import React, { useEffect } from "react";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { getDefaultJobName } from "../../../CopyJobUtils";
import FieldRow from "../Components/FieldRow";
import { getPreviewCopyJobDetailsListColumns } from "./Utils/PreviewCopyJobUtils";
const PreviewCopyJob: React.FC = () => {
@@ -16,6 +17,11 @@ const PreviewCopyJob: React.FC = () => {
targetContainerName: copyJobState.target?.containerId,
},
];
useEffect(() => {
onJobNameChange(undefined, getDefaultJobName(selectedDatabaseAndContainers));
}, []);
const jobName = copyJobState.jobName;
const onJobNameChange = (_ev?: React.FormEvent, newValue?: string) => {

View File

@@ -27,4 +27,5 @@ export const AccountDropdown: React.FC<AccountDropdownProps> = React.memo(
/>
</FieldRow>
),
(prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey,
);

View File

@@ -25,4 +25,5 @@ export const SubscriptionDropdown: React.FC<SubscriptionDropdownProps> = React.m
/>
</FieldRow>
),
(prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey,
);

View File

@@ -16,6 +16,7 @@ import { useDropdownOptions, useEventHandlers } from "./Utils/selectAccountUtils
const SelectAccount = React.memo(() => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId;
const selectedSourceAccountId = copyJobState?.source?.account?.id;
const subscriptions: Subscription[] = useSubscriptions();
const allAccounts: DatabaseAccount[] = useDatabaseAccounts(selectedSubscriptionId);
@@ -38,7 +39,7 @@ const SelectAccount = React.memo(() => {
<AccountDropdown
options={accountOptions}
selectedKey={copyJobState?.source?.account?.id}
selectedKey={selectedSourceAccountId}
disabled={!selectedSubscriptionId}
onChange={(_ev, option) => handleSelectSourceAccount("account", option?.data)}
/>

View File

@@ -11,25 +11,19 @@ export function useDropdownOptions(
subscriptionOptions: DropdownOptionType[];
accountOptions: DropdownOptionType[];
} {
const subscriptionOptions = React.useMemo(
() =>
subscriptions?.map((sub) => ({
key: sub.subscriptionId,
text: sub.displayName,
data: sub,
})) || [],
[subscriptions],
);
const subscriptionOptions =
subscriptions?.map((sub) => ({
key: sub.subscriptionId,
text: sub.displayName,
data: sub,
})) || [];
const accountOptions = React.useMemo(
() =>
accounts?.map((account) => ({
key: account.id,
text: account.name,
data: account,
})) || [],
[accounts],
);
const accountOptions =
accounts?.map((account) => ({
key: account.id,
text: account.name,
data: account,
})) || [];
return { subscriptionOptions, accountOptions };
}
@@ -38,45 +32,42 @@ type setCopyJobStateType = CopyJobContextProviderType["setCopyJobState"];
export function useEventHandlers(setCopyJobState: setCopyJobStateType) {
const { setValidationCache } = useCopyJobPrerequisitesCache();
const handleSelectSourceAccount = React.useCallback(
(type: "subscription" | "account", data: (Subscription & DatabaseAccount) | undefined) => {
setCopyJobState((prevState: CopyJobContextState) => {
if (type === "subscription") {
return {
...prevState,
source: {
...prevState.source,
subscription: data || null,
account: null,
},
};
}
if (type === "account") {
return {
...prevState,
source: {
...prevState.source,
account: data || null,
},
};
}
return prevState;
});
setValidationCache(new Map<string, boolean>());
},
[setCopyJobState, setValidationCache],
);
const handleSelectSourceAccount = (
type: "subscription" | "account",
data: (Subscription & DatabaseAccount) | undefined,
) => {
setCopyJobState((prevState: CopyJobContextState) => {
if (type === "subscription") {
return {
...prevState,
source: {
...prevState.source,
subscription: data || null,
account: null,
},
};
}
if (type === "account") {
return {
...prevState,
source: {
...prevState.source,
account: data || null,
},
};
}
return prevState;
});
setValidationCache(new Map<string, boolean>());
};
const handleMigrationTypeChange = React.useCallback(
(_ev?: React.FormEvent<HTMLElement>, checked?: boolean) => {
setCopyJobState((prevState: CopyJobContextState) => ({
...prevState,
migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
}));
setValidationCache(new Map<string, boolean>());
},
[setCopyJobState, setValidationCache],
);
const handleMigrationTypeChange = React.useCallback((_ev?: React.FormEvent<HTMLElement>, checked?: boolean) => {
setCopyJobState((prevState: CopyJobContextState) => ({
...prevState,
migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
}));
setValidationCache(new Map<string, boolean>());
}, []);
return { handleSelectSourceAccount, handleMigrationTypeChange };
}

View File

@@ -7,36 +7,44 @@ import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { DatabaseContainerSection } from "./components/DatabaseContainerSection";
import { dropDownChangeHandler } from "./Events/DropDownChangeHandler";
import { useMemoizedSourceAndTargetData } from "./memoizedData";
import { useSourceAndTargetData } from "./memoizedData";
const SelectSourceAndTargetContainers = () => {
type SelectSourceAndTargetContainers = {
showAddCollectionPanel?: () => void;
};
const SelectSourceAndTargetContainers = ({ showAddCollectionPanel }: SelectSourceAndTargetContainers) => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams } =
useMemoizedSourceAndTargetData(copyJobState);
useSourceAndTargetData(copyJobState);
const sourceDatabases = useDatabases(...sourceDbParams) || [];
const sourceContainers = useDataContainers(...sourceContainerParams) || [];
const targetDatabases = useDatabases(...targetDbParams) || [];
const targetContainers = useDataContainers(...targetContainerParams) || [];
if (!source) {
return null;
}
const sourceDatabases = useDatabases(...sourceDbParams);
const sourceContainers = useDataContainers(...sourceContainerParams);
const targetDatabases = useDatabases(...targetDbParams);
const targetContainers = useDataContainers(...targetContainerParams);
const sourceDatabaseOptions = React.useMemo(
() => sourceDatabases.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })),
() => sourceDatabases?.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })) || [],
[sourceDatabases],
);
const sourceContainerOptions = React.useMemo(
() => sourceContainers.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })),
() => sourceContainers?.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })) || [],
[sourceContainers],
);
const targetDatabaseOptions = React.useMemo(
() => targetDatabases.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })),
() => targetDatabases?.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })) || [],
[targetDatabases],
);
const targetContainerOptions = React.useMemo(
() => targetContainers.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })),
() => targetContainers?.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })) || [],
[targetContainers],
);
const onDropdownChange = React.useCallback(dropDownChangeHandler(setCopyJobState), [setCopyJobState]);
const onDropdownChange = dropDownChangeHandler(setCopyJobState);
return (
<Stack className="selectSourceAndTargetContainers" tokens={{ childrenGap: 25 }}>
@@ -62,6 +70,7 @@ const SelectSourceAndTargetContainers = () => {
selectedContainer={target?.containerId}
containerDisabled={!target?.databaseId}
containerOnChange={onDropdownChange("targetContainer")}
handleOnDemandCreateContainer={showAddCollectionPanel}
/>
</Stack>
);

View File

@@ -1,4 +1,4 @@
import { Dropdown, Stack } from "@fluentui/react";
import { ActionButton, Dropdown, Stack } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { DatabaseContainerSectionProps } from "../../../../Types/CopyJobTypes";
@@ -14,6 +14,7 @@ export const DatabaseContainerSection = ({
selectedContainer,
containerDisabled,
containerOnChange,
handleOnDemandCreateContainer,
}: DatabaseContainerSectionProps) => (
<Stack tokens={{ childrenGap: 15 }} className="databaseContainerSection">
<label className="subHeading">{heading}</label>
@@ -29,15 +30,22 @@ export const DatabaseContainerSection = ({
/>
</FieldRow>
<FieldRow label={ContainerCopyMessages.containerDropdownLabel}>
<Dropdown
placeholder={ContainerCopyMessages.containerDropdownPlaceholder}
ariaLabel={ContainerCopyMessages.containerDropdownLabel}
options={containerOptions}
required
disabled={!!containerDisabled}
selectedKey={selectedContainer}
onChange={containerOnChange}
/>
<Stack>
<Dropdown
placeholder={ContainerCopyMessages.containerDropdownPlaceholder}
ariaLabel={ContainerCopyMessages.containerDropdownLabel}
options={containerOptions}
required
disabled={!!containerDisabled}
selectedKey={selectedContainer}
onChange={containerOnChange}
/>
{handleOnDemandCreateContainer && (
<ActionButton className="create-container-link-btn" onClick={() => handleOnDemandCreateContainer()}>
{ContainerCopyMessages.createContainerButtonLabel}
</ActionButton>
)}
</Stack>
</FieldRow>
</Stack>
);

View File

@@ -1,8 +1,7 @@
import React from "react";
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
import { CopyJobContextState, DatabaseParams, DataContainerParams } from "../../../Types/CopyJobTypes";
export function useMemoizedSourceAndTargetData(copyJobState: CopyJobContextState) {
export function useSourceAndTargetData(copyJobState: CopyJobContextState) {
const { source, target } = copyJobState ?? {};
const selectedSourceAccount = source?.account;
const selectedTargetAccount = target?.account;
@@ -17,27 +16,22 @@ export function useMemoizedSourceAndTargetData(copyJobState: CopyJobContextState
accountName: targetAccountName,
} = getAccountDetailsFromResourceId(selectedTargetAccount?.id);
const sourceDbParams = React.useMemo(
() => [sourceSubscriptionId, sourceResourceGroup, sourceAccountName, "SQL"] as DatabaseParams,
[sourceSubscriptionId, sourceResourceGroup, sourceAccountName],
);
const sourceContainerParams = React.useMemo(
() =>
[sourceSubscriptionId, sourceResourceGroup, sourceAccountName, source?.databaseId, "SQL"] as DataContainerParams,
[sourceSubscriptionId, sourceResourceGroup, sourceAccountName, source?.databaseId],
);
const targetDbParams = React.useMemo(
() => [targetSubscriptionId, targetResourceGroup, targetAccountName, "SQL"] as DatabaseParams,
[targetSubscriptionId, targetResourceGroup, targetAccountName],
);
const targetContainerParams = React.useMemo(
() =>
[targetSubscriptionId, targetResourceGroup, targetAccountName, target?.databaseId, "SQL"] as DataContainerParams,
[targetSubscriptionId, targetResourceGroup, targetAccountName, target?.databaseId],
);
const sourceDbParams = [sourceSubscriptionId, sourceResourceGroup, sourceAccountName, "SQL"] as DatabaseParams;
const sourceContainerParams = [
sourceSubscriptionId,
sourceResourceGroup,
sourceAccountName,
source?.databaseId,
"SQL",
] as DataContainerParams;
const targetDbParams = [targetSubscriptionId, targetResourceGroup, targetAccountName, "SQL"] as DatabaseParams;
const targetContainerParams = [
targetSubscriptionId,
targetResourceGroup,
targetAccountName,
target?.databaseId,
"SQL",
] as DataContainerParams;
return { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams };
}

View File

@@ -2,7 +2,7 @@ import { useCallback, useMemo, useReducer, useState } from "react";
import { useSidePanel } from "../../../../hooks/useSidePanel";
import { submitCreateCopyJob } from "../../Actions/CopyJobActions";
import { useCopyJobContext } from "../../Context/CopyJobContext";
import { isIntraAccountCopy } from "../../CopyJobUtils";
import { getContainerIdentifiers, isIntraAccountCopy } from "../../CopyJobUtils";
import { CopyJobMigrationType } from "../../Enums/CopyJobEnums";
import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache";
import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList";
@@ -35,10 +35,14 @@ function navigationReducer(state: NavigationState, action: Action): NavigationSt
export function useCopyJobNavigation() {
const [isLoading, setIsLoading] = useState(false);
const { copyJobState, resetCopyJobState, setContextError } = useCopyJobContext();
const screens = useCreateCopyJobScreensList();
const { validationCache: cache } = useCopyJobPrerequisitesCache();
const [state, dispatch] = useReducer(navigationReducer, { screenHistory: [SCREEN_KEYS.SelectAccount] });
const handlePrevious = useCallback(() => {
dispatch({ type: "PREVIOUS" });
}, [dispatch]);
const screens = useCreateCopyJobScreensList(handlePrevious);
const currentScreenKey = state.screenHistory[state.screenHistory.length - 1];
const currentScreen = screens.find((screen) => screen.key === currentScreenKey);
@@ -51,7 +55,9 @@ export function useCopyJobNavigation() {
}, [currentScreen.key, copyJobState, cache, isLoading]);
const primaryBtnText = useMemo(() => {
if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) {
if (currentScreenKey === SCREEN_KEYS.CreateCollection) {
return "Create";
} else if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) {
return "Copy";
}
return "Next";
@@ -65,12 +71,6 @@ export function useCopyJobNavigation() {
useSidePanel.getState().closeSidePanel();
}, []);
const getContainerIdentifiers = (container: typeof copyJobState.source | typeof copyJobState.target) => ({
accountId: container?.account?.id || "",
databaseId: container?.databaseId || "",
containerId: container?.containerId || "",
});
const areContainersIdentical = () => {
const { source, target } = copyJobState;
const sourceIds = getContainerIdentifiers(source);
@@ -107,7 +107,26 @@ export function useCopyJobNavigation() {
}
};
const handleAddCollectionPanelSubmit = () => {
const form = document.getElementById("panelContainer") as HTMLFormElement;
if (form) {
const submitEvent = new Event("submit", {
bubbles: true,
cancelable: true,
});
form.dispatchEvent(submitEvent);
}
};
const showAddCollectionPanel = useCallback(() => {
dispatch({ type: "NEXT", nextScreen: SCREEN_KEYS.CreateCollection });
}, [dispatch]);
const handlePrimary = useCallback(() => {
if (currentScreenKey === SCREEN_KEYS.CreateCollection) {
handleAddCollectionPanelSubmit();
return;
}
if (currentScreenKey === SCREEN_KEYS.SelectSourceAndTargetContainers && areContainersIdentical()) {
setContextError(
"Source and destination containers cannot be the same. Please select different containers to proceed.",
@@ -132,10 +151,6 @@ export function useCopyJobNavigation() {
}
}, [currentScreenKey, copyJobState, areContainersIdentical, handleCopyJobSubmission]);
const handlePrevious = useCallback(() => {
dispatch({ type: "PREVIOUS" });
}, []);
return {
currentScreen,
isPrimaryDisabled,
@@ -143,6 +158,7 @@ export function useCopyJobNavigation() {
handlePrimary,
handlePrevious,
handleCancel,
showAddCollectionPanel,
primaryBtnText,
};
}

View File

@@ -1,11 +1,14 @@
import React from "react";
import { useCopyJobContext } from "../../Context/CopyJobContext";
import { CopyJobContextState } from "../../Types/CopyJobTypes";
import AssignPermissions from "../Screens/AssignPermissions/AssignPermissions";
import AddCollectionPanelWrapper from "../Screens/CreateContainer/AddCollectionPanelWrapper";
import PreviewCopyJob from "../Screens/PreviewCopyJob/PreviewCopyJob";
import SelectAccount from "../Screens/SelectAccount/SelectAccount";
import SelectSourceAndTargetContainers from "../Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers";
const SCREEN_KEYS = {
CreateCollection: "CreateCollection",
SelectAccount: "SelectAccount",
SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers",
PreviewCopyJob: "PreviewCopyJob",
@@ -23,7 +26,9 @@ type Screen = {
validations: Validation[];
};
function useCreateCopyJobScreensList() {
function useCreateCopyJobScreensList(goBack: () => void): Screen[] {
const { explorer } = useCopyJobContext();
return React.useMemo<Screen[]>(
() => [
{
@@ -50,13 +55,18 @@ function useCreateCopyJobScreensList() {
},
],
},
{
key: SCREEN_KEYS.CreateCollection,
component: <AddCollectionPanelWrapper explorer={explorer} goBack={goBack} />,
validations: [],
},
{
key: SCREEN_KEYS.PreviewCopyJob,
component: <PreviewCopyJob />,
validations: [
{
validate: (state: CopyJobContextState) =>
!!(typeof state?.jobName === "string" && state?.jobName && /^[a-zA-Z0-9-.]+$/.test(state?.jobName)),
!!(typeof state?.jobName === "string" && state?.jobName && /^[a-zA-Z0-9-._]+$/.test(state?.jobName)),
message: "Please enter a job name to proceed",
},
],
@@ -80,7 +90,7 @@ function useCreateCopyJobScreensList() {
],
},
],
[],
[explorer],
);
}

View File

@@ -1,4 +1,5 @@
import { FontIcon, getTheme, mergeStyles, mergeStyleSets, 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";
@@ -34,7 +35,11 @@ const iconMap: Partial<Record<CopyJobStatusType, string>> = {
[CopyJobStatusType.Completed]: "CompletedSolid",
};
const CopyJobStatusWithIcon: React.FC<{ status: CopyJobStatusType }> = ({ status }) => {
export interface CopyJobStatusWithIconProps {
status: CopyJobStatusType;
}
const CopyJobStatusWithIcon: React.FC<CopyJobStatusWithIconProps> = React.memo(({ status }) => {
const statusText = ContainerCopyMessages.MonitorJobs.Status[status] || "Unknown";
const isSpinnerStatus = [
@@ -57,6 +62,11 @@ const CopyJobStatusWithIcon: React.FC<{ status: CopyJobStatusType }> = ({ status
<Text>{statusText}</Text>
</Stack>
);
});
CopyJobStatusWithIcon.displayName = "CopyJobStatusWithIcon";
CopyJobStatusWithIcon.propTypes = {
status: PropTypes.oneOf(Object.values(CopyJobStatusType)).isRequired,
};
export default CopyJobStatusWithIcon;

View File

@@ -1,22 +1,28 @@
import { ActionButton, Image } from "@fluentui/react";
import React, { useCallback } from "react";
import Explorer from "Explorer/Explorer";
import React, { memo } from "react";
import CopyJobIcon from "../../../../../images/ContainerCopy/copy-jobs.svg";
import * as Actions from "../../Actions/CopyJobActions";
import ContainerCopyMessages from "../../ContainerCopyMessages";
interface CopyJobsNotFoundProps {}
interface CopyJobsNotFoundProps {
explorer: Explorer;
}
const CopyJobsNotFound: React.FC<CopyJobsNotFoundProps> = () => {
const handleCreateCopyJob = useCallback(Actions.openCreateCopyJobPanel, []);
const CopyJobsNotFound: React.FC<CopyJobsNotFoundProps> = ({ explorer }) => {
return (
<div className="notFoundContainer flexContainer centerContent">
<Image src={CopyJobIcon} alt={ContainerCopyMessages.noCopyJobsTitle} width={100} height={100} />
<h4 className="noCopyJobsMessage">{ContainerCopyMessages.noCopyJobsTitle}</h4>
<ActionButton allowDisabledFocus className="createCopyJobButton" onClick={handleCreateCopyJob}>
<ActionButton
allowDisabledFocus
className="createCopyJobButton"
onClick={Actions.openCreateCopyJobPanel.bind(null, explorer)}
>
{ContainerCopyMessages.createCopyJobButtonText}
</ActionButton>
</div>
);
};
export default CopyJobsNotFound;
export default memo(CopyJobsNotFound);

View File

@@ -1,9 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/prop-types */
import {
ConstrainMode,
DetailsListLayoutMode,
DetailsRow,
IColumn,
IDetailsRowProps,
ScrollablePane,
ScrollbarVisibility,
ShimmeredDetailsList,
@@ -58,22 +60,19 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
setStartIndex(0);
};
const columns: IColumn[] = React.useMemo(
() => getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending),
[handleSort, handleActionClick, sortedColumnKey, isSortedDescending],
);
const columns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending);
const _handleRowClick = React.useCallback((job: CopyJobType) => {
const _handleRowClick = (job: CopyJobType) => {
openCopyJobDetailsPanel(job);
}, []);
};
const _onRenderRow = React.useCallback((props: any) => {
const _onRenderRow = (props: IDetailsRowProps) => {
return (
<div onClick={_handleRowClick.bind(null, props.item)}>
<DetailsRow {...props} styles={{ root: { cursor: "pointer" } }} />
</div>
);
}, []);
};
return (
<div style={styles.container}>

View File

@@ -1,31 +1,33 @@
/* eslint-disable react/display-name */
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
import ShimmerTree, { IndentLevel } from "Common/ShimmerTree/ShimmerTree";
import Explorer from "Explorer/Explorer";
import React, { forwardRef, useEffect, useImperativeHandle } from "react";
import { getCopyJobs, updateCopyJobStatus } from "../Actions/CopyJobActions";
import { convertToCamelCase } from "../CopyJobUtils";
import { convertToCamelCase, isEqual } from "../CopyJobUtils";
import { CopyJobStatusType } from "../Enums/CopyJobEnums";
import CopyJobsNotFound from "../MonitorCopyJobs/Components/CopyJobs.NotFound";
import { CopyJobType, JobActionUpdatorType } from "../Types/CopyJobTypes";
import CopyJobsList from "./Components/CopyJobsList";
const FETCH_INTERVAL_MS = 30 * 1000;
const SHIMMER_INDENT_LEVELS: IndentLevel[] = Array(7).fill({ level: 0, width: "100%" });
interface MonitorCopyJobsProps {}
interface MonitorCopyJobsProps {
explorer: Explorer;
}
export interface MonitorCopyJobsRef {
refreshJobList: () => void;
}
const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>((_props, ref) => {
const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>(({ explorer }, ref) => {
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [jobs, setJobs] = React.useState<CopyJobType[]>([]);
const isUpdatingRef = React.useRef(false);
const isFirstFetchRef = React.useRef(true);
const indentLevels = React.useMemo<IndentLevel[]>(() => Array(7).fill({ level: 0, width: "100%" }), []);
const fetchJobs = React.useCallback(async () => {
if (isUpdatingRef.current) {
return;
@@ -38,8 +40,7 @@ const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>((_p
const response = await getCopyJobs();
setJobs((prevJobs) => {
const isSame = JSON.stringify(prevJobs) === JSON.stringify(response);
return isSame ? prevJobs : response;
return isEqual(prevJobs, response) ? prevJobs : response;
});
} catch (error) {
setError(error.message || "Failed to load copy jobs. Please try again later.");
@@ -96,25 +97,27 @@ const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>((_p
[],
);
const memoizedJobsList = React.useMemo(() => {
const renderJobsList = () => {
if (loading) {
return null;
}
if (jobs.length > 0) {
return <CopyJobsList jobs={jobs} handleActionClick={handleActionClick} />;
}
return <CopyJobsNotFound />;
}, [jobs, loading, handleActionClick]);
return <CopyJobsNotFound explorer={explorer} />;
};
return (
<Stack className="monitorCopyJobs flexContainer">
{loading && <ShimmerTree indentLevels={indentLevels} style={{ width: "100%", padding: "1rem 2.5rem" }} />}
{loading && (
<ShimmerTree indentLevels={SHIMMER_INDENT_LEVELS} style={{ width: "100%", padding: "1rem 2.5rem" }} />
)}
{error && (
<MessageBar messageBarType={MessageBarType.error} isMultiline={false} onDismiss={() => setError(null)}>
{error}
</MessageBar>
)}
{memoizedJobsList}
{renderJobsList()}
</Stack>
);
});

View File

@@ -6,7 +6,7 @@ import Explorer from "../../Explorer";
import { CopyJobMigrationType, CopyJobStatusType } from "../Enums/CopyJobEnums";
export interface ContainerCopyProps {
container: Explorer;
explorer: Explorer;
}
export type CopyJobCommandBarBtnType = {
@@ -48,6 +48,7 @@ export interface DatabaseContainerSectionProps {
selectedContainer: string;
containerDisabled?: boolean;
containerOnChange: (ev: React.FormEvent<HTMLDivElement>, option: DropdownOptionType) => void;
handleOnDemandCreateContainer?: () => void;
}
export interface CopyJobContextState {
@@ -80,6 +81,7 @@ export interface CopyJobContextProviderType {
copyJobState: CopyJobContextState | null;
setCopyJobState: React.Dispatch<React.SetStateAction<CopyJobContextState>>;
resetCopyJobState: () => void;
explorer?: Explorer;
}
export type CopyJobType = {

View File

@@ -19,6 +19,10 @@
.createCopyJobScreensContainer {
height: 100%;
padding: 1em 1.5em;
.pointInTimeRestoreContainer, .onlineCopyContainer {
position: relative;
}
label {
padding: 0;
@@ -59,6 +63,7 @@
}
}
.popover-container {
border-radius: 6px;
button[disabled] {
cursor: not-allowed;
opacity: 0.8;
@@ -66,7 +71,7 @@
}
.foreground {
z-index: 10;
background-color: white;
background-color: #f9f9f9;
padding: 20px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transform: translate(0%, -9%);
@@ -75,6 +80,24 @@
.createCopyJobErrorMessageBar {
margin-bottom: 2em;
}
.create-container-link-btn {
padding: 0;
height: 25px;
color: @LinkColor;
&:focus {
outline: none;
}
}
/* Create collection panel */
.panelFormWrapper .panelMainContent {
padding: 0;
}
.createCopyJobScreensFooter {
margin-top: 50px;
}
}
.monitorCopyJobs {
@@ -118,8 +141,9 @@
.jobNameLink {
color: @LinkColor;
text-decoration: underline;
cursor: pointer;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
}

View File

@@ -65,6 +65,8 @@ export interface AddCollectionPanelProps {
explorer: Explorer;
databaseId?: string;
isQuickstart?: boolean;
isCopyJobFlow?: boolean;
onSubmitSuccess?: (collectionData: { databaseId: string; collectionId: string }) => void;
}
export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = {
@@ -975,7 +977,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
)}
</div>
<PanelFooterComponent buttonLabel="OK" isButtonDisabled={this.state.isThroughputCapExceeded} />
{!this.props.isCopyJobFlow && (
<PanelFooterComponent buttonLabel="OK" isButtonDisabled={this.state.isThroughputCapExceeded} />
)}
{this.state.isExecuting && (
<div>
@@ -1415,8 +1419,13 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
}
}
this.setState({ isExecuting: false });
TelemetryProcessor.traceSuccess(Action.CreateCollection, telemetryData, startKey);
useSidePanel.getState().closeSidePanel();
if (this.props.isCopyJobFlow && this.props.onSubmitSuccess) {
this.props.onSubmitSuccess({ databaseId, collectionId });
} else {
TelemetryProcessor.traceSuccess(Action.CreateCollection, telemetryData, startKey);
useSidePanel.getState().closeSidePanel();
}
} catch (error) {
const errorMessage: string = getErrorMessage(error);
this.setState({ isExecuting: false, errorMessage, showErrorDetails: true });

View File

@@ -87,7 +87,7 @@ const App: React.FunctionComponent = () => {
<KeyboardShortcutRoot>
<div className="flexContainer" aria-hidden="false" data-test="DataExplorerRoot">
{userContext.features.enableContainerCopy && userContext.apiType === "SQL" ? (
<ContainerCopyPanel container={explorer} />
<ContainerCopyPanel explorer={explorer} />
) : (
<DivExplorer explorer={explorer} />
)}

View File

@@ -2,7 +2,7 @@ import { DatabaseAccount } from "Contracts/DataModels";
import { userContext } from "UserContext";
import { buildArmUrl } from "Utils/arm/armUtils";
const apiVersion = "2025-04-15";
const apiVersion = "2025-05-01-preview";
export type FetchAccountDetailsParams = {
subscriptionId: string;
resourceGroupName: string;

View File

@@ -6,6 +6,7 @@ export interface SidePanelState {
hasConsole: boolean;
panelContent?: JSX.Element;
headerText?: string;
setHeaderText: (headerText: string) => void;
openSidePanel: (headerText: string, panelContent: JSX.Element, panelWidth?: string, onClose?: () => void) => void;
closeSidePanel: () => void;
setPanelHasConsole: (hasConsole: boolean) => void;
@@ -15,6 +16,7 @@ export const useSidePanel: UseStore<SidePanelState> = create((set) => ({
isOpen: false,
panelWidth: "440px",
hasConsole: true,
setHeaderText: (headerText: string) => set((state) => ({ ...state, headerText })),
setPanelHasConsole: (hasConsole: boolean) => set((state) => ({ ...state, hasConsole })),
openSidePanel: (headerText, panelContent, panelWidth = "440px") =>
set((state) => ({ ...state, headerText, panelContent, panelWidth, isOpen: true })),