From 125b1c86b7ad03411c625620a2bb04265cd8cdc8 Mon Sep 17 00:00:00 2001 From: BChoudhury-ms Date: Wed, 19 Nov 2025 22:41:13 +0530 Subject: [PATCH 1/9] Refactor Container Copy Jobs for Intra-account copy and Online operations (#2258) * fix: for intra-account copy, validation screen should not visible * fix: handle online operations using a button instead manual CLI commands * reset validation cache on leaving of permission screen * update same account logic * fix: update job action menu list and permission screen messages * uplift error handling to context level * use of logError instead of console.error --- src/Common/Constants.ts | 2 +- .../ContainerCopy/Actions/CopyJobActions.tsx | 7 +- .../ContainerCopy/ContainerCopyMessages.ts | 6 +- .../ContainerCopy/Context/CopyJobContext.tsx | 17 ++- src/Explorer/ContainerCopy/CopyJobUtils.ts | 12 +- .../AddReadPermissionToDefaultIdentity.tsx | 10 +- .../AssignPermissions/AssignPermissions.tsx | 11 +- .../AssignPermissions/OnlineCopyEnabled.tsx | 106 +++++++++++------- .../AssignPermissions/PointInTimeRestore.tsx | 15 ++- .../hooks/useManagedIdentity.tsx | 7 +- .../hooks/usePermissionsSection.tsx | 6 +- .../Screens/CreateCopyJobScreens.tsx | 10 +- .../Utils/selectAccountUtils.tsx | 8 +- .../Utils/useCopyJobNavigation.ts | 26 ++--- .../Components/CopyJobActionMenu.tsx | 13 ++- .../ContainerCopy/Types/CopyJobTypes.ts | 2 + 16 files changed, 166 insertions(+), 92 deletions(-) diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 74d5ef925..802bfd590 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -93,7 +93,7 @@ export class CapabilityNames { public static readonly EnableDataMasking: string = "EnableDataMasking"; public static readonly EnableDynamicDataMasking: string = "EnableDynamicDataMasking"; public static readonly EnableNoSQLFullTextSearchPreviewFeatures: string = "EnableNoSQLFullTextSearchPreviewFeatures"; - public static readonly EnableOnlineCopyFeature: string = "EnableOnlineCopyFeature"; + public static readonly EnableOnlineCopyFeature: string = "EnableOnlineContainerCopy"; } export enum CapacityMode { diff --git a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx index 6394359db..14198b700 100644 --- a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx +++ b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx @@ -1,5 +1,6 @@ import React from "react"; import { userContext } from "UserContext"; +import { logError } from "../../../Common/Logger"; import { useSidePanel } from "../../../hooks/useSidePanel"; import { cancel, @@ -159,7 +160,8 @@ export const submitCreateCopyJob = async (state: CopyJobContextState, onSuccess: onSuccess(); return response; } catch (error) { - console.error("Error submitting create copy job:", error); + const errorMessage = error.message || "Error submitting create copy job. Please try again later."; + logError(errorMessage, "CopyJob/CopyJobActions.submitCreateCopyJob"); throw error; } }; @@ -198,8 +200,7 @@ export const updateCopyJobStatus = async (job: CopyJobType, action: string): Pro pattern, `'${ContainerCopyMessages.MonitorJobs.Status.InProgress}'`, ); - - console.error(`Error updating copy job status: ${normalizedErrorMessage}`); + logError(`Error updating copy job status: ${normalizedErrorMessage}`, "CopyJob/CopyJobActions.updateCopyJobStatus"); throw error; } }; diff --git a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts index 80c9c658f..0be496783 100644 --- a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts +++ b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts @@ -48,8 +48,10 @@ export default { // Assign Permissions Screen assignPermissions: { - description: + crossAccountDescription: "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.`, }, toggleBtn: { onText: "On", @@ -115,7 +117,7 @@ export default { }, onlineCopyEnabled: { title: "Online copy enabled", - description: (accountName: string) => `Use Azure CLI to enable Online copy on "${accountName}".`, + description: (accountName: string) => `Enable Online copy on "${accountName}".`, 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", diff --git a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx index 16f17598b..d89f839e2 100644 --- a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx +++ b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx @@ -39,16 +39,23 @@ const getInitialCopyJobState = (): CopyJobContextState => { const CopyJobContextProvider: React.FC = (props) => { const [copyJobState, setCopyJobState] = React.useState(getInitialCopyJobState()); const [flow, setFlow] = React.useState(null); + const [contextError, setContextError] = React.useState(null); const resetCopyJobState = () => { setCopyJobState(getInitialCopyJobState()); }; - return ( - - {props.children} - - ); + const contextValue: CopyJobContextProviderType = { + contextError, + setContextError, + copyJobState, + setCopyJobState, + flow, + setFlow, + resetCopyJobState, + }; + + return {props.children}; }; export default CopyJobContextProvider; diff --git a/src/Explorer/ContainerCopy/CopyJobUtils.ts b/src/Explorer/ContainerCopy/CopyJobUtils.ts index a37fa660a..1a9e46aad 100644 --- a/src/Explorer/ContainerCopy/CopyJobUtils.ts +++ b/src/Explorer/ContainerCopy/CopyJobUtils.ts @@ -106,7 +106,7 @@ export function getAccountDetailsFromResourceId(accountId: string | undefined) { return null; } const pattern = new RegExp( - "/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft\\.DocumentDB/databaseAccounts/([^/]+)", + "/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft\\.DocumentDB?/databaseAccounts/([^/]+)", "i", ); const matches = accountId.match(pattern); @@ -114,3 +114,13 @@ export function getAccountDetailsFromResourceId(accountId: string | undefined) { const [_, subscriptionId, resourceGroup, accountName] = matches || []; return { subscriptionId, resourceGroup, accountName }; } + +export function isIntraAccountCopy(sourceAccountId: string | undefined, targetAccountId: string | undefined): boolean { + const sourceAccountDetails = getAccountDetailsFromResourceId(sourceAccountId); + const targetAccountDetails = getAccountDetailsFromResourceId(targetAccountId); + return ( + sourceAccountDetails?.subscriptionId === targetAccountDetails?.subscriptionId && + sourceAccountDetails?.resourceGroup === targetAccountDetails?.resourceGroup && + sourceAccountDetails?.accountName === targetAccountDetails?.accountName + ); +} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx index 4c3f5e98d..160c6d973 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx @@ -1,5 +1,6 @@ import { Link, Stack, Text, Toggle } from "@fluentui/react"; import React, { useCallback } from "react"; +import { logError } from "../../../../../Common/Logger"; import { assignRole } from "../../../../../Utils/arm/RbacUtils"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; import { useCopyJobContext } from "../../../Context/CopyJobContext"; @@ -21,7 +22,7 @@ type AddReadPermissionToDefaultIdentityProps = Partial; const AddReadPermissionToDefaultIdentity: React.FC = () => { const [loading, setLoading] = React.useState(false); - const { copyJobState, setCopyJobState } = useCopyJobContext(); + const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext(); const [readPermissionAssigned, onToggle] = useToggle(false); const handleAddReadPermission = useCallback(async () => { @@ -48,11 +49,14 @@ const AddReadPermissionToDefaultIdentity: React.FC diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx index 6a5e69154..1f3861753 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx @@ -6,6 +6,7 @@ import WarningIcon from "../../../../../../images/warning.svg"; import ShimmerTree, { IndentLevel } from "../../../../../Common/ShimmerTree/ShimmerTree"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import { isIntraAccountCopy } from "../../../CopyJobUtils"; import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; import usePermissionSections, { PermissionSectionConfig } from "./hooks/usePermissionsSection"; @@ -39,6 +40,8 @@ const AssignPermissions = () => { [], ); + const isSameAccount = isIntraAccountCopy(copyJobState?.source?.account?.id, copyJobState?.target?.account?.id); + useEffect(() => { const firstIncompleteSection = permissionSections.find((section) => !section.completed); const nextOpenItems = firstIncompleteSection ? [firstIncompleteSection.id] : []; @@ -49,7 +52,13 @@ const AssignPermissions = () => { return ( - {ContainerCopyMessages.assignPermissions.description} + + {isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online + ? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription( + copyJobState?.source?.account?.name || "", + ) + : ContainerCopyMessages.assignPermissions.crossAccountDescription} + {permissionSections?.length === 0 ? ( ) : ( diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx index f616df1c6..9d9279e1a 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx @@ -1,7 +1,10 @@ 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 { logError } from "../../../../../Common/Logger"; +import { update as updateDatabaseAccount } from "../../../../../Utils/arm/generatedClients/cosmos/databaseAccounts"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; import { useCopyJobContext } from "../../../Context/CopyJobContext"; import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils"; @@ -19,8 +22,10 @@ const OnlineCopyEnabled: React.FC = () => { const [showRefreshButton, setShowRefreshButton] = React.useState(false); const intervalRef = React.useRef(null); const timeoutRef = React.useRef(null); - const { copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext(); + const { setContextError, copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext(); const selectedSourceAccount = source?.account; + const sourceAccountCapabilities = selectedSourceAccount?.properties?.capabilities ?? []; + const { subscriptionId: sourceSubscriptionId, resourceGroup: sourceResourceGroup, @@ -38,16 +43,24 @@ const OnlineCopyEnabled: React.FC = () => { setLoading(false); } } catch (error) { - console.error("Error fetching source account after enabling online copy:", error); - setLoading(false); + const errorMessage = + error.message || "Error fetching source account after enabling online copy. Please try again later."; + logError(errorMessage, "CopyJob/OnlineCopyEnabled.handleFetchAccount"); + setContextError(errorMessage); + clearAccountFetchInterval(); } }; - const clearIntervalAndShowRefresh = () => { + const clearAccountFetchInterval = () => { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } + setLoading(false); + }; + + const clearIntervalAndShowRefresh = () => { + clearAccountFetchInterval(); setShowRefreshButton(true); }; @@ -56,18 +69,42 @@ const OnlineCopyEnabled: React.FC = () => { handleFetchAccount(); }; + const handleOnlineCopyEnable = async () => { + setLoading(true); + setShowRefreshButton(false); + + try { + await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, { + properties: { + enableAllVersionsAndDeletesChangeFeed: true, + }, + }); + + await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, { + properties: { + capabilities: [...sourceAccountCapabilities, { name: CapabilityNames.EnableOnlineCopyFeature }], + }, + }); + + intervalRef.current = setInterval(() => { + handleFetchAccount(); + }, 30 * 1000); + + timeoutRef.current = setTimeout( + () => { + clearIntervalAndShowRefresh(); + }, + 10 * 60 * 1000, + ); + } catch (error) { + const errorMessage = error.message || "Failed to enable online copy feature. Please try again later."; + logError(errorMessage, "CopyJob/OnlineCopyEnabled.handleOnlineCopyEnable"); + setContextError(errorMessage); + setLoading(false); + } + }; + React.useEffect(() => { - intervalRef.current = setInterval(() => { - handleFetchAccount(); - }, 30 * 1000); - - timeoutRef.current = setTimeout( - () => { - clearIntervalAndShowRefresh(); - }, - 15 * 60 * 1000, - ); - return () => { if (intervalRef.current) { clearInterval(intervalRef.current); @@ -89,32 +126,7 @@ const OnlineCopyEnabled: React.FC = () => { -
-          
-            {`# Set shell variables
-$resourceGroupName = 
-$accountName = 
-$EnableOnlineContainerCopy = "EnableOnlineContainerCopy"
-
-# List down existing capabilities of your account
-$cosmosdb = az cosmosdb show --resource-group $resourceGroupName --name $accountName
-
-$capabilities = (($cosmosdb | ConvertFrom-Json).capabilities)
-
-# Append EnableOnlineContainerCopy capability in the list of capabilities
-$capabilitiesToAdd = @()
-foreach ($item in $capabilities) {
-  $capabilitiesToAdd += $item.name
-}
-$capabilitiesToAdd += $EnableOnlineContainerCopy
-
-# Update Cosmos DB account
-az cosmosdb update --capabilities $capabilitiesToAdd -n $accountName -g $resourceGroupName`}
-          
-        
-
- {showRefreshButton && ( - + {showRefreshButton ? ( - - )} + ) : ( + + )} +
); }; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx index eb072d92b..30c104ec2 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx @@ -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 { logError } from "../../../../../Common/Logger"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; import { useCopyJobContext } from "../../../Context/CopyJobContext"; import { buildResourceLink, getAccountDetailsFromResourceId } from "../../../CopyJobUtils"; @@ -63,17 +64,23 @@ const PointInTimeRestore: React.FC = () => { setLoading(false); } } catch (error) { - console.error("Error fetching source account after Point-in-Time Restore:", error); - setLoading(false); + const errorMessage = + error.message || "Error fetching source account after Point-in-Time Restore. Please try again later."; + logError(errorMessage, "CopyJob/PointInTimeRestore.handleFetchAccount"); + clearAccountFetchInterval(); } }; - const clearIntervalAndShowRefresh = () => { + const clearAccountFetchInterval = () => { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } setLoading(false); + }; + + const clearIntervalAndShowRefresh = () => { + clearAccountFetchInterval(); setShowRefreshButton(true); }; @@ -95,7 +102,7 @@ const PointInTimeRestore: React.FC = () => { () => { clearIntervalAndShowRefresh(); }, - 15 * 60 * 1000, + 10 * 60 * 1000, ); }; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx index 08c79da3b..9ac826e8c 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/useManagedIdentity.tsx @@ -1,5 +1,6 @@ import { DatabaseAccount } from "Contracts/DataModels"; import { useCallback, useState } from "react"; +import { logError } from "../../../../../../Common/Logger"; import { useCopyJobContext } from "../../../../Context/CopyJobContext"; import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils"; @@ -19,7 +20,7 @@ interface UseManagedIdentityUpdaterReturn { const useManagedIdentity = ( updateIdentityFn: UseManagedIdentityUpdaterParams["updateIdentityFn"], ): UseManagedIdentityUpdaterReturn => { - const { copyJobState, setCopyJobState } = useCopyJobContext(); + const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext(); const [loading, setLoading] = useState(false); const handleAddSystemIdentity = useCallback(async (): Promise => { @@ -40,7 +41,9 @@ const useManagedIdentity = ( })); } } catch (error) { - console.error("Error enabling system-assigned managed identity:", error); + const errorMessage = error.message || "Error enabling system-assigned managed identity. Please try again later."; + logError(errorMessage, "CopyJob/useManagedIdentity.handleAddSystemIdentity"); + setContextError(errorMessage); } finally { setLoading(false); } diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx index 53db66ff8..01687101c 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx @@ -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 } from "../../../../CopyJobUtils"; +import { getAccountDetailsFromResourceId, isIntraAccountCopy } from "../../../../CopyJobUtils"; import { BackupPolicyType, CopyJobMigrationType, @@ -139,7 +139,9 @@ const usePermissionSections = (state: CopyJobContextState): PermissionSectionCon const isValidatingRef = useRef(false); const sectionToValidate = useMemo(() => { - const baseSections = sourceAccountId === targetAccountId ? [] : [...PERMISSION_SECTIONS_CONFIG]; + const isSameAccount = isIntraAccountCopy(sourceAccountId, targetAccountId); + + const baseSections = isSameAccount ? [] : [...PERMISSION_SECTIONS_CONFIG]; if (state.migrationType === CopyJobMigrationType.Online) { return [...baseSections, ...PERMISSION_SECTIONS_FOR_ONLINE_JOBS]; } diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx index cd7f39bf1..1ee9a89be 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx @@ -1,5 +1,6 @@ import { MessageBar, MessageBarType, Stack } from "@fluentui/react"; import React from "react"; +import { useCopyJobContext } from "../../Context/CopyJobContext"; import { useCopyJobNavigation } from "../Utils/useCopyJobNavigation"; import NavigationControls from "./Components/NavigationControls"; @@ -12,24 +13,23 @@ const CreateCopyJobScreens: React.FC = () => { handlePrevious, handleCancel, primaryBtnText, - error, - setError, } = useCopyJobNavigation(); + const { contextError, setContextError } = useCopyJobContext(); return ( - {error && ( + {contextError && ( setError(null)} + onDismiss={() => setContextError(null)} dismissButtonAriaLabel="Close" truncated={true} overflowButtonAriaLabel="See more" > - {error} + {contextError} )} {currentScreen?.component} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx index 16d17c33b..adb36b3a1 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Utils/selectAccountUtils.tsx @@ -2,6 +2,7 @@ import React from "react"; import { DatabaseAccount, Subscription } from "../../../../../../Contracts/DataModels"; import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums"; import { CopyJobContextProviderType, CopyJobContextState, DropdownOptionType } from "../../../../Types/CopyJobTypes"; +import { useCopyJobPrerequisitesCache } from "../../../Utils/useCopyJobPrerequisitesCache"; export function useDropdownOptions( subscriptions: Subscription[], @@ -36,6 +37,7 @@ export function useDropdownOptions( 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) => { @@ -60,8 +62,9 @@ export function useEventHandlers(setCopyJobState: setCopyJobStateType) { } return prevState; }); + setValidationCache(new Map()); }, - [setCopyJobState], + [setCopyJobState, setValidationCache], ); const handleMigrationTypeChange = React.useCallback( @@ -70,8 +73,9 @@ export function useEventHandlers(setCopyJobState: setCopyJobStateType) { ...prevState, migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online, })); + setValidationCache(new Map()); }, - [setCopyJobState], + [setCopyJobState, setValidationCache], ); return { handleSelectSourceAccount, handleMigrationTypeChange }; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts index 4bd552455..ea313ae12 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts @@ -2,6 +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 { CopyJobMigrationType } from "../../Enums/CopyJobEnums"; import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache"; import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList"; @@ -33,8 +34,7 @@ function navigationReducer(state: NavigationState, action: Action): NavigationSt export function useCopyJobNavigation() { const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const { copyJobState, resetCopyJobState } = useCopyJobContext(); + const { copyJobState, resetCopyJobState, setContextError } = useCopyJobContext(); const screens = useCreateCopyJobScreensList(); const { validationCache: cache } = useCopyJobPrerequisitesCache(); const [state, dispatch] = useReducer(navigationReducer, { screenHistory: [SCREEN_KEYS.SelectAccount] }); @@ -71,18 +71,13 @@ export function useCopyJobNavigation() { containerId: container?.containerId || "", }); - const isSameAccount = ( - sourceIds: ReturnType, - targetIds: ReturnType, - ) => sourceIds.accountId === targetIds.accountId; - const areContainersIdentical = () => { const { source, target } = copyJobState; const sourceIds = getContainerIdentifiers(source); const targetIds = getContainerIdentifiers(target); return ( - isSameAccount(sourceIds, targetIds) && + isIntraAccountCopy(sourceIds.accountId, targetIds.accountId) && sourceIds.databaseId === targetIds.databaseId && sourceIds.containerId === targetIds.containerId ); @@ -90,9 +85,10 @@ export function useCopyJobNavigation() { const shouldNotShowPermissionScreen = () => { const { source, target, migrationType } = copyJobState; + const sourceIds = getContainerIdentifiers(source); + const targetIds = getContainerIdentifiers(target); return ( - migrationType === CopyJobMigrationType.Offline && - isSameAccount(getContainerIdentifiers(source), getContainerIdentifiers(target)) + migrationType === CopyJobMigrationType.Offline && isIntraAccountCopy(sourceIds.accountId, targetIds.accountId) ); }; @@ -105,7 +101,7 @@ export function useCopyJobNavigation() { error instanceof Error ? error.message || "Failed to create copy job. Please try again later." : "Failed to create copy job. Please try again later."; - setError(errorMessage); + setContextError(errorMessage); } finally { setIsLoading(false); } @@ -113,11 +109,13 @@ export function useCopyJobNavigation() { const handlePrimary = useCallback(() => { if (currentScreenKey === SCREEN_KEYS.SelectSourceAndTargetContainers && areContainersIdentical()) { - setError("Source and destination containers cannot be the same. Please select different containers to proceed."); + setContextError( + "Source and destination containers cannot be the same. Please select different containers to proceed.", + ); return; } - setError(null); + setContextError(null); const transitions = { [SCREEN_KEYS.SelectAccount]: shouldNotShowPermissionScreen() ? SCREEN_KEYS.SelectSourceAndTargetContainers @@ -146,7 +144,5 @@ export function useCopyJobNavigation() { handlePrevious, handleCancel, primaryBtnText, - error, - setError, }; } diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx index 57bc99acd..b43307be9 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx @@ -11,7 +11,14 @@ interface CopyJobActionMenuProps { const CopyJobActionMenu: React.FC = ({ job, handleClick }) => { const [updatingJobAction, setUpdatingJobAction] = React.useState<{ jobName: string; action: string } | null>(null); - if ([CopyJobStatusType.Completed, CopyJobStatusType.Cancelled].includes(job.Status)) { + if ( + [ + CopyJobStatusType.Completed, + CopyJobStatusType.Cancelled, + CopyJobStatusType.Failed, + CopyJobStatusType.Faulted, + ].includes(job.Status) + ) { return null; } @@ -55,7 +62,7 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick [CopyJobStatusType.InProgress, CopyJobStatusType.Running, CopyJobStatusType.Partitioning].includes(job.Status) ) { const filteredItems = baseItems.filter((item) => item.key !== CopyJobActions.resume); - if (job.Mode === CopyJobMigrationType.Online) { + if ((job.Mode ?? "").toLowerCase() === CopyJobMigrationType.Online) { filteredItems.push({ key: CopyJobActions.complete, text: ContainerCopyMessages.MonitorJobs.Actions.complete, @@ -67,7 +74,7 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick return filteredItems; } - if ([CopyJobStatusType.Failed, CopyJobStatusType.Faulted, CopyJobStatusType.Skipped].includes(job.Status)) { + if ([CopyJobStatusType.Skipped].includes(job.Status)) { return baseItems.filter((item) => item.key === CopyJobActions.resume); } diff --git a/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts b/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts index 1c031bb33..30f6ee2e4 100644 --- a/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts +++ b/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts @@ -73,6 +73,8 @@ export interface CopyJobFlowType { } export interface CopyJobContextProviderType { + contextError: string | null; + setContextError: React.Dispatch>; flow: CopyJobFlowType; setFlow: React.Dispatch>; copyJobState: CopyJobContextState | null; From c72d92186693a8b71392d2fe7f2b0348b382f1b9 Mon Sep 17 00:00:00 2001 From: Laurent Nguyen Date: Thu, 20 Nov 2025 15:02:32 +0100 Subject: [PATCH 2/9] fix: for fabric, don't display Querying offer for collection. (#2259) Co-authored-by: Laurent Nguyen --- src/Common/dataAccess/readCollectionOffer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Common/dataAccess/readCollectionOffer.ts b/src/Common/dataAccess/readCollectionOffer.ts index d3c8e25cd..6712f274b 100644 --- a/src/Common/dataAccess/readCollectionOffer.ts +++ b/src/Common/dataAccess/readCollectionOffer.ts @@ -12,13 +12,13 @@ import { handleError } from "../ErrorHandlingUtils"; import { readOfferWithSDK } from "./readOfferWithSDK"; export const readCollectionOffer = async (params: ReadCollectionOfferParams): Promise => { - const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`); - if (isFabric()) { // Not exposing offers in Fabric return undefined; } + const clearMessage = logConsoleProgress(`Querying offer for collection ${params.collectionId}`); + try { if ( userContext.authType === AuthType.AAD && From 0fac59967a4572ad769792366f76a1440e7f8d7e Mon Sep 17 00:00:00 2001 From: sunghyunkang1111 <114709653+sunghyunkang1111@users.noreply.github.com> Date: Thu, 20 Nov 2025 12:23:24 -0600 Subject: [PATCH 3/9] Fix mongo database name handling (#2262) --- utils/cleanupDBs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/cleanupDBs.js b/utils/cleanupDBs.js index 723ec1c73..e0a42d946 100644 --- a/utils/cleanupDBs.js +++ b/utils/cleanupDBs.js @@ -28,7 +28,7 @@ async function main() { const mongoDatabases = await client.mongoDBResources.listMongoDBDatabases(resourceGroupName, account.name); for (const database of mongoDatabases) { // Unfortunately Mongo does not provide a timestamp in ARM. There is no way to tell how old the DB is other thn encoding it in the ID :( - const timestamp = Number(database.name.split("-")[1]); + const timestamp = Number(database.name.split("_").pop()); if (timestamp && timestamp < thirtyMinutesAgo) { await client.mongoDBResources.deleteMongoDBDatabase(resourceGroupName, account.name, database.name); console.log(`DELETED: ${account.name} | ${database.name} | Age: ${friendlyTime(Date.now() - timestamp)}`); From 490309b403e29781208fbe8e87abde9b547cfcfa Mon Sep 17 00:00:00 2001 From: vchske Date: Mon, 24 Nov 2025 12:36:03 -0800 Subject: [PATCH 4/9] Fixes an issue where tab titles were not truncating when characters used 4 bytes for encoding (#2254) * Fixes an issue where tab titles were not truncating when characters used 4 bytes for encoding. * Changed substringUtf method to be more accurate and added comments --- src/Explorer/Tabs/TabsBase.ts | 5 ++-- src/Utils/StringUtils.test.ts | 17 +++++++++++ src/Utils/StringUtils.ts | 55 +++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/Explorer/Tabs/TabsBase.ts b/src/Explorer/Tabs/TabsBase.ts index 2b97fed3e..2602b672d 100644 --- a/src/Explorer/Tabs/TabsBase.ts +++ b/src/Explorer/Tabs/TabsBase.ts @@ -1,5 +1,6 @@ import { OpenTab } from "Contracts/ActionContracts"; import { KeyboardActionGroup, clearKeyboardActionGroup } from "KeyboardShortcuts"; +import { substringUtf } from "Utils/StringUtils"; import * as ko from "knockout"; import * as Constants from "../../Common/Constants"; import * as ThemeUtility from "../../Common/ThemeUtility"; @@ -154,13 +155,13 @@ export default class TabsBase extends WaitsForTemplateViewModel { const db = this.database?.id(); if (coll) { if (coll.length > 8) { - return coll.slice(0, 5) + "…" + options.title; + return substringUtf(coll, 0, 5) + "…" + options.title; } else { return coll + "." + options.title; } } else if (db) { if (db.length > 8) { - return db.slice(0, 5) + "…" + options.title; + return substringUtf(db, 0, 5) + "…" + options.title; } else { return db + "." + options.title; } diff --git a/src/Utils/StringUtils.test.ts b/src/Utils/StringUtils.test.ts index bd626f492..408556f07 100644 --- a/src/Utils/StringUtils.test.ts +++ b/src/Utils/StringUtils.test.ts @@ -26,5 +26,22 @@ describe("StringUtils", () => { const transformedString: string | undefined = StringUtils.stripSpacesFromString(""); expect(transformedString).toBe(""); }); + + it("should return the right number of characters regardless of bytes used per character", () => { + // Tried to use a sample of characters across the range for each of the individual byte lengths + const ascii = "!,n~!,n~!,n~"; + const twoByteCharacters = "Āā߿܀Āā߿܀Āā߿܀"; + const threeByteCharacters = "ࠀ倀ꀀࠀ倀ꀀࠀ倀ꀀ"; + const fourByteCharacters = "𐀀𐔀𐨀𐿶𐀀𐔀𐨀𐿶𐀀𐔀𐨀𐿶"; + // Used a random character generator for each of the different byte-lengths of characters for the mixed tests + const mixedByteSizes = "Yח䙶𫶾eԚ疿𱺿]߉ꗫ𢆤*ɉ貸𪡑"; + + expect(StringUtils.substringUtf(ascii, 0, 5)).toBe("!,n~!"); + expect(StringUtils.substringUtf(twoByteCharacters, 0, 5)).toBe("Āā߿܀Ā"); + expect(StringUtils.substringUtf(threeByteCharacters, 0, 5)).toBe("ࠀ倀ꀀࠀ"); + expect(StringUtils.substringUtf(fourByteCharacters, 0, 5)).toBe("𐀀𐔀𐨀𐿶𐀀"); + expect(StringUtils.substringUtf(mixedByteSizes, 0, 5)).toBe("Yח䙶𫶾e"); + expect(StringUtils.substringUtf(mixedByteSizes, 4, 4)).toBe("eԚ疿𱺿"); + }); }); }); diff --git a/src/Utils/StringUtils.ts b/src/Utils/StringUtils.ts index 02ceba5f2..3e0d68ba2 100644 --- a/src/Utils/StringUtils.ts +++ b/src/Utils/StringUtils.ts @@ -17,3 +17,58 @@ export function endsWith(stringToTest: string, suffix: string): boolean { export function startsWith(stringToTest: string, prefix: string): boolean { return stringToTest.indexOf(prefix) === 0; } + +/** + * Returns the input number of characters from a desired string but takes into account characters encoded with different byte sizes. + * @param text The text from which to return the subset + * @param startChar The starting character from @param text (zero-based) + * @param numChars The number of characters to return starting from @param startChar + * @returns The resulting slice of characters + */ +export const substringUtf = (text: string, startChar: number, numChars: number) => { + const encoded = new TextEncoder().encode(text); + + let currentChar = 0; + let currentByte = 0; + let startByte = 0; + for (; currentChar < startChar + numChars; ) { + if (currentChar === startChar) { + startByte = currentByte; + } + + /* + Unicode is utf encoded using 1, 2, 3, or 4 bytes + In a byte array, we know how many bytes the character is encoded based on the first byte because it + was developed such that the first byte's range never occurs in any other byte. Subsequent bytes are + always within 128 and 191. So in binary it breaks down like this: + 1 byte: 0xxxxxxx + 2 bytes: 110xxxxx 10xxxxxx + 3 bytes: 1110xxxx 10xxxxxx 10xxxxxx + 4 bytes: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx + */ + switch (true) { + // The originall ASCII set is between 0 (00000000) and 127 (01111111) and those only take up one byte + case encoded[currentByte] >= 0 && encoded[currentByte] <= 127: + currentByte++; + break; + // But if the first byte is within 192 (11000000) and 223 (11011111) then we know the character is two bytes: + case encoded[currentByte] >= 192 && encoded[currentByte] <= 223: + currentByte = currentByte + 2; + break; + // If the first byte is anything within 224 (11100000) and 239 (11101111) then the character is three bytes + case encoded[currentByte] >= 224 && encoded[currentByte] <= 239: + currentByte = currentByte + 3; + break; + // If the first byte is anything within 240 (11110000) and 247 (11110111) then the character is four bytes + case encoded[currentByte] >= 240 && encoded[currentByte] <= 247: + currentByte = currentByte + 4; + break; + // Anything past is an error for now + default: + throw new Error("Unrecognized character"); + } + currentChar++; + } + + return new TextDecoder().decode(encoded.slice(startByte, currentByte)); +}; From 784dadce305a4e87c3811a5d95d7ab7f50d85e5b Mon Sep 17 00:00:00 2001 From: BChoudhury-ms Date: Wed, 26 Nov 2025 23:36:45 +0530 Subject: [PATCH 5/9] set intra-account copy as the default one (#2267) --- src/Explorer/ContainerCopy/Context/CopyJobContext.tsx | 7 +++++-- .../Screens/AssignPermissions/PointInTimeRestore.tsx | 5 +++-- .../Screens/SelectAccount/Components/AccountDropdown.tsx | 1 + .../SelectAccount/Components/SubscriptionDropdown.tsx | 1 + .../CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx | 3 ++- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx index d89f839e2..9e7c7a20d 100644 --- a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx +++ b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx @@ -1,3 +1,4 @@ +import { Subscription } from "Contracts/DataModels"; import React from "react"; import { userContext } from "UserContext"; import { CopyJobMigrationType } from "../Enums/CopyJobEnums"; @@ -21,8 +22,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: "", }, diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx index 30c104ec2..eb6ed683e 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx @@ -84,9 +84,10 @@ const PointInTimeRestore: React.FC = () => { setShowRefreshButton(true); }; - const handleRefresh = () => { + const handleRefresh = async () => { setLoading(true); - handleFetchAccount(); + await handleFetchAccount(); + setLoading(false); }; const openWindowAndMonitor = () => { diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx index 423920b43..b24aed7b3 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx @@ -27,4 +27,5 @@ export const AccountDropdown: React.FC = React.memo( /> ), + (prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey, ); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx index 67b8e4f87..2627918a6 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/SubscriptionDropdown.tsx @@ -25,4 +25,5 @@ export const SubscriptionDropdown: React.FC = React.m /> ), + (prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey, ); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx index 2932e6229..17f323413 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx @@ -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(() => { handleSelectSourceAccount("account", option?.data)} /> From a33429fd85535091f0315ff51eb90c5448e14b5e Mon Sep 17 00:00:00 2001 From: asier-isayas Date: Wed, 26 Nov 2025 13:07:18 -0500 Subject: [PATCH 6/9] Add Session Id (#2263) * adding sessionId to UserContext * add session id * add session id to settings pane and fix npm run compile * Add conditional for Portal * set default session id on userContext init * fix tests --------- Co-authored-by: Asier Isayas --- package-lock.json | 88 ++++++++++++++++++- package.json | 5 +- src/Common/Constants.ts | 1 + src/Common/MongoProxyClient.ts | 1 + src/Contracts/ViewModels.ts | 1 + .../Panes/SettingsPane/SettingsPane.test.tsx | 6 ++ .../Panes/SettingsPane/SettingsPane.tsx | 7 ++ .../__snapshots__/SettingsPane.test.tsx.snap | 32 +++++++ src/Explorer/Tables/TableDataClient.ts | 11 +-- src/UserContext.ts | 3 + src/hooks/useKnockoutExplorer.ts | 2 + 11 files changed, 148 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 045e28f44..3969be269 100644 --- a/package-lock.json +++ b/package-lock.json @@ -116,6 +116,7 @@ "tinykeys": "2.1.0", "underscore": "1.12.1", "utility-types": "3.10.0", + "uuid": "9.0.0", "zustand": "3.5.0" }, "devDependencies": { @@ -626,6 +627,14 @@ } } }, + "node_modules/@azure/ms-rest-js/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@azure/ms-rest-js/node_modules/xml2js": { "version": "0.5.0", "license": "MIT", @@ -685,6 +694,14 @@ "node": ">=0.8.0" } }, + "node_modules/@azure/msal-node/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@babel/code-frame": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", @@ -7595,6 +7612,14 @@ "uuid": "^8.0.0" } }, + "node_modules/@nteract/commutable/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nteract/connected-components": { "version": "6.8.2", "license": "BSD-3-Clause", @@ -9125,6 +9150,14 @@ "uuid": "^8.0.0" } }, + "node_modules/@nteract/fixtures/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nteract/iron-icons": { "version": "1.0.0", "license": "BSD-3-Clause", @@ -9282,6 +9315,14 @@ "uuid": "^8.0.0" } }, + "node_modules/@nteract/messaging/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nteract/monaco-editor": { "version": "3.2.2", "license": "BSD-3-Clause", @@ -9397,6 +9438,14 @@ "version": "0.18.1", "license": "MIT" }, + "node_modules/@nteract/monaco-editor/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nteract/mythic-configuration": { "version": "1.0.12", "license": "BSD-3-Clause", @@ -9665,6 +9714,14 @@ "uuid": "^8.0.0" } }, + "node_modules/@nteract/reducers/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nteract/selectors": { "version": "3.2.0", "license": "BSD-3-Clause", @@ -9888,6 +9945,14 @@ "uuid": "^8.0.0" } }, + "node_modules/@nteract/types/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@octokit/auth-token": { "version": "4.0.0", "license": "MIT", @@ -26419,6 +26484,15 @@ "xmlbuilder": "^15.1.0" } }, + "node_modules/jest-trx-results-processor/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/jest-util": { "version": "24.9.0", "license": "MIT", @@ -33753,6 +33827,15 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/sockjs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/source-map": { "version": "0.5.7", "license": "BSD-3-Clause", @@ -35619,8 +35702,9 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "license": "MIT", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", "bin": { "uuid": "dist/bin/uuid" } diff --git a/package.json b/package.json index 451da877d..ad7fd5e19 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,8 @@ "@types/mkdirp": "1.0.1", "@types/node-fetch": "2.5.7", "@xmldom/xmldom": "0.7.13", - "@xterm/xterm": "5.5.0", "@xterm/addon-fit": "0.10.0", + "@xterm/xterm": "5.5.0", "allotment": "1.20.2", "applicationinsights": "1.8.0", "bootstrap": "3.4.1", @@ -111,6 +111,7 @@ "tinykeys": "2.1.0", "underscore": "1.12.1", "utility-types": "3.10.0", + "uuid": "9.0.0", "zustand": "3.5.0" }, "devDependencies": { @@ -248,4 +249,4 @@ "printWidth": 120, "endOfLine": "auto" } -} \ No newline at end of file +} diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 802bfd590..65b3f8d51 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -297,6 +297,7 @@ export class HttpHeaders { public static migrateOfferToManualThroughput: string = "x-ms-cosmos-migrate-offer-to-manual-throughput"; public static migrateOfferToAutopilot: string = "x-ms-cosmos-migrate-offer-to-autopilot"; public static xAPIKey: string = "X-API-Key"; + public static sessionId: string = "x-ms-client-session-id"; } export class ContentType { diff --git a/src/Common/MongoProxyClient.ts b/src/Common/MongoProxyClient.ts index 298b9268d..48eef3a48 100644 --- a/src/Common/MongoProxyClient.ts +++ b/src/Common/MongoProxyClient.ts @@ -17,6 +17,7 @@ const defaultHeaders = { [HttpHeaders.apiType]: ApiType.MongoDB.toString(), [CosmosSDKConstants.HttpHeaders.MaxEntityCount]: "100", [CosmosSDKConstants.HttpHeaders.Version]: "2017-11-15", + [HttpHeaders.sessionId]: userContext.sessionId, }; function authHeaders() { diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index 1ea0f2075..99ff76f9d 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -446,6 +446,7 @@ export interface DataExplorerInputsFrame { feedbackPolicies?: any; aadToken?: string; containerCopyEnabled?: boolean; + sessionId?: string; } export interface SelfServeFrameInputs { diff --git a/src/Explorer/Panes/SettingsPane/SettingsPane.test.tsx b/src/Explorer/Panes/SettingsPane/SettingsPane.test.tsx index 25b0167f5..f3b72870b 100644 --- a/src/Explorer/Panes/SettingsPane/SettingsPane.test.tsx +++ b/src/Explorer/Panes/SettingsPane/SettingsPane.test.tsx @@ -5,6 +5,12 @@ import { updateUserContext } from "../../../UserContext"; import { SettingsPane } from "./SettingsPane"; describe("Settings Pane", () => { + beforeEach(() => { + updateUserContext({ + sessionId: "1234-5678", + }); + }); + it("should render Default properly", () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); diff --git a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx index 2e89418d8..bbf4c1438 100644 --- a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx +++ b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx @@ -212,6 +212,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ const styles = useStyles(); const explorerVersion = configContext.gitSha; + const sessionId: string = userContext.sessionId; const isEmulator = configContext.platform === Platform.Emulator; const shouldShowQueryPageOptions = userContext.apiType === "SQL"; const showRetrySettings = @@ -1227,6 +1228,12 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
{explorerVersion}
+
+
+
Session ID
+
{sessionId}
+
+
); diff --git a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap index 022cc8647..ca0243ec7 100644 --- a/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap +++ b/src/Explorer/Panes/SettingsPane/__snapshots__/SettingsPane.test.tsx.snap @@ -649,6 +649,22 @@ exports[`Settings Pane should render Default properly 1`] = `
+
+
+
+ Session ID +
+
+ 1234-5678 +
+
+
`; @@ -958,6 +974,22 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
+
+
+
+ Session ID +
+
+ 1234-5678 +
+
+
`; diff --git a/src/Explorer/Tables/TableDataClient.ts b/src/Explorer/Tables/TableDataClient.ts index ff2d9a5ea..631e19e88 100644 --- a/src/Explorer/Tables/TableDataClient.ts +++ b/src/Explorer/Tables/TableDataClient.ts @@ -286,7 +286,7 @@ export class CassandraAPIDataClient extends TableDataClient { query, paginationToken, }), - beforeSend: this.setAuthorizationHeader as any, + beforeSend: this.setCommonHeaders as any, cache: false, }); shouldNotify && @@ -440,7 +440,7 @@ export class CassandraAPIDataClient extends TableDataClient { keyspaceId: collection.databaseId, tableId: collection.id(), }), - beforeSend: this.setAuthorizationHeader as any, + beforeSend: this.setCommonHeaders as any, cache: false, }) .then( @@ -482,7 +482,7 @@ export class CassandraAPIDataClient extends TableDataClient { keyspaceId: collection.databaseId, tableId: collection.id(), }), - beforeSend: this.setAuthorizationHeader as any, + beforeSend: this.setCommonHeaders as any, cache: false, }) .then( @@ -518,7 +518,7 @@ export class CassandraAPIDataClient extends TableDataClient { resourceId: resourceId, query: query, }), - beforeSend: this.setAuthorizationHeader as any, + beforeSend: this.setCommonHeaders as any, cache: false, }).then( (data: any) => { @@ -547,7 +547,7 @@ export class CassandraAPIDataClient extends TableDataClient { return cassandraEndpoint; } - private setAuthorizationHeader: (xhr: XMLHttpRequest) => boolean = (xhr: XMLHttpRequest): boolean => { + private setCommonHeaders: (xhr: XMLHttpRequest) => boolean = (xhr: XMLHttpRequest): boolean => { const authorizationHeaderMetadata: ViewModels.AuthorizationTokenHeaderMetadata = getAuthorizationHeader(); xhr.setRequestHeader(authorizationHeaderMetadata.header, authorizationHeaderMetadata.token); @@ -555,6 +555,7 @@ export class CassandraAPIDataClient extends TableDataClient { xhr.setRequestHeader(Constants.HttpHeaders.entraIdToken, userContext.aadToken); } + xhr.setRequestHeader(Constants.HttpHeaders.sessionId, userContext.sessionId); return true; }; diff --git a/src/UserContext.ts b/src/UserContext.ts index ecc4f5807..a495b9971 100644 --- a/src/UserContext.ts +++ b/src/UserContext.ts @@ -4,6 +4,7 @@ import { Action } from "Shared/Telemetry/TelemetryConstants"; import { traceOpen } from "Shared/Telemetry/TelemetryProcessor"; import { useCarousel } from "hooks/useCarousel"; import { usePostgres } from "hooks/usePostgres"; +import { v4 as uuidv4 } from "uuid"; import { AuthType } from "./AuthType"; import { DatabaseAccount } from "./Contracts/DataModels"; import { SubscriptionType } from "./Contracts/SubscriptionType"; @@ -118,6 +119,7 @@ export interface UserContext { readonly dataPlaneRbacEnabled?: boolean; readonly refreshCosmosClient?: boolean; throughputBucketsEnabled?: boolean; + readonly sessionId: string; } export type ApiType = "SQL" | "Mongo" | "Gremlin" | "Tables" | "Cassandra" | "Postgres" | "VCoreMongo"; @@ -135,6 +137,7 @@ const userContext: UserContext = { features, subscriptionType: CollectionCreation.DefaultSubscriptionType, collectionCreationDefaults: CollectionCreationDefaults, + sessionId: uuidv4(), // Default sessionId - will be overwritten if provided by host }; export function isAccountNewerThanThresholdInMs(createdAt: string, threshold: number) { diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts index 3e5546f80..b2aa588be 100644 --- a/src/hooks/useKnockoutExplorer.ts +++ b/src/hooks/useKnockoutExplorer.ts @@ -85,6 +85,7 @@ export function useKnockoutExplorer(platform: Platform): Explorer { userContext.features.phoenixNotebooks = true; userContext.features.phoenixFeatures = true; } + let explorer: Explorer; if (platform === Platform.Hosted) { explorer = await configureHosted(); @@ -927,6 +928,7 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) { collectionCreationDefaults: inputs.defaultCollectionThroughput, isTryCosmosDBSubscription: inputs.isTryCosmosDBSubscription, feedbackPolicies: inputs.feedbackPolicies, + ...(inputs.sessionId && { sessionId: inputs.sessionId }), // Remove conditional once Portal sends sessionId }); if (inputs.isPostgresAccount) { From bb0bbd8a6e074caff15b2cf1f9807688709bbf6d Mon Sep 17 00:00:00 2001 From: BChoudhury-ms Date: Thu, 27 Nov 2025 10:34:08 +0530 Subject: [PATCH 7/9] show default copy job name (#2266) --- src/Explorer/ContainerCopy/CopyJobUtils.ts | 39 ++++++++++++++++++- .../Screens/PreviewCopyJob/PreviewCopyJob.tsx | 10 ++++- .../Utils/useCreateCopyJobScreensList.tsx | 2 +- .../ContainerCopy/containerCopyStyles.less | 5 ++- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/Explorer/ContainerCopy/CopyJobUtils.ts b/src/Explorer/ContainerCopy/CopyJobUtils.ts index 1a9e46aad..7f97367db 100644 --- a/src/Explorer/ContainerCopy/CopyJobUtils.ts +++ b/src/Explorer/ContainerCopy/CopyJobUtils.ts @@ -1,5 +1,5 @@ import { DatabaseAccount } from "Contracts/DataModels"; -import { CopyJobErrorType } from "./Types/CopyJobTypes"; +import { CopyJobErrorType, CopyJobType } from "./Types/CopyJobTypes"; const azurePortalMpacEndpoint = "https://ms.portal.azure.com/"; @@ -124,3 +124,40 @@ 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 ""; +} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx index c270ccdf5..050d696e9 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx @@ -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) => { diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx index 1b0c74f05..475cca684 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx @@ -56,7 +56,7 @@ function useCreateCopyJobScreensList() { 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", }, ], diff --git a/src/Explorer/ContainerCopy/containerCopyStyles.less b/src/Explorer/ContainerCopy/containerCopyStyles.less index 25ebb0311..73cb3052f 100644 --- a/src/Explorer/ContainerCopy/containerCopyStyles.less +++ b/src/Explorer/ContainerCopy/containerCopyStyles.less @@ -118,8 +118,9 @@ .jobNameLink { color: @LinkColor; - text-decoration: underline; - cursor: pointer; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; } } } From 63cddeb4b87f7b41acb116ffc75c4c3d29f91e4a Mon Sep 17 00:00:00 2001 From: BChoudhury-ms Date: Thu, 27 Nov 2025 13:19:50 +0530 Subject: [PATCH 8/9] Integrate container creation screen to copy job flow (#2265) --- .../ContainerCopy/Actions/CopyJobActions.tsx | 5 +- .../CommandBar/CopyJobCommandBar.tsx | 4 +- .../ContainerCopy/CommandBar/Utils.ts | 10 ++-- .../ContainerCopy/ContainerCopyMessages.ts | 3 ++ .../ContainerCopy/ContainerCopyPanel.tsx | 6 +-- .../ContainerCopy/Context/CopyJobContext.tsx | 3 ++ .../AddCollectionPanelWrapper.tsx | 53 +++++++++++++++++++ .../Screens/CreateCopyJobScreens.tsx | 3 +- .../Screens/CreateCopyJobScreensProvider.tsx | 5 +- .../SelectSourceAndTargetContainers.tsx | 7 ++- .../components/DatabaseContainerSection.tsx | 28 ++++++---- .../Utils/useCopyJobNavigation.ts | 34 +++++++++--- .../Utils/useCreateCopyJobScreensList.tsx | 14 ++++- .../Components/CopyJobs.NotFound.tsx | 16 ++++-- .../MonitorCopyJobs/MonitorCopyJobs.tsx | 15 +++--- .../ContainerCopy/Types/CopyJobTypes.ts | 4 +- .../ContainerCopy/containerCopyStyles.less | 14 +++++ .../AddCollectionPanel/AddCollectionPanel.tsx | 15 ++++-- src/Main.tsx | 2 +- src/hooks/useSidePanel.ts | 2 + 20 files changed, 193 insertions(+), 50 deletions(-) create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx diff --git a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx index 14198b700..2208a6ac9 100644 --- a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx +++ b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx @@ -1,3 +1,4 @@ +import Explorer from "Explorer/Explorer"; import React from "react"; import { userContext } from "UserContext"; import { logError } from "../../../Common/Logger"; @@ -29,12 +30,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, - , + , "650px", ); }; diff --git a/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx index f7d981484..9f163613d 100644 --- a/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx +++ b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx @@ -13,8 +13,8 @@ const rootStyle = { }, }; -const CopyJobCommandBar: React.FC = ({ container }) => { - const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(container); +const CopyJobCommandBar: React.FC = ({ explorer }) => { + const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer); const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor); return ( diff --git a/src/Explorer/ContainerCopy/CommandBar/Utils.ts b/src/Explorer/ContainerCopy/CommandBar/Utils.ts index a4d4b9d8b..a1472793b 100644 --- a/src/Explorer/ContainerCopy/CommandBar/Utils.ts +++ b/src/Explorer/ContainerCopy/CommandBar/Utils.ts @@ -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); } diff --git a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts index 0be496783..69ed69967 100644 --- a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts +++ b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts @@ -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", diff --git a/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx b/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx index 026fb7f09..2d7cccb87 100644 --- a/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx +++ b/src/Explorer/ContainerCopy/ContainerCopyPanel.tsx @@ -5,7 +5,7 @@ import { MonitorCopyJobsRefState } from "./MonitorCopyJobs/MonitorCopyJobRefStat import MonitorCopyJobs, { MonitorCopyJobsRef } from "./MonitorCopyJobs/MonitorCopyJobs"; import { ContainerCopyProps } from "./Types/CopyJobTypes"; -const ContainerCopyPanel: React.FC = ({ container }) => { +const ContainerCopyPanel: React.FC = ({ explorer }) => { const monitorCopyJobsRef = React.useRef(); useEffect(() => { if (monitorCopyJobsRef.current) { @@ -14,8 +14,8 @@ const ContainerCopyPanel: React.FC = ({ container }) => { }, [monitorCopyJobsRef.current]); return (
- - + +
); }; diff --git a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx index 9e7c7a20d..dab4bd3c0 100644 --- a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx +++ b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx @@ -1,3 +1,4 @@ +import Explorer from "Explorer/Explorer"; import { Subscription } from "Contracts/DataModels"; import React from "react"; import { userContext } from "UserContext"; @@ -15,6 +16,7 @@ export const useCopyJobContext = (): CopyJobContextProviderType => { interface CopyJobContextProviderProps { children: React.ReactNode; + explorer: Explorer; } const getInitialCopyJobState = (): CopyJobContextState => { @@ -56,6 +58,7 @@ const CopyJobContextProvider: React.FC = (props) => flow, setFlow, resetCopyJobState, + explorer: props.explorer, }; return {props.children}; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx new file mode 100644 index 000000000..01a2db73f --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx @@ -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 = ({ 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 ( + + + {ContainerCopyMessages.createNewContainerSubHeading} + + + + + + ); +}; + +export default AddCollectionPanelWrapper; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx index 1ee9a89be..55368cbf6 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateCopyJobScreens.tsx @@ -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} )} - {currentScreen?.component} + {React.cloneElement(currentScreen?.component as React.ReactElement, { showAddCollectionPanel })}
{ +const CreateCopyJobScreensProvider = ({ explorer }: { explorer: Explorer }) => { return ( - + ); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx index fb42c3a69..8bfc76167 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx @@ -9,7 +9,11 @@ import { DatabaseContainerSection } from "./components/DatabaseContainerSection" import { dropDownChangeHandler } from "./Events/DropDownChangeHandler"; import { useMemoizedSourceAndTargetData } 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); @@ -62,6 +66,7 @@ const SelectSourceAndTargetContainers = () => { selectedContainer={target?.containerId} containerDisabled={!target?.databaseId} containerOnChange={onDropdownChange("targetContainer")} + handleOnDemandCreateContainer={showAddCollectionPanel} />
); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx index 1ecfaa4e9..2edac6ce8 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/components/DatabaseContainerSection.tsx @@ -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) => ( @@ -29,15 +30,22 @@ export const DatabaseContainerSection = ({ /> - + + + {handleOnDemandCreateContainer && ( + handleOnDemandCreateContainer()}> + {ContainerCopyMessages.createContainerButtonLabel} + + )} + ); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts index ea313ae12..b7b543909 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts @@ -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"; @@ -107,7 +113,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 +157,6 @@ export function useCopyJobNavigation() { } }, [currentScreenKey, copyJobState, areContainersIdentical, handleCopyJobSubmission]); - const handlePrevious = useCallback(() => { - dispatch({ type: "PREVIOUS" }); - }, []); - return { currentScreen, isPrimaryDisabled, @@ -143,6 +164,7 @@ export function useCopyJobNavigation() { handlePrimary, handlePrevious, handleCancel, + showAddCollectionPanel, primaryBtnText, }; } diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx index 475cca684..acb17f602 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx @@ -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( () => [ { @@ -50,6 +55,11 @@ function useCreateCopyJobScreensList() { }, ], }, + { + key: SCREEN_KEYS.CreateCollection, + component: , + validations: [], + }, { key: SCREEN_KEYS.PreviewCopyJob, component: , @@ -80,7 +90,7 @@ function useCreateCopyJobScreensList() { ], }, ], - [], + [explorer], ); } diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx index d7f32d3e8..1b8ad0436 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobs.NotFound.tsx @@ -1,18 +1,24 @@ import { ActionButton, Image } from "@fluentui/react"; -import React, { useCallback } from "react"; +import Explorer from "Explorer/Explorer"; +import React 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 = () => { - const handleCreateCopyJob = useCallback(Actions.openCreateCopyJobPanel, []); +const CopyJobsNotFound: React.FC = ({ explorer }) => { return (
{ContainerCopyMessages.noCopyJobsTitle}

{ContainerCopyMessages.noCopyJobsTitle}

- + {ContainerCopyMessages.createCopyJobButtonText}
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx index cb4d0fea8..7278bc26c 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx @@ -1,6 +1,7 @@ /* 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"; @@ -11,13 +12,15 @@ import CopyJobsList from "./Components/CopyJobsList"; const FETCH_INTERVAL_MS = 30 * 1000; -interface MonitorCopyJobsProps {} +interface MonitorCopyJobsProps { + explorer: Explorer; +} export interface MonitorCopyJobsRef { refreshJobList: () => void; } -const MonitorCopyJobs = forwardRef((_props, ref) => { +const MonitorCopyJobs = forwardRef(({ explorer }, ref) => { const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [jobs, setJobs] = React.useState([]); @@ -96,15 +99,15 @@ const MonitorCopyJobs = forwardRef((_p [], ); - const memoizedJobsList = React.useMemo(() => { + const renderJobsList = () => { if (loading) { return null; } if (jobs.length > 0) { return ; } - return ; - }, [jobs, loading, handleActionClick]); + return ; + }; return ( @@ -114,7 +117,7 @@ const MonitorCopyJobs = forwardRef((_p {error} )} - {memoizedJobsList} + {renderJobsList()} ); }); diff --git a/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts b/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts index 30f6ee2e4..e9ebbd0da 100644 --- a/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts +++ b/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts @@ -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, option: DropdownOptionType) => void; + handleOnDemandCreateContainer?: () => void; } export interface CopyJobContextState { @@ -80,6 +81,7 @@ export interface CopyJobContextProviderType { copyJobState: CopyJobContextState | null; setCopyJobState: React.Dispatch>; resetCopyJobState: () => void; + explorer?: Explorer; } export type CopyJobType = { diff --git a/src/Explorer/ContainerCopy/containerCopyStyles.less b/src/Explorer/ContainerCopy/containerCopyStyles.less index 73cb3052f..e7901efbc 100644 --- a/src/Explorer/ContainerCopy/containerCopyStyles.less +++ b/src/Explorer/ContainerCopy/containerCopyStyles.less @@ -75,6 +75,20 @@ .createCopyJobErrorMessageBar { margin-bottom: 2em; } + .create-container-link-btn { + padding: 0; + height: 25px; + color: @LinkColor; + + &:focus { + outline: none; + } + } + + /* Create collection panel */ + .panelFormWrapper .panelMainContent { + padding: 0; + } } .monitorCopyJobs { diff --git a/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx index 5ab433785..58d7037b6 100644 --- a/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx +++ b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx @@ -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 - + {!this.props.isCopyJobFlow && ( + + )} {this.state.isExecuting && (
@@ -1415,8 +1419,13 @@ export class AddCollectionPanel extends React.Component {
{userContext.features.enableContainerCopy && userContext.apiType === "SQL" ? ( - + ) : ( )} diff --git a/src/hooks/useSidePanel.ts b/src/hooks/useSidePanel.ts index 25b87f346..1e883a8b3 100644 --- a/src/hooks/useSidePanel.ts +++ b/src/hooks/useSidePanel.ts @@ -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 = 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 })), From 9a6f0903744093b1071dded4a5103ed5387733ba Mon Sep 17 00:00:00 2001 From: BChoudhury-ms Date: Wed, 3 Dec 2025 07:43:13 +0530 Subject: [PATCH 9/9] Refactor Container Copy Permissions Screen: Group-based Validation and Improved Loading UX (#2269) * grouped permissions and added styles * Adding loading overlay for the permission sections --- src/Common/LoadingOverlay.tsx | 31 ++++ .../ContainerCopy/ContainerCopyMessages.ts | 9 ++ src/Explorer/ContainerCopy/CopyJobUtils.ts | 10 +- .../AssignPermissions/AssignPermissions.tsx | 87 +++++++++--- .../AssignPermissions/OnlineCopyEnabled.tsx | 4 +- .../AssignPermissions/PointInTimeRestore.tsx | 2 + .../hooks/usePermissionsSection.tsx | 133 ++++++++++++------ .../Screens/Components/PopoverContainer.tsx | 10 +- .../Utils/useCopyJobNavigation.ts | 8 +- .../ContainerCopy/containerCopyStyles.less | 11 +- 10 files changed, 228 insertions(+), 77 deletions(-) create mode 100644 src/Common/LoadingOverlay.tsx diff --git a/src/Common/LoadingOverlay.tsx b/src/Common/LoadingOverlay.tsx new file mode 100644 index 000000000..320576533 --- /dev/null +++ b/src/Common/LoadingOverlay.tsx @@ -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 = ({ isLoading, label }) => { + if (!isLoading) { + return null; + } + + return ( + + + + ); +}; + +export default LoadingOverlay; diff --git a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts index 69ed69967..526b6ffab 100644 --- a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts +++ b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts @@ -55,11 +55,20 @@ export default { "To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.", intraAccountOnlineDescription: (accountName: string) => `Follow the steps below to enable online copy on your "${accountName}" account.`, + commonConfiguration: { + title: "Common configuration", + description: "Basic permissions required for copy operations", + }, + onlineConfiguration: { + title: "Online copy configuration", + description: "Additional permissions required for online copy operations", + }, }, toggleBtn: { onText: "On", offText: "Off", }, + popoverOverlaySpinnerLabel: "Please wait while we process your request...", addManagedIdentity: { title: "System-assigned managed identity enabled.", description: diff --git a/src/Explorer/ContainerCopy/CopyJobUtils.ts b/src/Explorer/ContainerCopy/CopyJobUtils.ts index 7f97367db..75cc4acd2 100644 --- a/src/Explorer/ContainerCopy/CopyJobUtils.ts +++ b/src/Explorer/ContainerCopy/CopyJobUtils.ts @@ -1,5 +1,5 @@ import { DatabaseAccount } from "Contracts/DataModels"; -import { CopyJobErrorType, CopyJobType } 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); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx index 1f3861753..7b1f96241 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx @@ -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 = ({ id, title, Component, completed, disabled }) => ( @@ -30,43 +31,91 @@ const PermissionSection: React.FC = ({ id, title, Compo ); -const AssignPermissions = () => { - const { copyJobState } = useCopyJobContext(); - const permissionSections = usePermissionSections(copyJobState); +const PermissionGroup: React.FC = ({ title, description, sections }) => { const [openItems, setOpenItems] = React.useState([]); + useEffect(() => { + const firstIncompleteSection = sections.find((section) => !section.completed); + const nextOpenItems = firstIncompleteSection ? [firstIncompleteSection.id] : []; + if (JSON.stringify(openItems) !== JSON.stringify(nextOpenItems)) { + setOpenItems(nextOpenItems); + } + }, [sections]); + + return ( + + + + {title} + + {description && ( + + {description} + + )} + + + + {sections.map((section) => ( + + ))} + + + ); +}; + +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( () => 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()); + }; + }, []); return ( - - + + {isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online ? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription( copyJobState?.source?.account?.name || "", ) : ContainerCopyMessages.assignPermissions.crossAccountDescription} - - {permissionSections?.length === 0 ? ( + + + {totalSectionsCount === 0 ? ( ) : ( - - {permissionSections.map((section) => ( - + + {permissionGroups.map((group) => ( + ))} - + )} ); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx index 9d9279e1a..1c1d6bfd5 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/OnlineCopyEnabled.tsx @@ -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"; @@ -119,6 +120,7 @@ const OnlineCopyEnabled: React.FC = () => { return ( + {ContainerCopyMessages.onlineCopyEnabled.description(source?.account?.name || "")}  diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx index eb6ed683e..f62331677 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx @@ -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"; @@ -109,6 +110,7 @@ const PointInTimeRestore: React.FC = () => { return ( + {ContainerCopyMessages.pointInTimeRestore.description(source.account?.name ?? "")} {tooltipContent && ( diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx index 01687101c..8ee6d8355 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.tsx @@ -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; } +export interface PermissionGroupConfig { + id: string; + title: string; + description: string; + sections: PermissionSectionConfig[]; +} + export const SECTION_IDS = { addManagedIdentity: "addManagedIdentity", defaultManagedIdentity: "defaultManagedIdentity", @@ -127,26 +134,81 @@ 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, +): Promise => { + 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(null); + const [permissionGroups, setPermissionGroups] = useState(null); const isValidatingRef = useRef(false); - const sectionToValidate = useMemo(() => { - const isSameAccount = isIntraAccountCopy(sourceAccountId, targetAccountId); + const groupsToValidate = useMemo(() => { + const isSameAccount = isIntraAccountCopy(sourceAccount.accountId, targetAccount.accountId); + const commonSections = isSameAccount ? [] : [...PERMISSION_SECTIONS_CONFIG]; + const groups: PermissionGroupConfig[] = []; - const baseSections = isSameAccount ? [] : [...PERMISSION_SECTIONS_CONFIG]; - if (state.migrationType === CopyJobMigrationType.Online) { - return [...baseSections, ...PERMISSION_SECTIONS_FOR_ONLINE_JOBS]; + if (commonSections.length > 0) { + groups.push({ + id: "commonConfigs", + title: ContainerCopyMessages.assignPermissions.commonConfiguration.title, + description: ContainerCopyMessages.assignPermissions.commonConfiguration.description, + sections: commonSections, + }); } - 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, + sections: [...PERMISSION_SECTIONS_FOR_ONLINE_JOBS], + }); + } + + return groups; + }, [sourceAccount.accountId, targetAccount.accountId, state.migrationType]); const memoizedValidationCache = useMemo(() => { if (state.migrationType === CopyJobMigrationType.Offline) { @@ -157,52 +219,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; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx index eec4f5402..5a76d66eb 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx @@ -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 = React.memo( tokens={{ childrenGap: 20 }} style={{ maxWidth: 450 }} > + {title} {children} - + diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts index b7b543909..dd8059547 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.ts @@ -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"; @@ -71,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); diff --git a/src/Explorer/ContainerCopy/containerCopyStyles.less b/src/Explorer/ContainerCopy/containerCopyStyles.less index e7901efbc..05d9facec 100644 --- a/src/Explorer/ContainerCopy/containerCopyStyles.less +++ b/src/Explorer/ContainerCopy/containerCopyStyles.less @@ -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%); @@ -89,6 +94,10 @@ .panelFormWrapper .panelMainContent { padding: 0; } + + .createCopyJobScreensFooter { + margin-top: 50px; + } } .monitorCopyJobs {