grouped permissions and added styles

This commit is contained in:
Bikram Choudhury
2025-11-17 16:37:47 +05:30
parent 5a0f016cdf
commit b1b72cd293
7 changed files with 207 additions and 77 deletions

View File

@@ -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.", "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) => intraAccountOnlineDescription: (accountName: string) =>
`Follow the steps below to enable online copy on your "${accountName}" account.`, `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: { toggleBtn: {
onText: "On", onText: "On",
offText: "Off", offText: "Off",
}, },
popoverOverlaySpinnerLabel: "Please wait while we process your request...",
addManagedIdentity: { addManagedIdentity: {
title: "System-assigned managed identity enabled.", title: "System-assigned managed identity enabled.",
description: description:

View File

@@ -1,5 +1,5 @@
import { DatabaseAccount } from "Contracts/DataModels"; 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/"; const azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
@@ -115,6 +115,14 @@ export function getAccountDetailsFromResourceId(accountId: string | undefined) {
return { subscriptionId, resourceGroup, accountName }; 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 { export function isIntraAccountCopy(sourceAccountId: string | undefined, targetAccountId: string | undefined): boolean {
const sourceAccountDetails = getAccountDetailsFromResourceId(sourceAccountId); const sourceAccountDetails = getAccountDetailsFromResourceId(sourceAccountId);
const targetAccountDetails = getAccountDetailsFromResourceId(targetAccountId); const targetAccountDetails = getAccountDetailsFromResourceId(targetAccountId);

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
/* eslint-disable react/prop-types */ /* eslint-disable react/prop-types */
/* eslint-disable react/display-name */ /* eslint-disable react/display-name */
import { DefaultButton, PrimaryButton, Stack, Text } from "@fluentui/react"; import { DefaultButton, Overlay, PrimaryButton, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
import ContainerCopyMessages from "Explorer/ContainerCopy/ContainerCopyMessages";
import React from "react"; import React from "react";
interface PopoverContainerProps { interface PopoverContainerProps {
@@ -19,17 +20,31 @@ const PopoverContainer: React.FC<PopoverContainerProps> = React.memo(
tokens={{ childrenGap: 20 }} tokens={{ childrenGap: 20 }}
style={{ maxWidth: 450 }} style={{ maxWidth: 450 }}
> >
{isLoading && (
<Overlay
styles={{
root: {
backgroundColor: "rgba(255,255,255,0.9)",
zIndex: 9999,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
}}
>
<Spinner
size={SpinnerSize.large}
label={ContainerCopyMessages.popoverOverlaySpinnerLabel}
styles={{ label: { fontWeight: 600 } }}
/>
</Overlay>
)}
<Text variant="mediumPlus" style={{ fontWeight: 600 }}> <Text variant="mediumPlus" style={{ fontWeight: 600 }}>
{title} {title}
</Text> </Text>
<Text>{children}</Text> <Text>{children}</Text>
<Stack horizontal tokens={{ childrenGap: 20 }}> <Stack horizontal tokens={{ childrenGap: 20 }}>
<PrimaryButton <PrimaryButton text={"Yes"} onClick={onPrimary} disabled={isLoading} />
text={isLoading ? "" : "Yes"}
{...(isLoading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
onClick={onPrimary}
disabled={isLoading}
/>
<DefaultButton text="No" onClick={onCancel} disabled={isLoading} /> <DefaultButton text="No" onClick={onCancel} disabled={isLoading} />
</Stack> </Stack>
</Stack> </Stack>

View File

@@ -2,7 +2,7 @@ import { useCallback, useMemo, useReducer, useState } from "react";
import { useSidePanel } from "../../../../hooks/useSidePanel"; import { useSidePanel } from "../../../../hooks/useSidePanel";
import { submitCreateCopyJob } from "../../Actions/CopyJobActions"; import { submitCreateCopyJob } from "../../Actions/CopyJobActions";
import { useCopyJobContext } from "../../Context/CopyJobContext"; import { useCopyJobContext } from "../../Context/CopyJobContext";
import { isIntraAccountCopy } from "../../CopyJobUtils"; import { getContainerIdentifiers, isIntraAccountCopy } from "../../CopyJobUtils";
import { CopyJobMigrationType } from "../../Enums/CopyJobEnums"; import { CopyJobMigrationType } from "../../Enums/CopyJobEnums";
import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache"; import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache";
import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList"; import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList";
@@ -71,12 +71,6 @@ export function useCopyJobNavigation() {
useSidePanel.getState().closeSidePanel(); 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 areContainersIdentical = () => {
const { source, target } = copyJobState; const { source, target } = copyJobState;
const sourceIds = getContainerIdentifiers(source); const sourceIds = getContainerIdentifiers(source);

View File

@@ -59,6 +59,7 @@
} }
} }
.popover-container { .popover-container {
border-radius: 6px;
button[disabled] { button[disabled] {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.8; opacity: 0.8;
@@ -66,7 +67,7 @@
} }
.foreground { .foreground {
z-index: 10; z-index: 10;
background-color: white; background-color: #f9f9f9;
padding: 20px; padding: 20px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transform: translate(0%, -9%); transform: translate(0%, -9%);
@@ -89,6 +90,10 @@
.panelFormWrapper .panelMainContent { .panelFormWrapper .panelMainContent {
padding: 0; padding: 0;
} }
.createCopyJobScreensFooter {
margin-top: 50px;
}
} }
.monitorCopyJobs { .monitorCopyJobs {