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
This commit is contained in:
BChoudhury-ms
2025-12-03 07:43:13 +05:30
committed by GitHub
parent 63cddeb4b8
commit 9a6f090374
10 changed files with 228 additions and 77 deletions

View File

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

View File

@@ -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,91 @@ const PermissionSection: React.FC<PermissionSectionConfig> = ({ id, title, Compo
</AccordionItem> </AccordionItem>
); );
const AssignPermissions = () => { const PermissionGroup: React.FC<PermissionGroupConfig> = ({ 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">
{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

@@ -1,8 +1,9 @@
import { Link, PrimaryButton, Stack } from "@fluentui/react"; import { Link, PrimaryButton, Stack } from "@fluentui/react";
import { CapabilityNames } from "Common/Constants";
import { DatabaseAccount } from "Contracts/DataModels"; import { DatabaseAccount } from "Contracts/DataModels";
import React from "react"; import React from "react";
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils"; import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
import { CapabilityNames } from "../../../../../Common/Constants";
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
import { logError } from "../../../../../Common/Logger"; import { logError } from "../../../../../Common/Logger";
import { update as updateDatabaseAccount } from "../../../../../Utils/arm/generatedClients/cosmos/databaseAccounts"; import { update as updateDatabaseAccount } from "../../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
import ContainerCopyMessages from "../../../ContainerCopyMessages"; import ContainerCopyMessages from "../../../ContainerCopyMessages";
@@ -119,6 +120,7 @@ const OnlineCopyEnabled: React.FC = () => {
return ( return (
<Stack className="onlineCopyContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}> <Stack className="onlineCopyContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<LoadingOverlay isLoading={loading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
<Stack.Item className="info-message"> <Stack.Item className="info-message">
{ContainerCopyMessages.onlineCopyEnabled.description(source?.account?.name || "")}&ensp; {ContainerCopyMessages.onlineCopyEnabled.description(source?.account?.name || "")}&ensp;
<Link href={ContainerCopyMessages.onlineCopyEnabled.href} target="_blank" rel="noopener noreferrer"> <Link href={ContainerCopyMessages.onlineCopyEnabled.href} target="_blank" rel="noopener noreferrer">

View File

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

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

@@ -2,6 +2,8 @@
/* eslint-disable react/display-name */ /* eslint-disable react/display-name */
import { DefaultButton, PrimaryButton, Stack, Text } from "@fluentui/react"; import { DefaultButton, PrimaryButton, Stack, Text } from "@fluentui/react";
import React from "react"; import React from "react";
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
interface PopoverContainerProps { interface PopoverContainerProps {
isLoading?: boolean; isLoading?: boolean;
@@ -19,17 +21,13 @@ const PopoverContainer: React.FC<PopoverContainerProps> = React.memo(
tokens={{ childrenGap: 20 }} tokens={{ childrenGap: 20 }}
style={{ maxWidth: 450 }} style={{ maxWidth: 450 }}
> >
<LoadingOverlay isLoading={isLoading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
<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

@@ -19,6 +19,10 @@
.createCopyJobScreensContainer { .createCopyJobScreensContainer {
height: 100%; height: 100%;
padding: 1em 1.5em; padding: 1em 1.5em;
.pointInTimeRestoreContainer, .onlineCopyContainer {
position: relative;
}
label { label {
padding: 0; padding: 0;
@@ -59,6 +63,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 +71,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 +94,10 @@
.panelFormWrapper .panelMainContent { .panelFormWrapper .panelMainContent {
padding: 0; padding: 0;
} }
.createCopyJobScreensFooter {
margin-top: 50px;
}
} }
.monitorCopyJobs { .monitorCopyJobs {