Compare commits

..

12 Commits

Author SHA1 Message Date
nishthaAhujaa
bfa68a9b00 fixed ui for jobList 2025-10-29 19:51:22 +05:30
Bikram Choudhury
844b6e6b65 show copyjob screen from portal navigation 2025-10-29 18:37:48 +05:30
Bikram Choudhury
58e187aeb2 Fix lint & typescript checks 2025-10-28 18:05:41 +05:30
Bikram Choudhury
5ba7ce2f10 fetch account details from account id instead of context 2025-10-28 16:58:48 +05:30
Bikram Choudhury
e002a4505c remove arm token dependency 2025-10-28 16:58:38 +05:30
Bikram Choudhury
6483bd146d added copy job list refresh and reset functionality 2025-10-23 18:38:10 +05:30
Bikram Choudhury
7b437b62ce Added monitor copy job list screen 2025-10-23 16:54:11 +05:30
Bikram Choudhury
c504d97f7c added copyjob pre-requsite screen along with it's validations 2025-10-21 18:54:00 +05:30
Bikram Choudhury
a23a7791d4 Added hooks to evaluate reader role access 2025-10-15 18:40:19 +05:30
Bikram Choudhury
9bfb6aecc9 Added Copy Job prerequisites screen 2025-10-15 12:23:22 +05:30
Bikram Choudhury
9227ad379b remove padding from label 2025-10-10 17:40:17 +05:30
Bikram Choudhury
c83f4fc431 Initial dev for container copy 2025-10-10 17:23:23 +05:30
115 changed files with 388160 additions and 301907 deletions

11
.vscode/settings.json vendored
View File

@@ -24,5 +24,14 @@
"source.organizeImports": "explicit"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescriptreact]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
}
}

96
package-lock.json generated
View File

@@ -10,7 +10,7 @@
"hasInstallScript": true,
"dependencies": {
"@azure/arm-cosmosdb": "9.1.0",
"@azure/cosmos": "4.7.0",
"@azure/cosmos": "4.5.0",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "4.5.0",
"@azure/msal-browser": "2.14.2",
@@ -116,7 +116,6 @@
"tinykeys": "2.1.0",
"underscore": "1.12.1",
"utility-types": "3.10.0",
"uuid": "9.0.0",
"zustand": "3.5.0"
},
"devDependencies": {
@@ -392,9 +391,9 @@
"license": "0BSD"
},
"node_modules/@azure/cosmos": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@azure/cosmos/-/cosmos-4.7.0.tgz",
"integrity": "sha512-a8OV7E41u/ZDaaaDAFdqTTiJ7c82jZc/+ot3XzNCIIilR25NBB+1ixzWQOAgP8SHRUIKfaUl6wAPdTuiG9I66A==",
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@azure/cosmos/-/cosmos-4.5.0.tgz",
"integrity": "sha512-JsTh4twb6FcwP7rJwxQiNZQ/LGtuF6gmciaxY9Rnp6/A325Lhsw/SH4R2ArpT0yCvozbZpweIwdPfUkXVBtp5w==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.1.2",
@@ -627,14 +626,6 @@
}
}
},
"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",
@@ -694,14 +685,6 @@
"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",
@@ -7612,14 +7595,6 @@
"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",
@@ -9150,14 +9125,6 @@
"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",
@@ -9315,14 +9282,6 @@
"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",
@@ -9438,14 +9397,6 @@
"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",
@@ -9714,14 +9665,6 @@
"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",
@@ -9945,14 +9888,6 @@
"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",
@@ -26484,15 +26419,6 @@
"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",
@@ -33827,15 +33753,6 @@
"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",
@@ -35702,9 +35619,8 @@
}
},
"node_modules/uuid": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
"version": "8.3.2",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}

View File

@@ -5,7 +5,7 @@
"main": "index.js",
"dependencies": {
"@azure/arm-cosmosdb": "9.1.0",
"@azure/cosmos": "4.7.0",
"@azure/cosmos": "4.5.0",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "4.5.0",
"@azure/msal-browser": "2.14.2",
@@ -46,8 +46,8 @@
"@types/mkdirp": "1.0.1",
"@types/node-fetch": "2.5.7",
"@xmldom/xmldom": "0.7.13",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"@xterm/addon-fit": "0.10.0",
"allotment": "1.20.2",
"applicationinsights": "1.8.0",
"bootstrap": "3.4.1",
@@ -111,7 +111,6 @@
"tinykeys": "2.1.0",
"underscore": "1.12.1",
"utility-types": "3.10.0",
"uuid": "9.0.0",
"zustand": "3.5.0"
},
"devDependencies": {

View File

@@ -7,6 +7,7 @@ const backendEndpoint = "https://cdb-ms-mpac-pbe.cosmos.azure.com";
const previewSiteEndpoint = "https://dataexplorer-preview.azurewebsites.net";
const previewStorageWebsiteEndpoint = "https://dataexplorerpreview.z5.web.core.windows.net/";
const githubApiUrl = "https://api.github.com/repos/Azure/cosmos-explorer";
const githubPullRequestUrl = "https://github.com/Azure/cosmos-explorer/pull";
const azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
const api = createProxyMiddleware({
@@ -56,7 +57,11 @@ app.get("/pull/:pr(\\d+)", (req, res) => {
fetch(`${githubApiUrl}/pulls/${pr}`)
.then((response) => response.json())
.then(({ head: { sha } }) => {
.then(({ head: { ref, sha } }) => {
const prUrl = new URL(`${githubPullRequestUrl}/${pr}`);
prUrl.hash = ref;
search.set("feature.pr", prUrl.href);
const explorer = new URL(`${previewSiteEndpoint}/commit/${sha}/explorer.html`);
explorer.search = search.toString();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -90,10 +90,6 @@ export class CapabilityNames {
public static readonly EnableServerless: string = "EnableServerless";
public static readonly EnableNoSQLVectorSearch: string = "EnableNoSQLVectorSearch";
public static readonly EnableNoSQLFullTextSearch: string = "EnableNoSQLFullTextSearch";
public static readonly EnableDataMasking: string = "EnableDataMasking";
public static readonly EnableDynamicDataMasking: string = "EnableDynamicDataMasking";
public static readonly EnableNoSQLFullTextSearchPreviewFeatures: string = "EnableNoSQLFullTextSearchPreviewFeatures";
public static readonly EnableOnlineCopyFeature: string = "EnableOnlineContainerCopy";
}
export enum CapacityMode {
@@ -297,7 +293,6 @@ 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 {

View File

@@ -23,10 +23,7 @@ export const handleError = (error: string | ARMError | Error, area: string, cons
};
export const getErrorMessage = (error: string | Error = ""): string => {
let errorMessage = typeof error === "string" ? error : error.message;
if (!errorMessage) {
errorMessage = JSON.stringify(error);
}
const errorMessage = typeof error === "string" ? error : error.message;
return replaceKnownError(errorMessage);
};

View File

@@ -1,31 +0,0 @@
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

@@ -17,7 +17,6 @@ const defaultHeaders = {
[HttpHeaders.apiType]: ApiType.MongoDB.toString(),
[CosmosSDKConstants.HttpHeaders.MaxEntityCount]: "100",
[CosmosSDKConstants.HttpHeaders.Version]: "2017-11-15",
[HttpHeaders.sessionId]: userContext.sessionId,
};
function authHeaders() {

View File

@@ -1,13 +0,0 @@
.pager-container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
gap: 16px;
}
.pager-container > div {
display: flex;
gap: 8px;
align-items: center;
}

View File

@@ -1,111 +0,0 @@
import { IconButton, Text } from "@fluentui/react";
import * as React from "react";
import "./Pager.css";
export interface PagerProps {
startIndex: number;
totalCount: number;
pageSize: number;
onLoadPage: (startIndex: number, pageSize: number) => void;
disabled?: boolean;
showFirstLast?: boolean;
showItemCount?: boolean;
className?: string;
}
const iconButtonStyles = {
root: {
backgroundColor: "transparent",
},
rootHovered: {
backgroundColor: "transparent",
},
rootPressed: {
backgroundColor: "transparent",
},
rootDisabled: {
backgroundColor: "transparent",
},
rootFocused: {
backgroundColor: "transparent",
outline: "none",
},
};
const Pager: React.FC<PagerProps> = ({
startIndex,
totalCount,
pageSize,
onLoadPage,
disabled = false,
showFirstLast = true,
showItemCount = true,
className,
}) => {
// Calculate current page and total pages from startIndex
const currentPage = Math.floor(startIndex / pageSize) + 1;
const totalPages = Math.ceil(totalCount / pageSize);
const endIndex = Math.min(startIndex + pageSize, totalCount);
const handleFirstPage = () => onLoadPage(0, pageSize);
const handlePreviousPage = () => onLoadPage(startIndex - pageSize, pageSize);
const handleNextPage = () => onLoadPage(startIndex + pageSize, pageSize);
const handleLastPage = () => onLoadPage((totalPages - 1) * pageSize, pageSize);
if (totalCount === 0) {
return null;
}
return (
<div className={className || "pager-container"}>
{showItemCount && (
<Text>
Showing {startIndex + 1} - {endIndex} of {totalCount} items
</Text>
)}
<div>
{showFirstLast && (
<IconButton
iconProps={{ iconName: "DoubleChevronLeft" }}
title="First page"
ariaLabel="Go to first page"
onClick={handleFirstPage}
disabled={disabled || currentPage === 1}
styles={iconButtonStyles}
/>
)}
<IconButton
iconProps={{ iconName: "ChevronLeft" }}
title="Previous page"
ariaLabel="Go to previous page"
onClick={handlePreviousPage}
disabled={disabled || currentPage === 1}
styles={iconButtonStyles}
/>
<Text>
Page {currentPage} of {totalPages}
</Text>
<IconButton
iconProps={{ iconName: "ChevronRight" }}
title="Next page"
ariaLabel="Go to next page"
onClick={handleNextPage}
disabled={disabled || currentPage === totalPages}
styles={iconButtonStyles}
/>
{showFirstLast && (
<IconButton
iconProps={{ iconName: "DoubleChevronRight" }}
title="Last page"
ariaLabel="Go to last page"
onClick={handleLastPage}
disabled={disabled || currentPage === totalPages}
styles={iconButtonStyles}
/>
)}
</div>
</div>
);
};
export default Pager;

View File

@@ -11,11 +11,19 @@ interface ShimmerTreeProps {
}
const ShimmerTree = ({ indentLevels, style = {} }: ShimmerTreeProps) => {
/**
* indentLevels - Array of indent levels for shimmer tree
* 0 - Root
* 1 - Level 1
* 2 - Level 2
* 3 - Level 3
* n - Level n
* */
const renderShimmers = (indent: IndentLevel) => (
<Shimmer
key={Math.random()}
shimmerElements={[
{ type: ShimmerElementType.gap, width: `${indent.level * 20}px` },
{ type: ShimmerElementType.gap, width: `${indent.level * 20}px` }, // Indent for hierarchy
{ type: ShimmerElementType.line, height: 16, width: indent.width || "100%" },
]}
style={{ marginBottom: 8 }}

View File

@@ -12,13 +12,13 @@ import { handleError } from "../ErrorHandlingUtils";
import { readOfferWithSDK } from "./readOfferWithSDK";
export const readCollectionOffer = async (params: ReadCollectionOfferParams): Promise<Offer> => {
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 &&

View File

@@ -1,6 +1,5 @@
import { Item, RequestOptions } from "@azure/cosmos";
import { HttpHeaders } from "Common/Constants";
import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
import { CollectionBase } from "../../Contracts/ViewModels";
import DocumentId from "../../Explorer/Tree/DocumentId";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
@@ -24,17 +23,10 @@ export const updateDocument = async (
[HttpHeaders.partitionKey]: documentId.partitionKeyValue,
}
: {};
// If user has chosen to ignore partition key on update, pass null instead of actual partition key value
const ignorePartitionKeyOnDocumentUpdateFlag = LocalStorageUtility.getEntryBoolean(
StorageKey.IgnorePartitionKeyOnDocumentUpdate,
);
const partitionKey = ignorePartitionKeyOnDocumentUpdateFlag ? undefined : getPartitionKeyValue(documentId);
const response = await client()
.database(collection.databaseId)
.container(collection.id())
.item(documentId.id(), partitionKey)
.item(documentId.id(), getPartitionKeyValue(documentId))
.replace(newDocument, options);
logConsoleInfo(`Successfully updated ${entityName} ${documentId.id()}`);

View File

@@ -46,10 +46,6 @@ export type DataExploreMessageV3 =
params: {
updateType: "created" | "deleted" | "settings";
};
}
| {
type: FabricMessageTypes.RestoreContainer;
params: [];
};
export interface GetCosmosTokenMessageOptions {
verb: "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";

View File

@@ -210,7 +210,6 @@ export interface Collection extends Resource {
geospatialConfig?: GeospatialConfig;
vectorEmbeddingPolicy?: VectorEmbeddingPolicy;
fullTextPolicy?: FullTextPolicy;
dataMaskingPolicy?: DataMaskingPolicy;
schema?: ISchema;
requestSchema?: () => void;
computedProperties?: ComputedProperties;
@@ -275,17 +274,6 @@ export interface ComputedProperty {
export type ComputedProperties = ComputedProperty[];
export interface DataMaskingPolicy {
includedPaths: Array<{
path: string;
strategy: string;
startPosition: number;
length: number;
}>;
excludedPaths: string[];
isPolicyEnabled: boolean;
}
export interface MaterializedView {
id: string;
_rid: string;

View File

@@ -49,5 +49,4 @@ export enum MessageTypes {
Ready, // unused. Can be removed if the portal uses the same list of enums.
OpenCESCVAFeedbackBlade,
ActivateTab,
OpenContainerCopyFeedbackBlade,
}

View File

@@ -140,7 +140,6 @@ export interface Collection extends CollectionBase {
requestSchema?: () => void;
vectorEmbeddingPolicy: ko.Observable<DataModels.VectorEmbeddingPolicy>;
fullTextPolicy: ko.Observable<DataModels.FullTextPolicy>;
dataMaskingPolicy: ko.Observable<DataModels.DataMaskingPolicy>;
indexingPolicy: ko.Observable<DataModels.IndexingPolicy>;
uniqueKeyPolicy: DataModels.UniqueKeyPolicy;
usageSizeInKB: ko.Observable<number>;
@@ -446,7 +445,6 @@ export interface DataExplorerInputsFrame {
feedbackPolicies?: any;
aadToken?: string;
containerCopyEnabled?: boolean;
sessionId?: string;
}
export interface SelfServeFrameInputs {

View File

@@ -1,7 +1,5 @@
import Explorer from "Explorer/Explorer";
import React from "react";
import { userContext } from "UserContext";
import { logError } from "../../../Common/Logger";
import { useSidePanel } from "../../../hooks/useSidePanel";
import {
cancel,
@@ -25,27 +23,16 @@ import {
getAccountDetailsFromResourceId,
} from "../CopyJobUtils";
import CreateCopyJobScreensProvider from "../CreateCopyJob/Screens/CreateCopyJobScreensProvider";
import { CopyJobActions, CopyJobStatusType } from "../Enums/CopyJobEnums";
import CopyJobDetails from "../MonitorCopyJobs/Components/CopyJobDetails";
import { CopyJobActions, CopyJobStatusType } from "../Enums";
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
import { CopyJobContextState, CopyJobError, CopyJobErrorType, CopyJobType } from "../Types/CopyJobTypes";
import { CopyJobContextState, CopyJobError, CopyJobErrorType, CopyJobType } from "../Types";
export const openCreateCopyJobPanel = (explorer: Explorer) => {
export const openCreateCopyJobPanel = () => {
const sidePanelState = useSidePanel.getState();
sidePanelState.setPanelHasConsole(false);
sidePanelState.openSidePanel(
ContainerCopyMessages.createCopyJobPanelTitle,
<CreateCopyJobScreensProvider explorer={explorer} />,
"650px",
);
};
export const openCopyJobDetailsPanel = (job: CopyJobType) => {
const sidePanelState = useSidePanel.getState();
sidePanelState.setPanelHasConsole(false);
sidePanelState.openSidePanel(
ContainerCopyMessages.copyJobDetailsPanelTitle(job.Name),
<CopyJobDetails job={job} />,
<CreateCopyJobScreensProvider />,
"650px",
);
};
@@ -53,12 +40,12 @@ export const openCopyJobDetailsPanel = (job: CopyJobType) => {
let copyJobsAbortController: AbortController | null = null;
export const getCopyJobs = async (): Promise<CopyJobType[]> => {
// Abort previous request if still in-flight
if (copyJobsAbortController) {
copyJobsAbortController.abort();
}
copyJobsAbortController = new AbortController();
try {
if (copyJobsAbortController) {
copyJobsAbortController.abort();
}
copyJobsAbortController = new AbortController();
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
userContext.databaseAccount?.id || "",
);
@@ -109,8 +96,6 @@ export const getCopyJobs = async (): Promise<CopyJobType[]> => {
ID: (index + 1).toString(),
Mode: job.properties.mode,
Name: job.properties.jobName,
Source: job.properties.source,
Destination: job.properties.destination,
Status: convertToCamelCase(job.properties.status) as CopyJobType["Status"],
CompletionPercentage: calculateCompletionPercentage(job.properties.processedCount, job.properties.totalCount),
Duration: convertTime(job.properties.duration),
@@ -121,15 +106,9 @@ export const getCopyJobs = async (): Promise<CopyJobType[]> => {
});
return formattedJobs;
} catch (error) {
const errorContent = JSON.stringify(error.content || error.message || error);
if (errorContent.includes("signal is aborted without reason")) {
throw {
message:
"Please wait for the current fetch request to complete. The previous copy job fetch request was aborted.",
};
} else {
throw error;
}
const errorContent = JSON.stringify(error.content || error);
console.error(`Error fetching copy jobs: ${errorContent}`);
throw error;
}
};
@@ -161,8 +140,7 @@ export const submitCreateCopyJob = async (state: CopyJobContextState, onSuccess:
onSuccess();
return response;
} catch (error) {
const errorMessage = error.message || "Error submitting create copy job. Please try again later.";
logError(errorMessage, "CopyJob/CopyJobActions.submitCreateCopyJob");
console.error("Error submitting create copy job:", error);
throw error;
}
};
@@ -201,7 +179,8 @@ export const updateCopyJobStatus = async (job: CopyJobType, action: string): Pro
pattern,
`'${ContainerCopyMessages.MonitorJobs.Status.InProgress}'`,
);
logError(`Error updating copy job status: ${normalizedErrorMessage}`, "CopyJob/CopyJobActions.updateCopyJobStatus");
console.error(`Error updating copy job status: ${normalizedErrorMessage}`);
throw error;
}
};

View File

@@ -3,7 +3,7 @@ import React from "react";
import { StyleConstants } from "../../../Common/StyleConstants";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import * as CommandBarUtil from "../../Menus/CommandBar/CommandBarUtil";
import { ContainerCopyProps } from "../Types/CopyJobTypes";
import { ContainerCopyProps } from "../Types";
import { getCommandBarButtons } from "./Utils";
const backgroundColor = StyleConstants.BaseLight;
@@ -13,8 +13,8 @@ const rootStyle = {
},
};
const CopyJobCommandBar: React.FC<ContainerCopyProps> = ({ explorer }) => {
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer);
const CopyJobCommandBar: React.FC<ContainerCopyProps> = ({ container }) => {
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(container);
const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor);
return (

View File

@@ -7,9 +7,9 @@ import Explorer from "../../Explorer";
import * as Actions from "../Actions/CopyJobActions";
import ContainerCopyMessages from "../ContainerCopyMessages";
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
import { CopyJobCommandBarBtnType } from "../Types/CopyJobTypes";
import { CopyJobCommandBarBtnType } from "../Types";
function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] {
function getCopyJobBtns(): CopyJobCommandBarBtnType[] {
const monitorCopyJobsRef = MonitorCopyJobsRefState((state) => state.ref);
const buttons: CopyJobCommandBarBtnType[] = [
{
@@ -17,7 +17,7 @@ function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] {
iconSrc: AddIcon,
label: ContainerCopyMessages.createCopyJobButtonLabel,
ariaLabel: ContainerCopyMessages.createCopyJobButtonAriaLabel,
onClick: Actions.openCreateCopyJobPanel.bind(null, explorer),
onClick: Actions.openCreateCopyJobPanel,
},
{
key: "refresh",
@@ -33,9 +33,7 @@ function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] {
iconSrc: FeedbackIcon,
label: ContainerCopyMessages.feedbackButtonLabel,
ariaLabel: ContainerCopyMessages.feedbackButtonAriaLabel,
onClick: () => {
explorer.openContainerCopyFeedbackBlade();
},
onClick: () => {},
});
}
return buttons;
@@ -54,6 +52,7 @@ function btnMapper(config: CopyJobCommandBarBtnType): CommandButtonComponentProp
};
}
export function getCommandBarButtons(explorer: Explorer): CommandButtonComponentProps[] {
return getCopyJobBtns(explorer).map(btnMapper);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function getCommandBarButtons(_container: Explorer): CommandButtonComponentProps[] {
return getCopyJobBtns().map(btnMapper);
}

View File

@@ -11,13 +11,8 @@ export default {
noCopyJobsTitle: "No copy jobs to show",
createCopyJobButtonText: "Create a container copy job",
// Copy Job Details
copyJobDetailsPanelTitle: (jobName: string) => jobName || "Job Details",
errorTitle: "Error Details",
selectedContainers: "Selected Containers",
// Create Copy Job Panel
createCopyJobPanelTitle: "Create copy job",
createCopyJobPanelTitle: "Copy container",
// Select Account Screen
selectAccountDescription: "Please select a source account from which to copy.",
@@ -36,9 +31,6 @@ 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",
@@ -51,87 +43,59 @@ export default {
// Assign Permissions Screen
assignPermissions: {
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.`,
commonConfiguration: {
title: "Common configuration",
description: "Basic permissions required for copy operations",
},
onlineConfiguration: {
title: "Online copy configuration",
description: "Additional permissions required for online copy operations",
},
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
},
toggleBtn: {
onText: "On",
offText: "Off",
},
popoverOverlaySpinnerLabel: "Please wait while we process your request...",
addManagedIdentity: {
title: "System-assigned managed identity enabled.",
title: "System assigned managed identity enabled",
description:
"A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you dont have to store any credentials in code.",
descriptionHrefText: "Learn more about Managed identities.",
descriptionHref: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
"Enable a system assigned managed identity for the destination account to allow the copy job to access it.",
toggleLabel: "System assigned managed identity",
tooltip: {
content: "Learn more about",
hrefText: "Managed Identities.",
href: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
},
managedIdentityTooltip:
"A system assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. You can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don't have to store any credentials in code.",
userAssignedIdentityTooltip: "You can select an existing user assigned identity or create a new one.",
userAssignedIdentityLabel: "You may also select a user assigned managed identity.",
createUserAssignedIdentityLink: "Create User Assigned Managed Identity",
enablementTitle: "Enable system assigned managed identity",
enablementDescription: (accountName: string) =>
accountName
? `Enable system-assigned managed identity on the ${accountName}. To confirm, click the "Yes" button. `
enablementDescription: (identityName: string) =>
identityName
? `'${identityName}' will be registered with Microsoft Entra ID. Once it is registered, '${identityName}' can be granted permissions to access resources protected by Microsoft Entra ID. Do you want to enable the system assigned managed identity for '${identityName}'?`
: "",
},
defaultManagedIdentity: {
title: "System-assigned managed identity set as default.",
description: (accountName: string) =>
`Set the system-assigned managed identity as default for "${accountName}" by switching it on.`,
tooltip: {
content: "Learn more about",
hrefText: "Default Managed Identities.",
href: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
},
title: "System assigned managed identity enabled as default",
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
tooltip:
"A system assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. You can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don't have to store any credentials in code.",
popoverTitle: "System assigned managed identity set as default",
popoverDescription: (accountName: string) =>
`Assign the system-assigned managed identity as the default for "${accountName}". To confirm, click the "Yes" button. `,
popoverDescription:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco.",
},
readPermissionAssigned: {
title: "Read permissions assigned to the default identity.",
title: "Read permission assigned to default identity",
description:
"To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.",
tooltip: {
content: "Learn more about",
hrefText: "Read permissions.",
href: "https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control",
},
popoverTitle: "Read permissions assigned to default identity.",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
tooltip:
"A system assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. You can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don't have to store any credentials in code.",
popoverTitle: "Read permission assigned to default identity",
popoverDescription:
"Assign read permissions of the source account to the default identity of the destination account. To confirm click the “Yes” button. ",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco.",
},
pointInTimeRestore: {
title: "Point In Time Restore enabled",
description: (accessName: string) =>
`To facilitate online container copy jobs, please update your "${accessName}" backup policy from periodic to continuous backup. Enabling continuous backup is required for this functionality.`,
tooltip: {
content: "Learn more about",
hrefText: "Continuous Backup",
href: "https://learn.microsoft.com/en-us/azure/cosmos-db/continuous-backup-restore-introduction",
},
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
buttonText: "Enable Point In Time Restore",
},
onlineCopyEnabled: {
title: "Online copy enabled",
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",
description:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
buttonText: "Enable Online Copy",
},
MonitorJobs: {

View File

@@ -1,9 +1,7 @@
import Explorer from "Explorer/Explorer";
import { Subscription } from "Contracts/DataModels";
import React from "react";
import { userContext } from "UserContext";
import { CopyJobMigrationType } from "../Enums/CopyJobEnums";
import { CopyJobContextProviderType, CopyJobContextState, CopyJobFlowType } from "../Types/CopyJobTypes";
import { CopyJobMigrationType } from "../Enums";
import { CopyJobContextProviderType, CopyJobContextState, CopyJobFlowType } from "../Types";
export const CopyJobContext = React.createContext<CopyJobContextProviderType>(null);
export const useCopyJobContext = (): CopyJobContextProviderType => {
@@ -16,7 +14,6 @@ export const useCopyJobContext = (): CopyJobContextProviderType => {
interface CopyJobContextProviderProps {
children: React.ReactNode;
explorer: Explorer;
}
const getInitialCopyJobState = (): CopyJobContextState => {
@@ -24,10 +21,8 @@ const getInitialCopyJobState = (): CopyJobContextState => {
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: {
subscriptionId: userContext.subscriptionId || "",
} as Subscription,
account: userContext.databaseAccount || null,
subscription: null,
account: null,
databaseId: "",
containerId: "",
},
@@ -44,24 +39,16 @@ const getInitialCopyJobState = (): CopyJobContextState => {
const CopyJobContextProvider: React.FC<CopyJobContextProviderProps> = (props) => {
const [copyJobState, setCopyJobState] = React.useState<CopyJobContextState>(getInitialCopyJobState());
const [flow, setFlow] = React.useState<CopyJobFlowType | null>(null);
const [contextError, setContextError] = React.useState<string | null>(null);
const resetCopyJobState = () => {
setCopyJobState(getInitialCopyJobState());
};
const contextValue: CopyJobContextProviderType = {
contextError,
setContextError,
copyJobState,
setCopyJobState,
flow,
setFlow,
resetCopyJobState,
explorer: props.explorer,
};
return <CopyJobContext.Provider value={contextValue}>{props.children}</CopyJobContext.Provider>;
return (
<CopyJobContext.Provider value={{ copyJobState, setCopyJobState, flow, setFlow, resetCopyJobState }}>
{props.children}
</CopyJobContext.Provider>
);
};
export default CopyJobContextProvider;

View File

@@ -1,5 +1,5 @@
import { DatabaseAccount } from "Contracts/DataModels";
import { CopyJobContextState, CopyJobErrorType, CopyJobType } from "./Types/CopyJobTypes";
import { CopyJobErrorType } from "./Types";
const azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
@@ -49,7 +49,7 @@ export function convertTime(timeStr: string): string | null {
const timeParts = timeStr.split(":").map(Number);
if (timeParts.length !== 3 || timeParts.some(isNaN)) {
return null;
return null; // Return null for invalid format
}
const formatPart = (value: number, unit: string) => {
if (unit === "seconds") {
@@ -67,7 +67,7 @@ export function convertTime(timeStr: string): string | null {
.filter(Boolean)
.join(", ");
return formattedTimeParts || "0 seconds";
return formattedTimeParts || "0 seconds"; // Return "0 seconds" if all parts are zero
}
export function formatUTCDateTime(utcStr: string): { formattedDateTime: string; timestamp: number } | null {
@@ -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,58 +114,3 @@ export function getAccountDetailsFromResourceId(accountId: string | undefined) {
const [_, subscriptionId, resourceGroup, accountName] = matches || [];
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);
return (
sourceAccountDetails?.subscriptionId === targetAccountDetails?.subscriptionId &&
sourceAccountDetails?.resourceGroup === targetAccountDetails?.resourceGroup &&
sourceAccountDetails?.accountName === targetAccountDetails?.accountName
);
}
export function isEqual(prevJobs: CopyJobType[], newJobs: CopyJobType[]): boolean {
if (prevJobs.length !== newJobs.length) {
return false;
}
return prevJobs.every((prevJob: CopyJobType) => {
const newJob = newJobs.find((job) => job.Name === prevJob.Name);
if (!newJob) {
return false;
}
return prevJob.Status === newJob.Status;
});
}
const truncateLength = 5;
const truncateName = (name: string, length: number = truncateLength): string => {
return name.length <= length ? name : name.slice(0, length);
};
export function getDefaultJobName(
selectedDatabaseAndContainers: {
sourceDatabaseName?: string;
sourceContainerName?: string;
targetDatabaseName?: string;
targetContainerName?: string;
}[],
): string {
if (selectedDatabaseAndContainers.length === 1) {
const { sourceDatabaseName, sourceContainerName, targetDatabaseName, targetContainerName } =
selectedDatabaseAndContainers[0];
const timestamp = new Date().getTime().toString();
const sourcePart = `${truncateName(sourceDatabaseName)}.${truncateName(sourceContainerName)}`;
const targetPart = `${truncateName(targetDatabaseName)}.${truncateName(targetContainerName)}`;
return `${sourcePart}_${targetPart}_${timestamp}`;
}
return "";
}

View File

@@ -1,22 +1,20 @@
import { Link, Stack, Text, Toggle } from "@fluentui/react";
import React from "react";
import React, { useMemo } from "react";
import { updateSystemIdentity } from "../../../../../Utils/arm/identityUtils";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { buildResourceLink } from "../../../CopyJobUtils";
import InfoTooltip from "../Components/InfoTooltip";
import PopoverMessage from "../Components/PopoverContainer";
import useManagedIdentity from "./hooks/useManagedIdentity";
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
import useToggle from "./hooks/useToggle";
const managedIdentityTooltip = (
<Text>
{ContainerCopyMessages.addManagedIdentity.tooltip.content} &nbsp;
<Link href={ContainerCopyMessages.addManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.addManagedIdentity.tooltip.hrefText}
</Link>
</Text>
);
const managedIdentityTooltip = ContainerCopyMessages.addManagedIdentity.managedIdentityTooltip;
const userAssignedTooltip = ContainerCopyMessages.addManagedIdentity.userAssignedIdentityTooltip;
const textStyle = { display: "flex", alignItems: "center" };
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
const AddManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
@@ -24,22 +22,35 @@ const AddManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
const [systemAssigned, onToggle] = useToggle(false);
const { loading, handleAddSystemIdentity } = useManagedIdentity(updateSystemIdentity);
const manageIdentityLink = useMemo(() => {
const { target } = copyJobState;
const resourceUri = buildResourceLink(target.account);
return target?.account?.id ? `${resourceUri}/ManagedIdentitiesBlade` : "#";
}, [copyJobState]);
return (
<Stack className="addManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<Text>
{ContainerCopyMessages.addManagedIdentity.description}&ensp;
<Link href={ContainerCopyMessages.addManagedIdentity.descriptionHref} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.addManagedIdentity.descriptionHrefText}
</Link>{" "}
&nbsp;
<InfoTooltip content={managedIdentityTooltip} />
</Text>
<Toggle
label={
<Text className="toggle-label" style={textStyle}>
{ContainerCopyMessages.addManagedIdentity.toggleLabel}&nbsp;
<InfoTooltip content={managedIdentityTooltip} />
</Text>
}
checked={systemAssigned}
onText={ContainerCopyMessages.toggleBtn.onText}
offText={ContainerCopyMessages.toggleBtn.offText}
onChange={onToggle}
/>
<Text className="user-assigned-label" style={textStyle}>
{ContainerCopyMessages.addManagedIdentity.userAssignedIdentityLabel}&nbsp;
<InfoTooltip content={userAssignedTooltip} />
</Text>
<div style={{ marginTop: 8 }}>
<Link href={manageIdentityLink} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.addManagedIdentity.createUserAssignedIdentityLink}
</Link>
</div>
<PopoverMessage
isLoading={loading}
visible={systemAssigned}

View File

@@ -1,6 +1,5 @@
import { Link, Stack, Text, Toggle } from "@fluentui/react";
import { Stack, 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";
@@ -10,19 +9,12 @@ import PopoverMessage from "../Components/PopoverContainer";
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
import useToggle from "./hooks/useToggle";
const TooltipContent = (
<Text>
{ContainerCopyMessages.readPermissionAssigned.tooltip.content} &nbsp;
<Link href={ContainerCopyMessages.readPermissionAssigned.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.readPermissionAssigned.tooltip.hrefText}
</Link>
</Text>
);
type AddReadPermissionToDefaultIdentityProps = Partial<PermissionSectionConfig>;
const TooltipContent = ContainerCopyMessages.readPermissionAssigned.tooltip;
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIdentityProps> = () => {
const AddReadPermissionToDefaultIdentity: React.FC<AddManagedIdentityProps> = () => {
const [loading, setLoading] = React.useState(false);
const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext();
const { copyJobState, setCopyJobState } = useCopyJobContext();
const [readPermissionAssigned, onToggle] = useToggle(false);
const handleAddReadPermission = useCallback(async () => {
@@ -49,21 +41,18 @@ const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIde
}));
}
} catch (error) {
const errorMessage =
error.message || "Error assigning read permission to default identity. Please try again later.";
logError(errorMessage, "CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission");
setContextError(errorMessage);
console.error("Error assigning read permission to default identity:", error);
} finally {
setLoading(false);
}
}, [copyJobState, setCopyJobState, setContextError]);
}, [copyJobState, setCopyJobState]);
return (
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<Text className="toggle-label">
{ContainerCopyMessages.readPermissionAssigned.description}&ensp;
<div className="toggle-label">
{ContainerCopyMessages.readPermissionAssigned.description} &nbsp;
<InfoTooltip content={TooltipContent} />
</Text>
</div>
<Toggle
checked={readPermissionAssigned}
onText={ContainerCopyMessages.toggleBtn.onText}

View File

@@ -1,124 +0,0 @@
import { Image, Stack, Text } from "@fluentui/react";
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel } from "@fluentui/react-components";
import React, { useEffect } from "react";
import CheckmarkIcon from "../../../../../../images/successfulPopup.svg";
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 { useCopyJobPrerequisitesCache } from "../../Utils/useCopyJobPrerequisitesCache";
import usePermissionSections, { PermissionGroupConfig, PermissionSectionConfig } from "./hooks/usePermissionsSection";
const PermissionSection: React.FC<PermissionSectionConfig> = ({ id, title, Component, completed, disabled }) => (
<AccordionItem key={id} value={id} disabled={disabled}>
<AccordionHeader className="accordionHeader">
<Text className="accordionHeaderText" variant="medium">
{title}
</Text>
<Image
className="statusIcon"
src={completed ? CheckmarkIcon : WarningIcon}
alt={completed ? "Checkmark icon" : "Warning icon"}
width={completed ? 20 : 24}
height={completed ? 20 : 24}
/>
</AccordionHeader>
<AccordionPanel aria-disabled={disabled} className="accordionPanel">
<Component />
</AccordionPanel>
</AccordionItem>
);
const PermissionGroup: React.FC<PermissionGroupConfig> = ({ title, description, sections }) => {
const [openItems, setOpenItems] = React.useState<string[]>([]);
useEffect(() => {
const firstIncompleteSection = sections.find((section) => !section.completed);
const nextOpenItems = firstIncompleteSection ? [firstIncompleteSection.id] : [];
if (JSON.stringify(openItems) !== JSON.stringify(nextOpenItems)) {
setOpenItems(nextOpenItems);
}
}, [sections]);
return (
<Stack
tokens={{ childrenGap: 15 }}
styles={{
root: {
background: "#fafafa",
border: "1px solid #e1e1e1",
borderRadius: 8,
padding: 16,
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
},
}}
>
<Stack tokens={{ childrenGap: 5 }}>
<Text variant="medium" style={{ fontWeight: 600 }}>
{title}
</Text>
{description && (
<Text variant="small" styles={{ root: { color: "#605E5C" } }}>
{description}
</Text>
)}
</Stack>
<Accordion className="permissionsAccordion" collapsible openItems={openItems}>
{sections.map((section) => (
<PermissionSection key={section.id} {...section} />
))}
</Accordion>
</Stack>
);
};
const AssignPermissions = () => {
const { setValidationCache } = useCopyJobPrerequisitesCache();
const { copyJobState } = useCopyJobContext();
const permissionGroups = usePermissionSections(copyJobState);
const totalSectionsCount = React.useMemo(
() => permissionGroups.reduce((total, group) => total + group.sections.length, 0),
[permissionGroups],
);
const indentLevels = React.useMemo<IndentLevel[]>(
() => Array(copyJobState.migrationType === CopyJobMigrationType.Online ? 5 : 3).fill({ level: 0, width: "100%" }),
[copyJobState.migrationType],
);
const isSameAccount = isIntraAccountCopy(copyJobState?.source?.account?.id, copyJobState?.target?.account?.id);
useEffect(() => {
return () => {
setValidationCache(new Map<string, boolean>());
};
}, []);
return (
<Stack className="assignPermissionsContainer" tokens={{ childrenGap: 20 }}>
<Text variant="medium">
{isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online
? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription(
copyJobState?.source?.account?.name || "",
)
: ContainerCopyMessages.assignPermissions.crossAccountDescription}
</Text>
{totalSectionsCount === 0 ? (
<ShimmerTree indentLevels={indentLevels} style={{ width: "100%" }} />
) : (
<Stack tokens={{ childrenGap: 25 }}>
{permissionGroups.map((group) => (
<PermissionGroup key={group.id} {...group} />
))}
</Stack>
)}
</Stack>
);
};
export default AssignPermissions;

View File

@@ -1,33 +1,24 @@
import { Link, Stack, Text, Toggle } from "@fluentui/react";
import { Stack, Toggle } from "@fluentui/react";
import React from "react";
import { updateDefaultIdentity } from "../../../../../Utils/arm/identityUtils";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import InfoTooltip from "../Components/InfoTooltip";
import PopoverMessage from "../Components/PopoverContainer";
import useManagedIdentity from "./hooks/useManagedIdentity";
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
import useToggle from "./hooks/useToggle";
const managedIdentityTooltip = (
<Text>
{ContainerCopyMessages.defaultManagedIdentity.tooltip.content} &nbsp;
<Link href={ContainerCopyMessages.defaultManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.defaultManagedIdentity.tooltip.hrefText}
</Link>
</Text>
);
const managedIdentityTooltip = ContainerCopyMessages.defaultManagedIdentity.tooltip;
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
const DefaultManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
const { copyJobState } = useCopyJobContext();
const [defaultSystemAssigned, onToggle] = useToggle(false);
const { loading, handleAddSystemIdentity } = useManagedIdentity(updateDefaultIdentity);
return (
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<div className="toggle-label">
{ContainerCopyMessages.defaultManagedIdentity.description(copyJobState?.target?.account.name)} &nbsp;
{ContainerCopyMessages.defaultManagedIdentity.description} &nbsp;
<InfoTooltip content={managedIdentityTooltip} />
</div>
<Toggle
@@ -48,7 +39,7 @@ const DefaultManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
onCancel={() => onToggle(null, false)}
onPrimary={handleAddSystemIdentity}
>
{ContainerCopyMessages.defaultManagedIdentity.popoverDescription(copyJobState?.target?.account.name)}
{ContainerCopyMessages.defaultManagedIdentity.popoverDescription}
</PopoverMessage>
</Stack>
);

View File

@@ -1,151 +1,26 @@
import { Link, PrimaryButton, Stack } from "@fluentui/react";
import { DatabaseAccount } from "Contracts/DataModels";
import { PrimaryButton, Stack } from "@fluentui/react";
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";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
import { AccountValidatorFn } from "../../../Types/CopyJobTypes";
import { buildResourceLink } from "../../../CopyJobUtils";
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
import useWindowOpenMonitor from "./hooks/useWindowOpenMonitor";
const validatorFn: AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => {
const prevCapabilities = prev?.properties?.capabilities ?? [];
const nextCapabilities = next?.properties?.capabilities ?? [];
return JSON.stringify(prevCapabilities) !== JSON.stringify(nextCapabilities);
};
const OnlineCopyEnabled: React.FC = () => {
const [loading, setLoading] = React.useState(false);
const [showRefreshButton, setShowRefreshButton] = React.useState(false);
const intervalRef = React.useRef<NodeJS.Timeout | null>(null);
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const { setContextError, copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext();
const selectedSourceAccount = source?.account;
const sourceAccountCapabilities = selectedSourceAccount?.properties?.capabilities ?? [];
const {
subscriptionId: sourceSubscriptionId,
resourceGroup: sourceResourceGroup,
accountName: sourceAccountName,
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
const handleFetchAccount = async () => {
try {
const account = await fetchDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName);
if (account && validatorFn(selectedSourceAccount, account)) {
setCopyJobState((prevState) => ({
...prevState,
source: { ...prevState.source, account: account },
}));
setLoading(false);
}
} catch (error) {
const errorMessage =
error.message || "Error fetching source account after enabling online copy. Please try again later.";
logError(errorMessage, "CopyJob/OnlineCopyEnabled.handleFetchAccount");
setContextError(errorMessage);
clearAccountFetchInterval();
}
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
const OnlineCopyEnabled: React.FC<AddManagedIdentityProps> = () => {
const { copyJobState: { source } = {} } = useCopyJobContext();
const sourceAccountLink = buildResourceLink(source?.account);
const onlineCopyUrl = `${sourceAccountLink}/Features`;
const onWindowClosed = () => {
// eslint-disable-next-line no-console
console.log("Online copy window closed");
};
const clearAccountFetchInterval = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setLoading(false);
};
const clearIntervalAndShowRefresh = () => {
clearAccountFetchInterval();
setShowRefreshButton(true);
};
const handleRefresh = () => {
setLoading(true);
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(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, []);
const openWindowAndMonitor = useWindowOpenMonitor(onlineCopyUrl, onWindowClosed);
return (
<Stack className="onlineCopyContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<LoadingOverlay isLoading={loading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
<Stack.Item className="info-message">
{ContainerCopyMessages.onlineCopyEnabled.description(source?.account?.name || "")}&ensp;
<Link href={ContainerCopyMessages.onlineCopyEnabled.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.onlineCopyEnabled.hrefText}
</Link>
</Stack.Item>
<Stack.Item>
{showRefreshButton ? (
<PrimaryButton
className="fullWidth"
text={ContainerCopyMessages.refreshButtonLabel}
iconProps={{ iconName: "Refresh" }}
onClick={handleRefresh}
disabled={loading}
/>
) : (
<PrimaryButton
className="fullWidth"
text={loading ? "" : ContainerCopyMessages.onlineCopyEnabled.buttonText}
{...(loading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
disabled={loading}
onClick={handleOnlineCopyEnable}
/>
)}
</Stack.Item>
<div className="toggle-label">{ContainerCopyMessages.onlineCopyEnabled.description}</div>
<PrimaryButton text={ContainerCopyMessages.onlineCopyEnabled.buttonText} onClick={openWindowAndMonitor} />
</Stack>
);
};

View File

@@ -1,143 +1,53 @@
import { Link, PrimaryButton, Stack, Text } from "@fluentui/react";
import { DatabaseAccount } from "Contracts/DataModels";
import React, { useEffect, useRef, useState } from "react";
import { PrimaryButton, Stack } from "@fluentui/react";
import React, { useCallback, 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";
import { buildResourceLink, getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
import { AccountValidatorFn } from "../../../Types/CopyJobTypes";
import InfoTooltip from "../Components/InfoTooltip";
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
import useWindowOpenMonitor from "./hooks/useWindowOpenMonitor";
const tooltipContent = (
<Text>
{ContainerCopyMessages.pointInTimeRestore.tooltip.content} &nbsp;
<Link href={ContainerCopyMessages.pointInTimeRestore.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.pointInTimeRestore.tooltip.hrefText}
</Link>
</Text>
);
const validatorFn: AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => {
const prevBackupPolicy = prev?.properties?.backupPolicy?.type ?? "";
const nextBackupPolicy = next?.properties?.backupPolicy?.type ?? "";
return prevBackupPolicy !== nextBackupPolicy;
};
const PointInTimeRestore: React.FC = () => {
const [loading, setLoading] = useState(false);
const [showRefreshButton, setShowRefreshButton] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
const PointInTimeRestore: React.FC<AddManagedIdentityProps> = () => {
const [loading, setLoading] = useState<boolean>(false);
const { copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext();
const sourceAccountLink = buildResourceLink(source?.account);
const featureUrl = `${sourceAccountLink}/backupRestore`;
const selectedSourceAccount = source?.account;
const {
subscriptionId: sourceSubscriptionId,
resourceGroup: sourceResourceGroup,
accountName: sourceAccountName,
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
const pitrUrl = `${sourceAccountLink}/backupRestore`;
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, []);
const handleFetchAccount = async () => {
const onWindowClosed = useCallback(async () => {
try {
const selectedSourceAccount = source?.account;
const {
subscriptionId: sourceSubscriptionId,
resourceGroup: sourceResourceGroup,
accountName: sourceAccountName,
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
setLoading(true);
const account = await fetchDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName);
if (account && validatorFn(selectedSourceAccount, account)) {
if (account) {
setCopyJobState((prevState) => ({
...prevState,
source: { ...prevState.source, account: account },
}));
setLoading(false);
}
} catch (error) {
const errorMessage =
error.message || "Error fetching source account after Point-in-Time Restore. Please try again later.";
logError(errorMessage, "CopyJob/PointInTimeRestore.handleFetchAccount");
clearAccountFetchInterval();
console.error("Error fetching database account after PITR window closed:", error);
} finally {
setLoading(false);
}
};
const clearAccountFetchInterval = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setLoading(false);
};
const clearIntervalAndShowRefresh = () => {
clearAccountFetchInterval();
setShowRefreshButton(true);
};
const handleRefresh = async () => {
setLoading(true);
await handleFetchAccount();
setLoading(false);
};
const openWindowAndMonitor = () => {
setLoading(true);
setShowRefreshButton(false);
window.open(featureUrl, "_blank");
intervalRef.current = setInterval(() => {
handleFetchAccount();
}, 30 * 1000);
timeoutRef.current = setTimeout(
() => {
clearIntervalAndShowRefresh();
},
10 * 60 * 1000,
);
};
}, []);
const openWindowAndMonitor = useWindowOpenMonitor(pitrUrl, onWindowClosed);
return (
<Stack className="pointInTimeRestoreContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<LoadingOverlay isLoading={loading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
<Stack.Item className="toggle-label">
{ContainerCopyMessages.pointInTimeRestore.description(source.account?.name ?? "")}
{tooltipContent && (
<>
{" "}
<InfoTooltip content={tooltipContent} />
</>
)}
</Stack.Item>
<Stack.Item>
{showRefreshButton ? (
<PrimaryButton
className="fullWidth"
text={ContainerCopyMessages.refreshButtonLabel}
iconProps={{ iconName: "Refresh" }}
onClick={handleRefresh}
/>
) : (
<PrimaryButton
className="fullWidth"
text={loading ? "" : ContainerCopyMessages.pointInTimeRestore.buttonText}
{...(loading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
disabled={loading}
onClick={openWindowAndMonitor}
/>
)}
</Stack.Item>
<div className="toggle-label">{ContainerCopyMessages.pointInTimeRestore.description}</div>
<PrimaryButton
text={loading ? "" : ContainerCopyMessages.pointInTimeRestore.buttonText}
{...(loading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
disabled={loading}
onClick={openWindowAndMonitor}
/>
</Stack>
);
};

View File

@@ -1,6 +1,5 @@
import { DatabaseAccount } from "Contracts/DataModels";
import { useCallback, useState } from "react";
import { logError } from "../../../../../../Common/Logger";
import { useCopyJobContext } from "../../../../Context/CopyJobContext";
import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils";
@@ -20,7 +19,7 @@ interface UseManagedIdentityUpdaterReturn {
const useManagedIdentity = (
updateIdentityFn: UseManagedIdentityUpdaterParams["updateIdentityFn"],
): UseManagedIdentityUpdaterReturn => {
const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext();
const { copyJobState, setCopyJobState } = useCopyJobContext();
const [loading, setLoading] = useState<boolean>(false);
const handleAddSystemIdentity = useCallback(async (): Promise<void> => {
@@ -41,9 +40,7 @@ const useManagedIdentity = (
}));
}
} catch (error) {
const errorMessage = error.message || "Error enabling system-assigned managed identity. Please try again later.";
logError(errorMessage, "CopyJob/useManagedIdentity.handleAddSystemIdentity");
setContextError(errorMessage);
console.error("Error enabling system-assigned managed identity:", error);
} finally {
setLoading(false);
}

View File

@@ -1,15 +1,9 @@
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, getContainerIdentifiers, isIntraAccountCopy } from "../../../../CopyJobUtils";
import {
BackupPolicyType,
CopyJobMigrationType,
DefaultIdentityType,
IdentityType,
} from "../../../../Enums/CopyJobEnums";
import { CopyJobContextState } from "../../../../Types/CopyJobTypes";
import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils";
import { BackupPolicyType, CopyJobMigrationType, DefaultIdentityType, IdentityType } from "../../../../Enums";
import { CopyJobContextState } from "../../../../Types";
import { useCopyJobPrerequisitesCache } from "../../../Utils/useCopyJobPrerequisitesCache";
import AddManagedIdentity from "../AddManagedIdentity";
import AddReadPermissionToDefaultIdentity from "../AddReadPermissionToDefaultIdentity";
@@ -26,13 +20,7 @@ export interface PermissionSectionConfig {
validate?: (state: CopyJobContextState) => boolean | Promise<boolean>;
}
export interface PermissionGroupConfig {
id: string;
title: string;
description: string;
sections: PermissionSectionConfig[];
}
// Section IDs for maintainability
export const SECTION_IDS = {
addManagedIdentity: "addManagedIdentity",
defaultManagedIdentity: "defaultManagedIdentity",
@@ -108,12 +96,9 @@ const PERMISSION_SECTIONS_FOR_ONLINE_JOBS: PermissionSectionConfig[] = [
title: ContainerCopyMessages.onlineCopyEnabled.title,
Component: OnlineCopyEnabled,
disabled: true,
validate: (state: CopyJobContextState) => {
const accountCapabilities = state?.source?.account?.properties?.capabilities ?? [];
const onlineCopyCapability = accountCapabilities.find(
(capability) => capability.name === CapabilityNames.EnableOnlineCopyFeature,
);
return !!onlineCopyCapability;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
validate: (_state: CopyJobContextState) => {
return false;
},
},
];
@@ -134,81 +119,21 @@ export function checkTargetHasReaderRoleOnSource(roleDefinitions: RoleDefinition
}
/**
* Validates sections within a group sequentially.
* Returns the permission sections configuration for the Assign Permissions screen.
* Memoizes derived values for performance and decouples logic for testability.
*/
const validateSectionsInGroup = async (
sections: PermissionSectionConfig[],
state: CopyJobContextState,
validationCache: Map<string, boolean>,
): Promise<PermissionSectionConfig[]> => {
const result: PermissionSectionConfig[] = [];
for (let i = 0; i < sections.length; i++) {
const section = sections[i];
if (validationCache.has(section.id) && validationCache.get(section.id) === true) {
result.push({ ...section, completed: true });
continue;
}
if (section.validate) {
const isValid = await section.validate(state);
validationCache.set(section.id, isValid);
result.push({ ...section, completed: isValid });
if (!isValid) {
// Mark remaining sections in this group as incomplete
for (let j = i + 1; j < sections.length; j++) {
result.push({ ...sections[j], completed: false });
}
break;
}
} else {
validationCache.set(section.id, false);
result.push({ ...section, completed: false });
}
}
return result;
};
/**
* Returns the permission groups configuration for the Assign Permissions screen.
* Groups validate independently but sections within each group validate sequentially.
*/
const usePermissionSections = (state: CopyJobContextState): PermissionGroupConfig[] => {
const sourceAccount = getContainerIdentifiers(state.source);
const targetAccount = getContainerIdentifiers(state.target);
const usePermissionSections = (state: CopyJobContextState): PermissionSectionConfig[] => {
const { validationCache, setValidationCache } = useCopyJobPrerequisitesCache();
const [permissionGroups, setPermissionGroups] = useState<PermissionGroupConfig[] | null>(null);
const [permissionSections, setPermissionSections] = useState<PermissionSectionConfig[] | null>(null);
const isValidatingRef = useRef(false);
const groupsToValidate = useMemo(() => {
const isSameAccount = isIntraAccountCopy(sourceAccount.accountId, targetAccount.accountId);
const commonSections = isSameAccount ? [] : [...PERMISSION_SECTIONS_CONFIG];
const groups: PermissionGroupConfig[] = [];
if (commonSections.length > 0) {
groups.push({
id: "commonConfigs",
title: ContainerCopyMessages.assignPermissions.commonConfiguration.title,
description: ContainerCopyMessages.assignPermissions.commonConfiguration.description,
sections: commonSections,
});
}
const sectionToValidate = useMemo(() => {
const baseSections = [...PERMISSION_SECTIONS_CONFIG];
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 [...baseSections, ...PERMISSION_SECTIONS_FOR_ONLINE_JOBS];
}
return groups;
}, [sourceAccount.accountId, targetAccount.accountId, state.migrationType]);
return baseSections;
}, [state.migrationType]);
const memoizedValidationCache = useMemo(() => {
if (state.migrationType === CopyJobMigrationType.Offline) {
@@ -219,39 +144,55 @@ const usePermissionSections = (state: CopyJobContextState): PermissionGroupConfi
}, [state.migrationType]);
useEffect(() => {
const validateGroups = async () => {
const validateSections = async () => {
if (isValidatingRef.current) {
return;
}
isValidatingRef.current = true;
const result: PermissionSectionConfig[] = [];
const newValidationCache = new Map(memoizedValidationCache);
// Validate all groups independently (in parallel)
const validatedGroups = await Promise.all(
groupsToValidate.map(async (group) => {
const validatedSections = await validateSectionsInGroup(group.sections, state, newValidationCache);
for (let i = 0; i < sectionToValidate.length; i++) {
const section = sectionToValidate[i];
return {
...group,
sections: validatedSections,
};
}),
);
// Check if this section was already validated and passed
if (newValidationCache.has(section.id) && newValidationCache.get(section.id) === true) {
result.push({ ...section, completed: true });
continue;
}
// We've reached the first non-cached section - validate it
if (section.validate) {
const isValid = await section.validate(state);
newValidationCache.set(section.id, isValid);
result.push({ ...section, completed: isValid });
// Stop validation if current section failed
if (!isValid) {
for (let j = i + 1; j < sectionToValidate.length; j++) {
result.push({ ...sectionToValidate[j], completed: false });
}
break;
}
} else {
// Section has no validate method
newValidationCache.set(section.id, false);
result.push({ ...section, completed: false });
}
}
setValidationCache(newValidationCache);
setPermissionGroups(validatedGroups);
setPermissionSections(result);
isValidatingRef.current = false;
};
validateGroups();
validateSections();
return () => {
isValidatingRef.current = false;
};
}, [state, groupsToValidate]);
}, [state, sectionToValidate]);
return permissionGroups ?? [];
return permissionSections ?? [];
};
export default usePermissionSections;

View File

@@ -3,7 +3,7 @@ import { useCallback, useState } from "react";
const useToggle = (initialState = false) => {
const [state, setState] = useState<boolean>(initialState);
const onToggle = useCallback((_, checked?: boolean) => {
setState(checked);
setState(!!checked);
}, []);
return [state, onToggle] as const;
};

View File

@@ -0,0 +1,31 @@
import { useEffect, useRef } from "react";
const useWindowOpenMonitor = (url: string, onClose?: () => void, intervalMs = 500) => {
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const openWindowAndMonitor = () => {
const newWindow = window.open(url, "_blank");
intervalRef.current = setInterval(() => {
if (newWindow?.closed) {
clearInterval(intervalRef.current!);
intervalRef.current = null;
if (onClose) {
onClose();
}
}
}, intervalMs);
};
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, []);
return openWindowAndMonitor;
};
export default useWindowOpenMonitor;

View File

@@ -0,0 +1,66 @@
import { Image, Stack, Text } from "@fluentui/react";
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel } from "@fluentui/react-components";
import React, { useEffect } from "react";
import CheckmarkIcon from "../../../../../../images/successfulPopup.svg";
import WarningIcon from "../../../../../../images/warning.svg";
import ShimmerTree, { IndentLevel } from "../../../../../Common/ShimmerTree";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { CopyJobMigrationType } from "../../../Enums";
import usePermissionSections, { PermissionSectionConfig } from "./hooks/usePermissionsSection";
const PermissionSection: React.FC<PermissionSectionConfig> = ({ id, title, Component, completed, disabled }) => (
<AccordionItem key={id} value={id} disabled={disabled}>
<AccordionHeader className="accordionHeader">
<Text className="accordionHeaderText" variant="medium">
{title}
</Text>
<Image
className="statusIcon"
src={completed ? CheckmarkIcon : WarningIcon}
alt={completed ? "Checkmark icon" : "Warning icon"}
width={completed ? 20 : 24}
height={completed ? 20 : 24}
/>
</AccordionHeader>
<AccordionPanel aria-disabled={disabled} className="accordionPanel">
<Component />
</AccordionPanel>
</AccordionItem>
);
const AssignPermissions = () => {
const { copyJobState } = useCopyJobContext();
const permissionSections = usePermissionSections(copyJobState);
const [openItems, setOpenItems] = React.useState<string[]>([]);
const indentLevels = React.useMemo<IndentLevel[]>(
() => Array(copyJobState.migrationType === CopyJobMigrationType.Online ? 5 : 3).fill({ level: 0, width: "100%" }),
[],
);
useEffect(() => {
const firstIncompleteSection = permissionSections.find((section) => !section.completed);
const nextOpenItems = firstIncompleteSection ? [firstIncompleteSection.id] : [];
if (JSON.stringify(openItems) !== JSON.stringify(nextOpenItems)) {
setOpenItems(nextOpenItems);
}
}, [permissionSections]);
return (
<Stack className="assignPermissionsContainer" tokens={{ childrenGap: 15 }}>
<span>{ContainerCopyMessages.assignPermissions.description}</span>
{permissionSections?.length === 0 ? (
<ShimmerTree indentLevels={indentLevels} style={{ width: "100%" }} />
) : (
<Accordion className="permissionsAccordion" collapsible openItems={openItems}>
{permissionSections.map((section) => (
<PermissionSection key={section.id} {...section} />
))}
</Accordion>
)}
</Stack>
);
};
export default AssignPermissions;

View File

@@ -2,7 +2,7 @@ import { Image, ITooltipHostStyles, TooltipHost } from "@fluentui/react";
import React from "react";
import InfoIcon from "../../../../../../images/Info.svg";
const InfoTooltip: React.FC<{ content?: string | JSX.Element }> = ({ content }) => {
const InfoTooltip: React.FC<{ content?: string }> = ({ content }) => {
if (!content) {
return null;
}

View File

@@ -2,8 +2,6 @@
/* 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;
@@ -21,13 +19,17 @@ const PopoverContainer: React.FC<PopoverContainerProps> = React.memo(
tokens={{ childrenGap: 20 }}
style={{ maxWidth: 450 }}
>
<LoadingOverlay isLoading={isLoading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
<Text variant="mediumPlus" style={{ fontWeight: 600 }}>
{title}
</Text>
<Text>{children}</Text>
<Stack horizontal tokens={{ childrenGap: 20 }}>
<PrimaryButton text={"Yes"} onClick={onPrimary} disabled={isLoading} />
<PrimaryButton
text={isLoading ? "" : "Yes"}
{...(isLoading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
onClick={onPrimary}
disabled={isLoading}
/>
<DefaultButton text="No" onClick={onCancel} disabled={isLoading} />
</Stack>
</Stack>

View File

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

View File

@@ -1,6 +1,5 @@
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
import { Stack } from "@fluentui/react";
import React from "react";
import { useCopyJobContext } from "../../Context/CopyJobContext";
import { useCopyJobNavigation } from "../Utils/useCopyJobNavigation";
import NavigationControls from "./Components/NavigationControls";
@@ -13,28 +12,11 @@ const CreateCopyJobScreens: React.FC = () => {
handlePrevious,
handleCancel,
primaryBtnText,
showAddCollectionPanel,
} = useCopyJobNavigation();
const { contextError, setContextError } = useCopyJobContext();
return (
<Stack verticalAlign="space-between" className="createCopyJobScreensContainer">
<Stack.Item className="createCopyJobScreensContent">
{contextError && (
<MessageBar
className="createCopyJobErrorMessageBar"
messageBarType={MessageBarType.blocked}
isMultiline={false}
onDismiss={() => setContextError(null)}
dismissButtonAriaLabel="Close"
truncated={true}
overflowButtonAriaLabel="See more"
>
{contextError}
</MessageBar>
)}
{React.cloneElement(currentScreen?.component as React.ReactElement, { showAddCollectionPanel })}
</Stack.Item>
<Stack.Item className="createCopyJobScreensContent">{currentScreen?.component}</Stack.Item>
<Stack.Item className="createCopyJobScreensFooter">
<NavigationControls
primaryBtnText={primaryBtnText}

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
import { Dropdown } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { DropdownOptionType } from "../../../../Types/CopyJobTypes";
import { DropdownOptionType } from "../../../../Types";
import FieldRow from "../../Components/FieldRow";
interface AccountDropdownProps {
@@ -27,5 +27,4 @@ export const AccountDropdown: React.FC<AccountDropdownProps> = React.memo(
/>
</FieldRow>
),
(prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey,
);

View File

@@ -3,7 +3,7 @@
import { Dropdown } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { DropdownOptionType } from "../../../../Types/CopyJobTypes";
import { DropdownOptionType } from "../../../../Types";
import FieldRow from "../../Components/FieldRow";
interface SubscriptionDropdownProps {
@@ -25,5 +25,4 @@ export const SubscriptionDropdown: React.FC<SubscriptionDropdownProps> = React.m
/>
</FieldRow>
),
(prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey,
);

View File

@@ -1,8 +1,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";
import { CopyJobMigrationType } from "../../../../Enums";
import { CopyJobContextProviderType, CopyJobContextState, DropdownOptionType } from "../../../../Types";
export function useDropdownOptions(
subscriptions: Subscription[],
@@ -37,7 +36,6 @@ 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) => {
@@ -47,7 +45,7 @@ export function useEventHandlers(setCopyJobState: setCopyJobStateType) {
source: {
...prevState.source,
subscription: data || null,
account: null,
account: null, // reset on subscription change
},
};
}
@@ -62,9 +60,8 @@ export function useEventHandlers(setCopyJobState: setCopyJobStateType) {
}
return prevState;
});
setValidationCache(new Map<string, boolean>());
},
[setCopyJobState, setValidationCache],
[setCopyJobState],
);
const handleMigrationTypeChange = React.useCallback(
@@ -73,9 +70,8 @@ export function useEventHandlers(setCopyJobState: setCopyJobStateType) {
...prevState,
migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
}));
setValidationCache(new Map<string, boolean>());
},
[setCopyJobState, setValidationCache],
[setCopyJobState],
);
return { handleSelectSourceAccount, handleMigrationTypeChange };

View File

@@ -1,13 +1,12 @@
/* eslint-disable react/display-name */
import { Stack } from "@fluentui/react";
import React from "react";
import { apiType } from "UserContext";
import { DatabaseAccount, Subscription } from "../../../../../Contracts/DataModels";
import { useDatabaseAccounts } from "../../../../../hooks/useDatabaseAccounts";
import { useSubscriptions } from "../../../../../hooks/useSubscriptions";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
import { CopyJobMigrationType } from "../../../Enums";
import { AccountDropdown } from "./Components/AccountDropdown";
import { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox";
import { SubscriptionDropdown } from "./Components/SubscriptionDropdown";
@@ -16,11 +15,12 @@ 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);
const sqlApiOnlyAccounts: DatabaseAccount[] = allAccounts?.filter((account) => apiType(account) === "SQL");
const sqlApiOnlyAccounts: DatabaseAccount[] = allAccounts?.filter(
(account) => account.type === "SQL" || account.kind === "GlobalDocumentDB",
);
const { subscriptionOptions, accountOptions } = useDropdownOptions(subscriptions, sqlApiOnlyAccounts);
const { handleSelectSourceAccount, handleMigrationTypeChange } = useEventHandlers(setCopyJobState);
@@ -39,7 +39,7 @@ const SelectAccount = React.memo(() => {
<AccountDropdown
options={accountOptions}
selectedKey={selectedSourceAccountId}
selectedKey={copyJobState?.source?.account?.id}
disabled={!selectedSubscriptionId}
onChange={(_ev, option) => handleSelectSourceAccount("account", option?.data)}
/>

View File

@@ -1,5 +1,5 @@
import React from "react";
import { CopyJobContextState, DropdownOptionType } from "../../../../Types/CopyJobTypes";
import { CopyJobContextState, DropdownOptionType } from "../../../../Types";
export function dropDownChangeHandler(setCopyJobState: React.Dispatch<React.SetStateAction<CopyJobContextState>>) {
return (type: "sourceDatabase" | "sourceContainer" | "targetDatabase" | "targetContainer") =>

View File

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

View File

@@ -9,20 +9,18 @@ import { DatabaseContainerSection } from "./components/DatabaseContainerSection"
import { dropDownChangeHandler } from "./Events/DropDownChangeHandler";
import { useMemoizedSourceAndTargetData } from "./memoizedData";
type SelectSourceAndTargetContainers = {
showAddCollectionPanel?: () => void;
};
const SelectSourceAndTargetContainers = ({ showAddCollectionPanel }: SelectSourceAndTargetContainers) => {
const SelectSourceAndTargetContainers = () => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams } =
useMemoizedSourceAndTargetData(copyJobState);
// Custom hooks
const sourceDatabases = useDatabases(...sourceDbParams) || [];
const sourceContainers = useDataContainers(...sourceContainerParams) || [];
const targetDatabases = useDatabases(...targetDbParams) || [];
const targetContainers = useDataContainers(...targetContainerParams) || [];
// Memoize option objects for dropdowns
const sourceDatabaseOptions = React.useMemo(
() => sourceDatabases.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })),
[sourceDatabases],
@@ -66,7 +64,6 @@ const SelectSourceAndTargetContainers = ({ showAddCollectionPanel }: SelectSourc
selectedContainer={target?.containerId}
containerDisabled={!target?.databaseId}
containerOnChange={onDropdownChange("targetContainer")}
handleOnDemandCreateContainer={showAddCollectionPanel}
/>
</Stack>
);

View File

@@ -1,6 +1,6 @@
import React from "react";
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
import { CopyJobContextState, DatabaseParams, DataContainerParams } from "../../../Types/CopyJobTypes";
import { CopyJobContextState, DatabaseParams, DataContainerParams } from "../../../Types";
export function useMemoizedSourceAndTargetData(copyJobState: CopyJobContextState) {
const { source, target } = copyJobState ?? {};

View File

@@ -1,9 +1,7 @@
import { useCallback, useMemo, useReducer, useState } from "react";
import { useCallback, useMemo, useReducer } from "react";
import { useSidePanel } from "../../../../hooks/useSidePanel";
import { submitCreateCopyJob } from "../../Actions/CopyJobActions";
import { useCopyJobContext } from "../../Context/CopyJobContext";
import { getContainerIdentifiers, isIntraAccountCopy } from "../../CopyJobUtils";
import { CopyJobMigrationType } from "../../Enums/CopyJobEnums";
import { useCopyJobPrerequisitesCache } from "./useCopyJobPrerequisitesCache";
import { SCREEN_KEYS, useCreateCopyJobScreensList } from "./useCreateCopyJobScreensList";
@@ -33,31 +31,20 @@ function navigationReducer(state: NavigationState, action: Action): NavigationSt
}
export function useCopyJobNavigation() {
const [isLoading, setIsLoading] = useState(false);
const { copyJobState, resetCopyJobState, setContextError } = useCopyJobContext();
const { copyJobState, resetCopyJobState } = 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);
const isPrimaryDisabled = useMemo(() => {
if (isLoading) {
return true;
}
const context = currentScreenKey === SCREEN_KEYS.AssignPermissions ? cache : copyJobState;
return !currentScreen?.validations.every((v) => v.validate(context));
}, [currentScreen.key, copyJobState, cache, isLoading]);
}, [currentScreen.key, copyJobState, cache]);
const primaryBtnText = useMemo(() => {
if (currentScreenKey === SCREEN_KEYS.CreateCollection) {
return "Create";
} else if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) {
if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) {
return "Copy";
}
return "Next";
@@ -71,74 +58,9 @@ export function useCopyJobNavigation() {
useSidePanel.getState().closeSidePanel();
}, []);
const areContainersIdentical = () => {
const { source, target } = copyJobState;
const sourceIds = getContainerIdentifiers(source);
const targetIds = getContainerIdentifiers(target);
return (
isIntraAccountCopy(sourceIds.accountId, targetIds.accountId) &&
sourceIds.databaseId === targetIds.databaseId &&
sourceIds.containerId === targetIds.containerId
);
};
const shouldNotShowPermissionScreen = () => {
const { source, target, migrationType } = copyJobState;
const sourceIds = getContainerIdentifiers(source);
const targetIds = getContainerIdentifiers(target);
return (
migrationType === CopyJobMigrationType.Offline && isIntraAccountCopy(sourceIds.accountId, targetIds.accountId)
);
};
const handleCopyJobSubmission = async () => {
try {
setIsLoading(true);
await submitCreateCopyJob(copyJobState, handleCancel);
} catch (error: unknown) {
const errorMessage =
error instanceof Error
? error.message || "Failed to create copy job. Please try again later."
: "Failed to create copy job. Please try again later.";
setContextError(errorMessage);
} finally {
setIsLoading(false);
}
};
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.",
);
return;
}
setContextError(null);
const transitions = {
[SCREEN_KEYS.SelectAccount]: shouldNotShowPermissionScreen()
? SCREEN_KEYS.SelectSourceAndTargetContainers
: SCREEN_KEYS.AssignPermissions,
[SCREEN_KEYS.SelectAccount]: SCREEN_KEYS.AssignPermissions,
[SCREEN_KEYS.AssignPermissions]: SCREEN_KEYS.SelectSourceAndTargetContainers,
[SCREEN_KEYS.SelectSourceAndTargetContainers]: SCREEN_KEYS.PreviewCopyJob,
};
@@ -147,9 +69,13 @@ export function useCopyJobNavigation() {
if (nextScreen) {
dispatch({ type: "NEXT", nextScreen });
} else if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) {
handleCopyJobSubmission();
submitCreateCopyJob(copyJobState, handleCancel);
}
}, [currentScreenKey, copyJobState, areContainersIdentical, handleCopyJobSubmission]);
}, [currentScreenKey, copyJobState]);
const handlePrevious = useCallback(() => {
dispatch({ type: "PREVIOUS" });
}, []);
return {
currentScreen,
@@ -158,7 +84,6 @@ export function useCopyJobNavigation() {
handlePrimary,
handlePrevious,
handleCancel,
showAddCollectionPanel,
primaryBtnText,
};
}

View File

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

View File

@@ -3,14 +3,15 @@ export enum CopyJobMigrationType {
Online = "online",
}
// all checks will happen
export enum IdentityType {
SystemAssigned = "systemassigned",
UserAssigned = "userassigned",
None = "none",
SystemAssigned = "systemassigned", // "SystemAssigned"
UserAssigned = "userassigned", // "UserAssigned"
None = "none", // "None"
}
export enum DefaultIdentityType {
SystemAssignedIdentity = "systemassignedidentity",
SystemAssignedIdentity = "systemassignedidentity", // "SystemAssignedIdentity"
}
export enum BackupPolicyType {

View File

@@ -1,52 +1,38 @@
import { IconButton, IContextualMenuProps } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../ContainerCopyMessages";
import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums/CopyJobEnums";
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums";
import { CopyJobType } from "../../Types";
interface CopyJobActionMenuProps {
job: CopyJobType;
handleClick: HandleJobActionClickType;
handleClick: (job: CopyJobType, action: string) => void;
}
const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick }) => {
const [updatingJobAction, setUpdatingJobAction] = React.useState<{ jobName: string; action: string } | null>(null);
if (
[
CopyJobStatusType.Completed,
CopyJobStatusType.Cancelled,
CopyJobStatusType.Failed,
CopyJobStatusType.Faulted,
].includes(job.Status)
) {
if ([CopyJobStatusType.Completed, CopyJobStatusType.Cancelled].includes(job.Status)) {
return null;
}
const getMenuItems = (): IContextualMenuProps["items"] => {
const isThisJobUpdating = updatingJobAction?.jobName === job.Name;
const updatingAction = updatingJobAction?.action;
const baseItems = [
{
key: CopyJobActions.pause,
text: ContainerCopyMessages.MonitorJobs.Actions.pause,
iconProps: { iconName: "Pause" },
onClick: () => handleClick(job, CopyJobActions.pause, setUpdatingJobAction),
disabled: isThisJobUpdating && updatingAction === CopyJobActions.pause,
onClick: () => handleClick(job, CopyJobActions.pause),
},
{
key: CopyJobActions.cancel,
text: ContainerCopyMessages.MonitorJobs.Actions.cancel,
iconProps: { iconName: "Cancel" },
onClick: () => handleClick(job, CopyJobActions.cancel, setUpdatingJobAction),
disabled: isThisJobUpdating && updatingAction === CopyJobActions.cancel,
onClick: () => handleClick(job, CopyJobActions.cancel),
},
{
key: CopyJobActions.resume,
text: ContainerCopyMessages.MonitorJobs.Actions.resume,
iconProps: { iconName: "Play" },
onClick: () => handleClick(job, CopyJobActions.resume, setUpdatingJobAction),
disabled: isThisJobUpdating && updatingAction === CopyJobActions.resume,
onClick: () => handleClick(job, CopyJobActions.resume),
},
];
@@ -62,19 +48,18 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
[CopyJobStatusType.InProgress, CopyJobStatusType.Running, CopyJobStatusType.Partitioning].includes(job.Status)
) {
const filteredItems = baseItems.filter((item) => item.key !== CopyJobActions.resume);
if ((job.Mode ?? "").toLowerCase() === CopyJobMigrationType.Online) {
if (job.Mode === CopyJobMigrationType.Online) {
filteredItems.push({
key: CopyJobActions.complete,
text: ContainerCopyMessages.MonitorJobs.Actions.complete,
iconProps: { iconName: "CheckMark" },
onClick: () => handleClick(job, CopyJobActions.complete, setUpdatingJobAction),
disabled: isThisJobUpdating && updatingAction === CopyJobActions.complete,
onClick: () => handleClick(job, CopyJobActions.complete),
});
}
return filteredItems;
}
if ([CopyJobStatusType.Skipped].includes(job.Status)) {
if ([CopyJobStatusType.Failed, CopyJobStatusType.Faulted, CopyJobStatusType.Skipped].includes(job.Status)) {
return baseItems.filter((item) => item.key === CopyJobActions.resume);
}

View File

@@ -1,80 +1,79 @@
import { IColumn } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../ContainerCopyMessages";
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
import { CopyJobType } from "../../Types";
import CopyJobActionMenu from "./CopyJobActionMenu";
import CopyJobStatusWithIcon from "./CopyJobStatusWithIcon";
export const getColumns = (
handleSort: (columnKey: string) => void,
handleActionClick: HandleJobActionClickType,
handleActionClick: (job: CopyJobType, action: string) => void,
sortedColumnKey: string | undefined,
isSortedDescending: boolean,
): IColumn[] => [
{
key: "LastUpdatedTime",
name: ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime,
fieldName: "LastUpdatedTime",
minWidth: 140,
maxWidth: 300,
isResizable: true,
isSorted: sortedColumnKey === "timestamp",
isSortedDescending: isSortedDescending,
onColumnClick: () => handleSort("timestamp"),
},
{
key: "Name",
name: ContainerCopyMessages.MonitorJobs.Columns.name,
fieldName: "Name",
minWidth: 140,
maxWidth: 300,
isResizable: true,
isSorted: sortedColumnKey === "Name",
isSortedDescending: isSortedDescending,
onColumnClick: () => handleSort("Name"),
onRender: (job: CopyJobType) => <span className="jobNameLink">{job.Name}</span>,
},
{
key: "Mode",
name: ContainerCopyMessages.MonitorJobs.Columns.mode,
fieldName: "Mode",
minWidth: 90,
maxWidth: 200,
isResizable: true,
isSorted: sortedColumnKey === "Mode",
isSortedDescending: isSortedDescending,
onColumnClick: () => handleSort("Mode"),
},
{
key: "CompletionPercentage",
name: ContainerCopyMessages.MonitorJobs.Columns.completionPercentage,
fieldName: "CompletionPercentage",
minWidth: 110,
maxWidth: 200,
isResizable: true,
isSorted: sortedColumnKey === "CompletionPercentage",
isSortedDescending: isSortedDescending,
onRender: (job: CopyJobType) => `${job.CompletionPercentage}%`,
onColumnClick: () => handleSort("CompletionPercentage"),
},
{
key: "CopyJobStatus",
name: ContainerCopyMessages.MonitorJobs.Columns.status,
fieldName: "Status",
minWidth: 130,
maxWidth: 200,
isResizable: true,
isSorted: sortedColumnKey === "Status",
isSortedDescending: isSortedDescending,
onRender: (job: CopyJobType) => <CopyJobStatusWithIcon status={job.Status} />,
onColumnClick: () => handleSort("Status"),
},
{
key: "Actions",
name: "",
minWidth: 80,
maxWidth: 200,
isResizable: true,
onRender: (job: CopyJobType) => <CopyJobActionMenu job={job} handleClick={handleActionClick} />,
},
];
{
key: "LastUpdatedTime",
name: ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime,
fieldName: "LastUpdatedTime",
minWidth: 140,
maxWidth: 300,
isResizable: true,
isSorted: sortedColumnKey === "timestamp",
isSortedDescending: isSortedDescending,
onColumnClick: () => handleSort("timestamp"),
},
{
key: "Name",
name: ContainerCopyMessages.MonitorJobs.Columns.name,
fieldName: "Name",
minWidth: 140,
maxWidth: 300,
isResizable: true,
isSorted: sortedColumnKey === "Name",
isSortedDescending: isSortedDescending,
onColumnClick: () => handleSort("Name"),
},
{
key: "Mode",
name: ContainerCopyMessages.MonitorJobs.Columns.mode,
fieldName: "Mode",
minWidth: 90,
maxWidth: 200,
isResizable: true,
isSorted: sortedColumnKey === "Mode",
isSortedDescending: isSortedDescending,
onColumnClick: () => handleSort("Mode"),
},
{
key: "CompletionPercentage",
name: ContainerCopyMessages.MonitorJobs.Columns.completionPercentage,
fieldName: "CompletionPercentage",
minWidth: 110,
maxWidth: 200,
isResizable: true,
isSorted: sortedColumnKey === "CompletionPercentage",
isSortedDescending: isSortedDescending,
onRender: (job: CopyJobType) => `${job.CompletionPercentage}%`,
onColumnClick: () => handleSort("CompletionPercentage"),
},
{
key: "CopyJobStatus",
name: ContainerCopyMessages.MonitorJobs.Columns.status,
fieldName: "Status",
minWidth: 130,
maxWidth: 200,
isResizable: true,
isSorted: sortedColumnKey === "Status",
isSortedDescending: isSortedDescending,
onRender: (job: CopyJobType) => <CopyJobStatusWithIcon status={job.Status} />,
onColumnClick: () => handleSort("Status"),
},
{
key: "Actions",
name: "",
minWidth: 80,
maxWidth: 200,
isResizable: true,
onRender: (job: CopyJobType) => <CopyJobActionMenu job={job} handleClick={handleActionClick} />,
},
];

View File

@@ -1,118 +0,0 @@
import { DetailsList, DetailsListLayoutMode, IColumn, Stack, Text } from "@fluentui/react";
import React, { memo } from "react";
import ContainerCopyMessages from "../../ContainerCopyMessages";
import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
import { CopyJobType } from "../../Types/CopyJobTypes";
import CopyJobStatusWithIcon from "./CopyJobStatusWithIcon";
interface CopyJobDetailsProps {
job: CopyJobType;
}
const sectionCss = {
verticalAlign: { display: "flex", flexDirection: "column" } as React.CSSProperties,
headingText: { marginBottom: "10px" } as React.CSSProperties,
};
const commonProps = {
minWidth: 100,
maxWidth: 130,
styles: {
root: {
whiteSpace: "normal",
lineHeight: "1.2",
wordBreak: "break-word",
},
},
};
const getCopyJobDetailsListColumns = (): IColumn[] => {
return [
{
key: "sourcedbcol",
name: ContainerCopyMessages.sourceDatabaseLabel,
fieldName: "sourceDatabaseName",
...commonProps,
},
{
key: "sourcecol",
name: ContainerCopyMessages.sourceContainerLabel,
fieldName: "sourceContainerName",
...commonProps,
},
{
key: "targetdbcol",
name: ContainerCopyMessages.targetDatabaseLabel,
fieldName: "targetDatabaseName",
...commonProps,
},
{
key: "targetcol",
name: ContainerCopyMessages.targetContainerLabel,
fieldName: "targetContainerName",
...commonProps,
},
{
key: "statuscol",
name: ContainerCopyMessages.MonitorJobs.Columns.status,
fieldName: "jobStatus",
onRender: ({ jobStatus }: { jobStatus: CopyJobStatusType }) => <CopyJobStatusWithIcon status={jobStatus} />,
...commonProps,
},
];
};
const CopyJobDetails: React.FC<CopyJobDetailsProps> = ({ job }) => {
const selectedContainers = [
{
sourceContainerName: job?.Source?.containerName || "N/A",
sourceDatabaseName: job?.Source?.databaseName || "N/A",
targetContainerName: job?.Destination?.containerName || "N/A",
targetDatabaseName: job?.Destination?.databaseName || "N/A",
jobStatus: job?.Status || "",
},
];
return (
<Stack className="copyJobDetailsContainer" tokens={{ childrenGap: 15 }} data-testid="copy-job-details">
{job.Error ? (
<Stack.Item data-testid="error-stack" style={sectionCss.verticalAlign}>
<Text className="bold" style={sectionCss.headingText}>
{ContainerCopyMessages.errorTitle}
</Text>
<Text as="pre" style={{ whiteSpace: "pre-wrap" }}>
{job.Error.message}
</Text>
</Stack.Item>
) : null}
<Stack.Item data-testid="selectedcollection-stack">
<Stack tokens={{ childrenGap: 15 }}>
<Stack.Item style={sectionCss.verticalAlign}>
<Text className="bold">{ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime}</Text>
<Text>{job.LastUpdatedTime}</Text>
</Stack.Item>
<Stack.Item style={sectionCss.verticalAlign}>
<Text className="bold">{ContainerCopyMessages.sourceAccountLabel}</Text>
<Text>{job.Source?.remoteAccountName}</Text>
</Stack.Item>
<Stack.Item style={sectionCss.verticalAlign}>
<Text className="bold">{ContainerCopyMessages.MonitorJobs.Columns.mode}</Text>
<Text>{job.Mode}</Text>
</Stack.Item>
</Stack>
</Stack.Item>
<Stack.Item style={sectionCss.verticalAlign}>
<DetailsList
items={selectedContainers}
layoutMode={DetailsListLayoutMode.justified}
checkboxVisibility={2}
columns={getCopyJobDetailsListColumns()}
/>
</Stack.Item>
</Stack>
);
};
export default memo(CopyJobDetails, (prevProps, nextProps) => {
return prevProps.job.ID === nextProps.job.ID && prevProps.job.Error === nextProps.job.Error;
});

View File

@@ -1,7 +1,7 @@
import { FontIcon, getTheme, mergeStyles, mergeStyleSets, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
import { FontIcon, getTheme, mergeStyles, mergeStyleSets, Stack, Text } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../ContainerCopyMessages";
import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
import { CopyJobStatusType } from "../../Enums";
const theme = getTheme();
@@ -24,8 +24,11 @@ const classNames = mergeStyleSets({
unknown: [{ color: theme.semanticColors.bodySubtext }, iconClass],
});
const iconMap: Partial<Record<CopyJobStatusType, string>> = {
[CopyJobStatusType.Pending]: "Clock",
const iconMap: Record<CopyJobStatusType, string> = {
[CopyJobStatusType.Pending]: "StatusCircleRing",
[CopyJobStatusType.InProgress]: "ProgressRingDots",
[CopyJobStatusType.Running]: "ProgressRingDots",
[CopyJobStatusType.Partitioning]: "ProgressRingDots",
[CopyJobStatusType.Paused]: "CirclePause",
[CopyJobStatusType.Skipped]: "StatusCircleBlock2",
[CopyJobStatusType.Cancelled]: "StatusErrorFull",
@@ -36,24 +39,13 @@ const iconMap: Partial<Record<CopyJobStatusType, string>> = {
const CopyJobStatusWithIcon: React.FC<{ status: CopyJobStatusType }> = ({ status }) => {
const statusText = ContainerCopyMessages.MonitorJobs.Status[status] || "Unknown";
const isSpinnerStatus = [
CopyJobStatusType.Running,
CopyJobStatusType.InProgress,
CopyJobStatusType.Partitioning,
].includes(status);
return (
<Stack horizontal verticalAlign="center">
{isSpinnerStatus ? (
<Spinner size={SpinnerSize.small} style={{ marginRight: "8px" }} />
) : (
<FontIcon
aria-label={status}
iconName={iconMap[status] || "UnknownSolid"}
className={classNames[status] || classNames.unknown}
/>
)}
<FontIcon
aria-label={status}
iconName={iconMap[status] || "UnknownSolid"}
className={classNames[status] || classNames.unknown}
/>
<Text>{statusText}</Text>
</Stack>
);

View File

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

View File

@@ -12,33 +12,30 @@ import {
StickyPositionType,
} from "@fluentui/react";
import React, { useEffect } from "react";
import Pager from "../../../../Common/Pager";
import { openCopyJobDetailsPanel } from "../../Actions/CopyJobActions";
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
import { CopyJobType } from "../../Types";
import { getColumns } from "./CopyJobColumns";
interface CopyJobsListProps {
jobs: CopyJobType[];
handleActionClick: HandleJobActionClickType;
handleActionClick: (job: CopyJobType, action: string) => void;
pageSize?: number;
}
const styles = {
container: { height: "calc(100vh - 25em)" } as React.CSSProperties,
container: { height: "calc(100vh - 15em)" } as React.CSSProperties,
stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties,
};
const PAGE_SIZE = 10;
const PAGE_SIZE = 100; // Number of items per page
const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => {
const [startIndex, setStartIndex] = React.useState(0);
const [startIndex] = React.useState(0);
const [sortedJobs, setSortedJobs] = React.useState<CopyJobType[]>(jobs);
const [sortedColumnKey, setSortedColumnKey] = React.useState<string | undefined>(undefined);
const [isSortedDescending, setIsSortedDescending] = React.useState<boolean>(false);
useEffect(() => {
setSortedJobs(jobs);
setStartIndex(0);
}, [jobs]);
const handleSort = (columnKey: string) => {
@@ -55,7 +52,6 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
setSortedJobs(sorted);
setSortedColumnKey(columnKey);
setIsSortedDescending(isDescending);
setStartIndex(0);
};
const columns: IColumn[] = React.useMemo(
@@ -64,7 +60,8 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
);
const _handleRowClick = React.useCallback((job: CopyJobType) => {
openCopyJobDetailsPanel(job);
// eslint-disable-next-line no-console
console.log("Row clicked:", job);
}, []);
const _onRenderRow = React.useCallback((props: any) => {
@@ -75,6 +72,8 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
);
}, []);
// const totalCount = jobs.length;
return (
<div style={styles.container}>
<Stack verticalFill={true}>
@@ -96,21 +95,6 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
/>
</ScrollablePane>
</Stack.Item>
{sortedJobs.length > pageSize && (
<Stack.Item>
<Pager
disabled={false}
startIndex={startIndex}
totalCount={sortedJobs.length}
pageSize={pageSize}
onLoadPage={(startIdx /* pageSize */) => {
setStartIndex(startIdx);
}}
showFirstLast={true}
showItemCount={true}
/>
</Stack.Item>
)}
</Stack>
</div>
);

View File

@@ -1,46 +1,44 @@
/* 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 ShimmerTree, { IndentLevel } from "Common/ShimmerTree";
import React, { forwardRef, useEffect, useImperativeHandle } from "react";
import { getCopyJobs, updateCopyJobStatus } from "../Actions/CopyJobActions";
import { convertToCamelCase } from "../CopyJobUtils";
import { CopyJobStatusType } from "../Enums/CopyJobEnums";
import { CopyJobStatusType } from "../Enums";
import CopyJobsNotFound from "../MonitorCopyJobs/Components/CopyJobs.NotFound";
import { CopyJobType, JobActionUpdatorType } from "../Types/CopyJobTypes";
import { CopyJobType } from "../Types";
import CopyJobsList from "./Components/CopyJobsList";
const FETCH_INTERVAL_MS = 30 * 1000;
const FETCH_INTERVAL_MS = 30 * 1000; // Interval time in milliseconds (30 seconds)
interface MonitorCopyJobsProps {
explorer: Explorer;
}
interface MonitorCopyJobsProps {}
export interface MonitorCopyJobsRef {
refreshJobList: () => void;
}
const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>(({ explorer }, ref) => {
const [loading, setLoading] = React.useState(true);
const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>((_props, ref) => {
const [loading, setLoading] = React.useState(true); // Start with loading as true
const [error, setError] = React.useState<string | null>(null);
const [jobs, setJobs] = React.useState<CopyJobType[]>([]);
const isUpdatingRef = React.useRef(false);
const isFirstFetchRef = React.useRef(true);
const isUpdatingRef = React.useRef(false); // Use ref to track updating state
const isFirstFetchRef = React.useRef(true); // Use ref to track the first fetch
const indentLevels = React.useMemo<IndentLevel[]>(() => Array(7).fill({ level: 0, width: "100%" }), []);
const fetchJobs = React.useCallback(async () => {
if (isUpdatingRef.current) {
return;
}
} // Skip if an update is in progress
try {
if (isFirstFetchRef.current) {
setLoading(true);
}
} // Show loading spinner only for the first fetch
setError(null);
const response = await getCopyJobs();
setJobs((prevJobs) => {
// Only update jobs if they are different
const isSame = JSON.stringify(prevJobs) === JSON.stringify(response);
return isSame ? prevJobs : response;
});
@@ -48,8 +46,8 @@ const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>(({
setError(error.message || "Failed to load copy jobs. Please try again later.");
} finally {
if (isFirstFetchRef.current) {
setLoading(false);
isFirstFetchRef.current = false;
setLoading(false); // Hide loading spinner after the first fetch
isFirstFetchRef.current = false; // Mark the first fetch as complete
}
}
}, []);
@@ -71,43 +69,38 @@ const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>(({
},
}));
const handleActionClick = React.useCallback(
async (job: CopyJobType, action: string, setUpdatingJobAction: JobActionUpdatorType) => {
try {
isUpdatingRef.current = true;
setUpdatingJobAction({ jobName: job.Name, action });
const updatedCopyJob = await updateCopyJobStatus(job, action);
if (updatedCopyJob) {
setJobs((prevJobs) =>
prevJobs.map((prevJob) =>
prevJob.Name === updatedCopyJob.properties.jobName
? {
...prevJob,
Status: convertToCamelCase(updatedCopyJob.properties.status) as CopyJobStatusType,
}
: prevJob,
),
);
}
} catch (error) {
setError(error.message || "Failed to update copy job status. Please try again later.");
} finally {
isUpdatingRef.current = false;
setUpdatingJobAction(null);
const handleActionClick = React.useCallback(async (job: CopyJobType, action: string) => {
try {
isUpdatingRef.current = true; // Mark as updating
const updatedCopyJob = await updateCopyJobStatus(job, action);
if (updatedCopyJob) {
setJobs((prevJobs) =>
prevJobs.map((prevJob) =>
prevJob.Name === updatedCopyJob.properties.jobName
? {
...prevJob,
Status: convertToCamelCase(updatedCopyJob.properties.status) as CopyJobStatusType,
}
: prevJob,
),
);
}
},
[],
);
} catch (error) {
setError(error.message || "Failed to update copy job status. Please try again later.");
} finally {
isUpdatingRef.current = false; // Mark as not updating
}
}, []);
const renderJobsList = () => {
const memoizedJobsList = React.useMemo(() => {
if (loading) {
return null;
}
if (jobs.length > 0) {
return <CopyJobsList jobs={jobs} handleActionClick={handleActionClick} />;
}
return <CopyJobsNotFound explorer={explorer} />;
};
return <CopyJobsNotFound />;
}, [jobs, loading, handleActionClick]);
return (
<Stack className="monitorCopyJobs flexContainer">
@@ -117,7 +110,7 @@ const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>(({
{error}
</MessageBar>
)}
{renderJobsList()}
{memoizedJobsList}
</Stack>
);
});

View File

@@ -1,12 +1,11 @@
import { DatabaseAccount, Subscription } from "Contracts/DataModels";
import React from "react";
import { ApiType } from "UserContext";
import { CosmosSqlDataTransferDataSourceSink } from "../../../Utils/arm/generatedClients/dataTransferService/types";
import Explorer from "../../Explorer";
import { CopyJobMigrationType, CopyJobStatusType } from "../Enums/CopyJobEnums";
import { CopyJobMigrationType, CopyJobStatusType } from "../Enums";
export interface ContainerCopyProps {
explorer: Explorer;
container: Explorer;
}
export type CopyJobCommandBarBtnType = {
@@ -48,19 +47,20 @@ export interface DatabaseContainerSectionProps {
selectedContainer: string;
containerDisabled?: boolean;
containerOnChange: (ev: React.FormEvent<HTMLDivElement>, option: DropdownOptionType) => void;
handleOnDemandCreateContainer?: () => void;
}
export interface CopyJobContextState {
jobName: string;
migrationType: CopyJobMigrationType;
sourceReadAccessFromTarget?: boolean;
// source details
source: {
subscription: Subscription;
account: DatabaseAccount;
databaseId: string;
containerId: string;
};
// target details
target: {
subscriptionId: string;
account: DatabaseAccount;
@@ -74,14 +74,11 @@ export interface CopyJobFlowType {
}
export interface CopyJobContextProviderType {
contextError: string | null;
setContextError: React.Dispatch<React.SetStateAction<string | null>>;
flow: CopyJobFlowType;
setFlow: React.Dispatch<React.SetStateAction<CopyJobFlowType>>;
copyJobState: CopyJobContextState | null;
setCopyJobState: React.Dispatch<React.SetStateAction<CopyJobContextState>>;
resetCopyJobState: () => void;
explorer?: Explorer;
}
export type CopyJobType = {
@@ -94,8 +91,6 @@ export type CopyJobType = {
LastUpdatedTime: string;
timestamp: number;
Error?: CopyJobErrorType;
Source: CosmosSqlDataTransferDataSourceSink;
Destination: CosmosSqlDataTransferDataSourceSink;
};
export interface CopyJobErrorType {
@@ -135,13 +130,3 @@ export type DataTransferJobType = {
};
};
};
export type JobActionUpdatorType = React.Dispatch<React.SetStateAction<{ jobName: string; action: string } | null>>;
export type HandleJobActionClickType = (
job: CopyJobType,
action: string,
setUpdatingJobAction: JobActionUpdatorType,
) => void;
export type AccountValidatorFn = (prev: DatabaseAccount, next: DatabaseAccount) => boolean;

View File

@@ -19,11 +19,9 @@
.createCopyJobScreensContainer {
height: 100%;
padding: 1em 1.5em;
.pointInTimeRestoreContainer, .onlineCopyContainer {
position: relative;
.bold {
font-weight: 600;
}
label {
padding: 0;
}
@@ -63,7 +61,6 @@
}
}
.popover-container {
border-radius: 6px;
button[disabled] {
cursor: not-allowed;
opacity: 0.8;
@@ -71,33 +68,12 @@
}
.foreground {
z-index: 10;
background-color: #f9f9f9;
background-color: white;
padding: 20px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transform: translate(0%, -9%);
position: absolute;
}
.createCopyJobErrorMessageBar {
margin-bottom: 2em;
}
.create-container-link-btn {
padding: 0;
height: 25px;
color: @LinkColor;
&:focus {
outline: none;
}
}
/* Create collection panel */
.panelFormWrapper .panelMainContent {
padding: 0;
}
.createCopyJobScreensFooter {
margin-top: 50px;
}
}
.monitorCopyJobs {
@@ -138,13 +114,6 @@
min-height: 48px;
display: flex;
align-items: center;
.jobNameLink {
color: @LinkColor;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
}
}
@@ -157,28 +126,3 @@
}
}
}
.copyJobDetailsContainer {
padding: 1em 0 0 2em;
.ms-DetailsList {
width: 100%;
.ms-DetailsHeader-cellTitle, .ms-DetailsRow-cell {
padding-left: 0;
}
.ms-DetailsRow-cell {
font-size: @DefaultFontSize;
color: @BaseHigh;
}
}
}
.bold {
font-weight: 600;
}
.fullWidth {
width: 100%;
display: flex;
justify-content: center;
}

View File

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

View File

@@ -7,7 +7,7 @@ import {
AddGlobalSecondaryIndexPanelProps,
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
import { useDatabases } from "Explorer/useDatabases";
import { isFabric, isFabricNative, openRestoreContainerDialog } from "Platform/Fabric/FabricUtil";
import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil";
import { Action } from "Shared/Telemetry/TelemetryConstants";
import { traceOpen } from "Shared/Telemetry/TelemetryProcessor";
import { ReactTabKind, useTabs } from "hooks/useTabs";
@@ -35,7 +35,6 @@ import StoredProcedure from "./Tree/StoredProcedure";
import Trigger from "./Tree/Trigger";
import UserDefinedFunction from "./Tree/UserDefinedFunction";
import { useSelectedNode } from "./useSelectedNode";
import { extractFeatures } from "../Platform/Hosted/extractFeatures";
export interface CollectionContextMenuButtonParams {
databaseId: string;
@@ -61,17 +60,6 @@ export const createDatabaseContextMenu = (container: Explorer, databaseId: strin
},
];
if (isFabricNative() && !userContext.fabricContext?.isReadOnly) {
const features = extractFeatures();
if (features?.enableRestoreContainer) {
items.push({
iconSrc: AddCollectionIcon,
onClick: () => openRestoreContainerDialog(),
label: `Restore ${getCollectionName()}`,
});
}
}
if (!isFabricNative() && (userContext.apiType !== "Tables" || userContext.features.enableSDKoperations)) {
items.push({
iconSrc: DeleteDatabaseIcon,

View File

@@ -12,7 +12,6 @@ import {
import { FullTextIndex, FullTextPath, FullTextPolicy } from "Contracts/DataModels";
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
import * as React from "react";
import { isFullTextSearchPreviewFeaturesEnabled } from "Utils/CapabilityUtils";
export interface FullTextPoliciesComponentProps {
fullTextPolicy: FullTextPolicy;
@@ -23,7 +22,6 @@ export interface FullTextPoliciesComponentProps {
) => void;
discardChanges?: boolean;
onChangesDiscarded?: () => void;
englishOnly?: boolean;
}
export interface FullTextPolicyData {
@@ -68,7 +66,6 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
onFullTextPathChange,
discardChanges,
onChangesDiscarded,
englishOnly,
}): JSX.Element => {
const getFullTextPathError = (path: string, index?: number): string => {
let error = "";
@@ -90,7 +87,6 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
if (!fullTextPolicy) {
fullTextPolicy = { defaultLanguage: getFullTextLanguageOptions()[0].key as never, fullTextPaths: [] };
}
return fullTextPolicy.fullTextPaths.map((fullTextPath: FullTextPath) => ({
...fullTextPath,
pathError: getFullTextPathError(fullTextPath.path),
@@ -170,7 +166,7 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
<Dropdown
required={true}
styles={dropdownStyles}
options={getFullTextLanguageOptions(englishOnly)}
options={getFullTextLanguageOptions()}
selectedKey={defaultLanguage}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
setDefaultLanguage(option.key as never)
@@ -215,7 +211,7 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
<Dropdown
required={true}
styles={dropdownStyles}
options={getFullTextLanguageOptions(englishOnly)}
options={getFullTextLanguageOptions()}
selectedKey={fullTextPolicy.language}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
onFullTextPathPolicyChange(index, option)
@@ -233,30 +229,11 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
);
};
export const getFullTextLanguageOptions = (englishOnly?: boolean): IDropdownOption[] => {
const multiLanguageSupportEnabled: boolean = isFullTextSearchPreviewFeaturesEnabled() && !englishOnly;
const fullTextLanguageOptions: IDropdownOption[] = [
export const getFullTextLanguageOptions = (): IDropdownOption[] => {
return [
{
key: "en-US",
text: "English (US)",
},
...(multiLanguageSupportEnabled
? [
{
key: "fr-FR",
text: "French",
},
{
key: "de-DE",
text: "German",
},
{
key: "es-ES",
text: "Spanish",
},
]
: []),
];
return fullTextLanguageOptions;
};

View File

@@ -24,11 +24,6 @@ jest.mock("../../../Common/dataAccess/updateCollection", () => ({
changeFeedPolicy: undefined,
analyticalStorageTtl: undefined,
geospatialConfig: undefined,
dataMaskingPolicy: {
includedPaths: [],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
},
indexes: [],
}),
}));
@@ -97,6 +92,7 @@ describe("SettingsComponent", () => {
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
expect(settingsComponentInstance.hasProvisioningTypeChanged()).toEqual(false);
wrapper.setState({
userCanChangeProvisioningTypes: true,
isAutoPilotSelected: true,
wasAutopilotOriginallySet: false,
autoPilotThroughput: 1000,
@@ -290,157 +286,4 @@ describe("SettingsComponent", () => {
expect(wrapper.state("isThroughputBucketsSaveable")).toBe(false);
});
it("should handle data masking policy updates correctly", async () => {
updateUserContext({
apiType: "SQL",
authType: AuthType.AAD,
});
const wrapper = shallow(<SettingsComponent {...baseProps} />);
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
wrapper.setState({
dataMaskingContent: {
includedPaths: [],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
},
dataMaskingContentBaseline: {
includedPaths: [],
excludedPaths: [],
isPolicyEnabled: false,
},
isDataMaskingDirty: true,
});
await settingsComponentInstance.onSaveClick();
// The test needs to match what onDataMaskingContentChange returns
expect(updateCollection).toHaveBeenCalled();
expect(wrapper.state("isDataMaskingDirty")).toBe(false);
expect(wrapper.state("dataMaskingContentBaseline")).toEqual({
includedPaths: [],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
});
});
it("should validate data masking policy content", () => {
const wrapper = shallow(<SettingsComponent {...baseProps} />);
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
// Test with invalid data structure
// Use invalid data type for testing validation
type InvalidPolicy = Omit<DataModels.DataMaskingPolicy, "includedPaths"> & { includedPaths: string };
const invalidPolicy: InvalidPolicy = {
includedPaths: "invalid",
excludedPaths: [],
isPolicyEnabled: false,
};
// Use type assertion since we're deliberately testing with invalid data
settingsComponentInstance["onDataMaskingContentChange"](invalidPolicy as unknown as DataModels.DataMaskingPolicy);
// State should update with the content but also set validation errors
expect(wrapper.state("dataMaskingContent")).toEqual({
includedPaths: "invalid",
excludedPaths: [],
isPolicyEnabled: false,
});
expect(wrapper.state("dataMaskingValidationErrors")).toEqual(["includedPaths must be an array"]);
// Test with valid data
const validPolicy = {
includedPaths: [
{
path: "/path1",
strategy: "mask",
startPosition: 0,
length: 4,
},
],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
};
settingsComponentInstance["onDataMaskingContentChange"](validPolicy);
// State should update with valid data and no validation errors
expect(wrapper.state("dataMaskingContent")).toEqual(validPolicy);
expect(wrapper.state("dataMaskingValidationErrors")).toEqual([]);
});
it("should handle data masking discard correctly", () => {
const wrapper = shallow(<SettingsComponent {...baseProps} />);
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
const baselinePolicy = {
includedPaths: [
{
path: "/basePath",
strategy: "mask",
startPosition: 0,
length: 4,
},
],
excludedPaths: ["/excludedPath1"],
isPolicyEnabled: false,
};
const modifiedPolicy = {
includedPaths: [
{
path: "/newPath",
strategy: "mask",
startPosition: 1,
length: 5,
},
],
excludedPaths: ["/excludedPath2"],
isPolicyEnabled: true,
};
// Set initial state
wrapper.setState({
dataMaskingContent: modifiedPolicy,
dataMaskingContentBaseline: baselinePolicy,
isDataMaskingDirty: true,
});
// Call revert
settingsComponentInstance.onRevertClick();
// Verify state is reset
expect(wrapper.state("dataMaskingContent")).toEqual(baselinePolicy);
expect(wrapper.state("isDataMaskingDirty")).toBe(false);
expect(wrapper.state("shouldDiscardDataMasking")).toBe(true);
});
it("should disable save button when data masking has validation errors", () => {
const wrapper = shallow(<SettingsComponent {...baseProps} />);
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
// Initially, save button should be disabled
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(false);
// Make data masking dirty with valid data
wrapper.setState({
isDataMaskingDirty: true,
dataMaskingValidationErrors: [],
});
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true);
// Add validation errors - save should be disabled
wrapper.setState({
dataMaskingValidationErrors: ["includedPaths must be an array"],
});
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(false);
// Clear validation errors - save should be enabled again
wrapper.setState({
dataMaskingValidationErrors: [],
});
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true);
});
});

View File

@@ -15,7 +15,7 @@ import {
} from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent";
import { useDatabases } from "Explorer/useDatabases";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isRunningOnPublicCloud } from "Utils/CloudUtils";
import * as React from "react";
import DiscardIcon from "../../../../images/discard.svg";
@@ -47,7 +47,6 @@ import {
ConflictResolutionComponent,
ConflictResolutionComponentProps,
} from "./SettingsSubComponents/ConflictResolutionComponent";
import { DataMaskingComponent, DataMaskingComponentProps } from "./SettingsSubComponents/DataMaskingComponent";
import {
GlobalSecondaryIndexComponent,
GlobalSecondaryIndexComponentProps,
@@ -152,12 +151,6 @@ export interface SettingsComponentState {
conflictResolutionPolicyProcedureBaseline: string;
isConflictResolutionDirty: boolean;
dataMaskingContent: DataModels.DataMaskingPolicy;
dataMaskingContentBaseline: DataModels.DataMaskingPolicy;
shouldDiscardDataMasking: boolean;
isDataMaskingDirty: boolean;
dataMaskingValidationErrors: string[];
selectedTab: SettingsV2TabTypes;
}
@@ -265,12 +258,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
shouldDiscardComputedProperties: false,
isComputedPropertiesDirty: false,
dataMaskingContent: undefined,
dataMaskingContentBaseline: undefined,
shouldDiscardDataMasking: false,
isDataMaskingDirty: false,
dataMaskingValidationErrors: [],
conflictResolutionPolicyMode: undefined,
conflictResolutionPolicyModeBaseline: undefined,
conflictResolutionPolicyPath: undefined,
@@ -347,7 +334,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
};
public isSaveSettingsButtonEnabled = (): boolean => {
if (this.isOfferReplacePending() || this.props.settingsTab.isExecuting()) {
if (this.isOfferReplacePending()) {
return false;
}
@@ -355,10 +342,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
return false;
}
if (this.state.dataMaskingValidationErrors.length > 0) {
return false;
}
return (
this.state.isScaleSaveable ||
this.state.isSubSettingsSaveable ||
@@ -366,16 +349,12 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.state.isIndexingPolicyDirty ||
this.state.isConflictResolutionDirty ||
this.state.isComputedPropertiesDirty ||
this.state.isDataMaskingDirty ||
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicySaveable) ||
this.state.isThroughputBucketsSaveable
);
};
public isDiscardSettingsButtonEnabled = (): boolean => {
if (this.props.settingsTab.isExecuting()) {
return false;
}
return (
this.state.isScaleDiscardable ||
this.state.isSubSettingsDiscardable ||
@@ -383,7 +362,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.state.isIndexingPolicyDirty ||
this.state.isConflictResolutionDirty ||
this.state.isComputedPropertiesDirty ||
this.state.isDataMaskingDirty ||
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicyDiscardable) ||
this.state.isThroughputBucketsSaveable
);
@@ -439,6 +417,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
: this.saveDatabaseSettings(startKey));
} catch (error) {
this.props.settingsTab.isExecutionError(true);
console.error(error);
traceFailure(
Action.SettingsV2Updated,
{
@@ -455,6 +434,8 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
} finally {
this.props.settingsTab.isExecuting(false);
// Send message to Fabric no matter success or failure.
// In case of failure, saveCollectionSettings might have partially succeeded and Fabric needs to refresh
if (isFabricNative() && this.isCollectionSettingsTab) {
sendMessage({
type: FabricMessageTypes.ContainerUpdated,
@@ -465,9 +446,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
};
public onRevertClick = (): void => {
if (this.props.settingsTab.isExecuting()) {
return;
}
trace(Action.SettingsV2Discarded, ActionModifiers.Mark, {
message: "Settings Discarded",
});
@@ -508,10 +486,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
computedPropertiesContent: this.state.computedPropertiesContentBaseline,
shouldDiscardComputedProperties: true,
isComputedPropertiesDirty: false,
dataMaskingContent: this.state.dataMaskingContentBaseline,
shouldDiscardDataMasking: true,
isDataMaskingDirty: false,
dataMaskingValidationErrors: [],
});
};
@@ -674,36 +648,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private onComputedPropertiesDirtyChange = (isComputedPropertiesDirty: boolean): void =>
this.setState({ isComputedPropertiesDirty: isComputedPropertiesDirty });
private onDataMaskingContentChange = (newDataMasking: DataModels.DataMaskingPolicy): void => {
if (!newDataMasking.excludedPaths) {
newDataMasking.excludedPaths = [];
}
if (!newDataMasking.includedPaths) {
newDataMasking.includedPaths = [];
}
const validationErrors = [];
if (!Array.isArray(newDataMasking.includedPaths)) {
validationErrors.push("includedPaths must be an array");
}
if (!Array.isArray(newDataMasking.excludedPaths)) {
validationErrors.push("excludedPaths must be an array");
}
if (typeof newDataMasking.isPolicyEnabled !== "boolean") {
validationErrors.push("isPolicyEnabled must be a boolean");
}
this.setState({
dataMaskingContent: newDataMasking,
dataMaskingValidationErrors: validationErrors,
});
};
private resetShouldDiscardDataMasking = (): void => this.setState({ shouldDiscardDataMasking: false });
private onDataMaskingDirtyChange = (isDataMaskingDirty: boolean): void =>
this.setState({ isDataMaskingDirty: isDataMaskingDirty });
private calculateTotalThroughputUsed = (): void => {
this.totalThroughputUsed = 0;
(useDatabases.getState().databases || []).forEach(async (database) => {
@@ -828,11 +772,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
const fullTextPolicy: DataModels.FullTextPolicy =
this.collection.fullTextPolicy && this.collection.fullTextPolicy();
const indexingPolicyContent = this.collection.indexingPolicy();
const dataMaskingContent: DataModels.DataMaskingPolicy = {
includedPaths: this.collection.dataMaskingPolicy?.()?.includedPaths || [],
excludedPaths: this.collection.dataMaskingPolicy?.()?.excludedPaths || [],
isPolicyEnabled: this.collection.dataMaskingPolicy?.()?.isPolicyEnabled ?? true,
};
const conflictResolutionPolicy: DataModels.ConflictResolutionPolicy =
this.collection.conflictResolutionPolicy && this.collection.conflictResolutionPolicy();
const conflictResolutionPolicyMode = parseConflictResolutionMode(conflictResolutionPolicy?.mode);
@@ -885,14 +824,11 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
geospatialConfigTypeBaseline: geoSpatialConfigType,
computedPropertiesContent: computedPropertiesContent,
computedPropertiesContentBaseline: computedPropertiesContent,
dataMaskingContent: dataMaskingContent,
dataMaskingContentBaseline: dataMaskingContent,
};
};
private getTabsButtons = (): CommandButtonComponentProps[] => {
const buttons: CommandButtonComponentProps[] = [];
const isExecuting = this.props.settingsTab.isExecuting();
if (this.saveSettingsButton.isVisible()) {
const label = "Save";
buttons.push({
@@ -902,7 +838,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
commandButtonLabel: label,
ariaLabel: label,
hasPopup: false,
disabled: isExecuting || !this.saveSettingsButton.isEnabled(),
disabled: !this.saveSettingsButton.isEnabled(),
});
}
@@ -911,16 +847,11 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
buttons.push({
iconSrc: DiscardIcon,
iconAlt: label,
onCommandClick: () => {
if (isExecuting) {
return;
}
this.onRevertClick();
},
onCommandClick: this.onRevertClick,
commandButtonLabel: label,
ariaLabel: label,
hasPopup: false,
disabled: isExecuting || !this.discardSettingsChangesButton.isEnabled(),
disabled: !this.discardSettingsChangesButton.isEnabled(),
});
}
return buttons;
@@ -1018,8 +949,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.state.isContainerPolicyDirty ||
this.state.isIndexingPolicyDirty ||
this.state.isConflictResolutionDirty ||
this.state.isComputedPropertiesDirty ||
this.state.isDataMaskingDirty
this.state.isComputedPropertiesDirty
) {
let defaultTtl: number;
switch (this.state.timeToLive) {
@@ -1042,11 +972,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
newCollection.fullTextPolicy = this.state.fullTextPolicy;
// Only send data masking policy if it was modified (dirty)
if (this.state.isDataMaskingDirty && isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking)) {
newCollection.dataMaskingPolicy = this.state.dataMaskingContent;
}
newCollection.indexingPolicy = this.state.indexingPolicyContent;
newCollection.changeFeedPolicy =
@@ -1092,18 +1017,13 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
await this.refreshIndexTransformationProgress();
}
// Update collection object with new data
this.collection.dataMaskingPolicy(updatedCollection.dataMaskingPolicy);
this.setState({
dataMaskingContentBaseline: this.state.dataMaskingContent,
isSubSettingsSaveable: false,
isSubSettingsDiscardable: false,
isContainerPolicyDirty: false,
isIndexingPolicyDirty: false,
isConflictResolutionDirty: false,
isComputedPropertiesDirty: false,
isDataMaskingDirty: false,
});
}
@@ -1433,31 +1353,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
});
}
// Check if DDM should be enabled
const shouldEnableDDM = (): boolean => {
const hasDataMaskingCapability = isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking);
const isSqlAccount = userContext.apiType === "SQL";
return isSqlAccount && hasDataMaskingCapability; // Only show for SQL accounts with DDM capability
};
if (shouldEnableDDM()) {
const dataMaskingComponentProps: DataMaskingComponentProps = {
shouldDiscardDataMasking: this.state.shouldDiscardDataMasking,
resetShouldDiscardDataMasking: this.resetShouldDiscardDataMasking,
dataMaskingContent: this.state.dataMaskingContent,
dataMaskingContentBaseline: this.state.dataMaskingContentBaseline,
onDataMaskingContentChange: this.onDataMaskingContentChange,
onDataMaskingDirtyChange: this.onDataMaskingDirtyChange,
validationErrors: this.state.dataMaskingValidationErrors,
};
tabs.push({
tab: SettingsV2TabTypes.DataMaskingTab,
content: <DataMaskingComponent {...dataMaskingComponentProps} />,
});
}
if (this.throughputBucketsEnabled && !hasDatabaseSharedThroughput(this.collection) && this.offer) {
tabs.push({
tab: SettingsV2TabTypes.ThroughputBucketsTab,

View File

@@ -69,111 +69,13 @@ describe("SettingsUtils functions", () => {
expect(wrapper).toMatchSnapshot();
});
describe("getRuPriceBreakdown", () => {
it("should return correct price breakdown for a manual RU setting of 500, 1 region, multimaster disabled", () => {
const prices = getRuPriceBreakdown(500, "", 1, false, false);
expect(prices.hourlyPrice).toBe(0.04);
expect(prices.dailyPrice).toBe(0.96);
expect(prices.monthlyPrice).toBe(29.2);
expect(prices.pricePerRu).toBe(0.00008);
expect(prices.currency).toBe("USD");
expect(prices.currencySign).toBe("$");
});
it("should return correct price breakdown for autoscale", () => {
const prices = getRuPriceBreakdown(1000, "", 1, false, true);
// For autoscale, the baseline RU is 10% of max RU
expect(prices.hourlyPrice).toBe(0.12); // Higher because autoscale pricing is different
expect(prices.dailyPrice).toBe(2.88); // hourlyPrice * 24
expect(prices.monthlyPrice).toBe(87.6); // hourlyPrice * 730
expect(prices.pricePerRu).toBe(0.00012); // Autoscale price per RU
expect(prices.currency).toBe("USD");
expect(prices.currencySign).toBe("$");
});
it("should return correct price breakdown for multimaster", () => {
const prices = getRuPriceBreakdown(500, "", 2, true, false);
// For multimaster with 2 regions, price is multiplied by 4
expect(prices.hourlyPrice).toBe(0.16); // Base price * 4
expect(prices.dailyPrice).toBe(3.84); // hourlyPrice * 24
expect(prices.monthlyPrice).toBe(116.8); // hourlyPrice * 730
expect(prices.pricePerRu).toBe(0.00016); // Base price per RU * 2 (regions) * 2 (multimaster)
expect(prices.currency).toBe("USD");
expect(prices.currencySign).toBe("$");
});
});
describe("message formatting", () => {
it("should format throughput apply delayed message correctly", () => {
const message = getThroughputApplyDelayedMessage(false, 1000, "RU/s", "testDb", "testColl", 2000);
const wrapper = shallow(message);
const text = wrapper.text();
expect(text).toContain("testDb");
expect(text).toContain("testColl");
expect(text).toContain("Current manual throughput: 1000 RU/s");
expect(text).toContain("Target manual throughput: 2000");
});
it("should format autoscale throughput message correctly", () => {
const message = getThroughputApplyDelayedMessage(true, 1000, "RU/s", "testDb", "testColl", 2000);
const wrapper = shallow(message);
const text = wrapper.text();
expect(text).toContain("Current autoscale throughput: 100 - 1000 RU/s");
expect(text).toContain("Target autoscale throughput: 200 - 2000 RU/s");
});
});
describe("estimated spending element", () => {
// Mock Stack component since we're using shallow rendering
const mockStack = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
beforeEach(() => {
jest.mock("@fluentui/react", () => ({
...jest.requireActual("@fluentui/react"),
Stack: mockStack,
}));
});
afterEach(() => {
jest.resetModules();
});
it("should render correct spending info for manual throughput", () => {
const costElement = <div>Cost</div>;
const priceBreakdown: PriceBreakdown = {
hourlyPrice: 1.0,
dailyPrice: 24.0,
monthlyPrice: 730.0,
pricePerRu: 0.0001,
currency: "USD",
currencySign: "$",
};
const element = getEstimatedSpendingElement(costElement, 1000, 1, priceBreakdown, false);
const wrapper = shallow(element);
const spendElement = wrapper.find("#throughputSpendElement");
expect(spendElement.find("span").at(0).text()).toBe("1 region");
expect(spendElement.find("span").at(1).text()).toBe("1000 RU/s");
expect(spendElement.find("span").at(2).text()).toBe("$0.0001/RU");
});
it("should render correct spending info for autoscale throughput", () => {
const costElement = <div>Cost</div>;
const priceBreakdown: PriceBreakdown = {
hourlyPrice: 1.0,
dailyPrice: 24.0,
monthlyPrice: 730.0,
pricePerRu: 0.0001,
currency: "USD",
currencySign: "$",
};
const element = getEstimatedSpendingElement(costElement, 1000, 1, priceBreakdown, true);
const wrapper = shallow(element);
const spendElement = wrapper.find("#throughputSpendElement");
expect(spendElement.find("span").at(1).text()).toBe("100 RU/s - 1000 RU/s");
});
it("should return correct price breakdown for a manual RU setting of 500, 1 region, multimaster disabled", () => {
const prices = getRuPriceBreakdown(500, "", 1, false, false);
expect(prices.hourlyPrice).toBe(0.04);
expect(prices.dailyPrice).toBe(0.96);
expect(prices.monthlyPrice).toBe(29.2);
expect(prices.pricePerRu).toBe(0.00008);
expect(prices.currency).toBe("USD");
expect(prices.currencySign).toBe("$");
});
});

View File

@@ -61,7 +61,7 @@ export interface PriceBreakdown {
currencySign: string;
}
export type editorType = "indexPolicy" | "computedProperties" | "dataMasking";
export type editorType = "indexPolicy" | "computedProperties";
export const infoAndToolTipTextStyle: ITextStyles = { root: { fontSize: 14, color: "windowtext" } };
@@ -170,14 +170,6 @@ export const messageBarStyles: Partial<IMessageBarStyles> = {
text: { fontSize: 14 },
};
export const unsavedEditorMessageBarStyles: Partial<IMessageBarStyles> = {
root: {
marginTop: "5px",
padding: "8px 12px",
},
text: { fontSize: 14 },
};
export const throughputUnit = "RU/s";
export function onRenderRow(props: IDetailsRowProps): JSX.Element {
@@ -267,12 +259,7 @@ export const ttlWarning: JSX.Element = (
export const unsavedEditorWarningMessage = (editor: editorType): JSX.Element => (
<Text styles={infoAndToolTipTextStyle}>
You have not saved the latest changes made to your{" "}
{editor === "indexPolicy"
? "indexing policy"
: editor === "dataMasking"
? "data masking policy"
: "computed properties"}
. Please click save to confirm the changes.
{editor === "indexPolicy" ? "indexing policy" : "computed properties"}. Please click save to confirm the changes.
</Text>
);

View File

@@ -1,210 +0,0 @@
import { MessageBar, MessageBarType } from "@fluentui/react";
import { mount } from "enzyme";
import React from "react";
import * as DataModels from "../../../../Contracts/DataModels";
import { DataMaskingComponent } from "./DataMaskingComponent";
const mockGetValue = jest.fn();
const mockSetValue = jest.fn();
const mockOnDidChangeContent = jest.fn();
const mockGetModel = jest.fn(() => ({
getValue: mockGetValue,
setValue: mockSetValue,
onDidChangeContent: mockOnDidChangeContent,
}));
const mockEditor = {
getModel: mockGetModel,
dispose: jest.fn(),
};
jest.mock("../../../LazyMonaco", () => ({
loadMonaco: jest.fn(() =>
Promise.resolve({
editor: {
create: jest.fn(() => mockEditor),
},
}),
),
}));
jest.mock("../../../../Utils/CapabilityUtils", () => ({
isCapabilityEnabled: jest.fn().mockReturnValue(true),
}));
describe("DataMaskingComponent", () => {
const mockProps = {
shouldDiscardDataMasking: false,
resetShouldDiscardDataMasking: jest.fn(),
dataMaskingContent: undefined as DataModels.DataMaskingPolicy,
dataMaskingContentBaseline: undefined as DataModels.DataMaskingPolicy,
onDataMaskingContentChange: jest.fn(),
onDataMaskingDirtyChange: jest.fn(),
validationErrors: [] as string[],
};
const samplePolicy: DataModels.DataMaskingPolicy = {
includedPaths: [
{
path: "/test",
strategy: "Default",
startPosition: 0,
length: -1,
},
],
excludedPaths: [],
isPolicyEnabled: false,
};
let changeContentCallback: () => void;
beforeEach(() => {
jest.clearAllMocks();
mockGetValue.mockReturnValue(JSON.stringify(samplePolicy));
mockOnDidChangeContent.mockImplementation((callback) => {
changeContentCallback = callback;
});
});
it("renders without crashing", async () => {
const wrapper = mount(<DataMaskingComponent {...mockProps} />);
await new Promise((resolve) => setTimeout(resolve, 0));
wrapper.update();
expect(wrapper.exists()).toBeTruthy();
});
it("displays warning message when content is dirty", async () => {
const wrapper = mount(
<DataMaskingComponent
{...mockProps}
dataMaskingContent={samplePolicy}
dataMaskingContentBaseline={{ ...samplePolicy, isPolicyEnabled: true }}
/>,
);
await new Promise((resolve) => setTimeout(resolve, 0));
wrapper.update();
// Verify editor div is rendered
const editorDiv = wrapper.find(".settingsV2Editor");
expect(editorDiv.exists()).toBeTruthy();
// Warning message should be visible when content is dirty
const messageBar = wrapper.find(MessageBar);
expect(messageBar.exists()).toBeTruthy();
expect(messageBar.prop("messageBarType")).toBe(MessageBarType.warning);
});
it("updates content and dirty state on valid JSON input", async () => {
const wrapper = mount(<DataMaskingComponent {...mockProps} />);
await new Promise((resolve) => setTimeout(resolve, 0));
wrapper.update();
// Simulate valid JSON input by setting mock return value and triggering callback
const validJson = JSON.stringify(samplePolicy);
mockGetValue.mockReturnValue(validJson);
changeContentCallback();
expect(mockProps.onDataMaskingContentChange).toHaveBeenCalledWith(samplePolicy);
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true);
});
it("doesn't update content on invalid JSON input", async () => {
const wrapper = mount(<DataMaskingComponent {...mockProps} />);
await new Promise((resolve) => setTimeout(resolve, 0));
wrapper.update();
// Simulate invalid JSON input
const invalidJson = "{invalid:json}";
mockGetValue.mockReturnValue(invalidJson);
changeContentCallback();
expect(mockProps.onDataMaskingContentChange).not.toHaveBeenCalled();
});
it("resets content when shouldDiscardDataMasking is true", async () => {
const baselinePolicy = { ...samplePolicy, isPolicyEnabled: true };
const wrapper = mount(
<DataMaskingComponent
{...mockProps}
dataMaskingContent={samplePolicy}
dataMaskingContentBaseline={baselinePolicy}
/>,
);
await new Promise((resolve) => setTimeout(resolve, 0));
wrapper.update();
// Now update props to trigger shouldDiscardDataMasking
wrapper.setProps({ shouldDiscardDataMasking: true });
await new Promise((resolve) => setTimeout(resolve, 0));
wrapper.update();
// Check that reset was triggered
expect(mockProps.resetShouldDiscardDataMasking).toHaveBeenCalled();
expect(mockSetValue).toHaveBeenCalledWith(JSON.stringify(samplePolicy, undefined, 4));
});
it("recalculates dirty state when baseline changes", async () => {
const wrapper = mount(
<DataMaskingComponent
{...mockProps}
dataMaskingContent={samplePolicy}
dataMaskingContentBaseline={samplePolicy}
/>,
);
await new Promise((resolve) => setTimeout(resolve, 0));
wrapper.update();
// Update baseline to trigger componentDidUpdate
const newBaseline = { ...samplePolicy, isPolicyEnabled: true };
wrapper.setProps({ dataMaskingContentBaseline: newBaseline });
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true);
});
it("validates required fields in policy", async () => {
const wrapper = mount(<DataMaskingComponent {...mockProps} />);
await new Promise((resolve) => setTimeout(resolve, 0));
wrapper.update();
// Test with missing required fields
const invalidPolicy: Record<string, unknown> = {
includedPaths: "not an array",
excludedPaths: [] as string[],
isPolicyEnabled: "not a boolean",
};
mockGetValue.mockReturnValue(JSON.stringify(invalidPolicy));
changeContentCallback();
// Parent callback should be called even with invalid data (parent will validate)
expect(mockProps.onDataMaskingContentChange).toHaveBeenCalledWith(invalidPolicy);
});
it("maintains dirty state after multiple content changes", async () => {
const wrapper = mount(
<DataMaskingComponent
{...mockProps}
dataMaskingContent={samplePolicy}
dataMaskingContentBaseline={samplePolicy}
/>,
);
await new Promise((resolve) => setTimeout(resolve, 0));
wrapper.update();
// First change
const modifiedPolicy1 = { ...samplePolicy, isPolicyEnabled: true };
mockGetValue.mockReturnValue(JSON.stringify(modifiedPolicy1));
changeContentCallback();
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true);
// Second change back to baseline
mockGetValue.mockReturnValue(JSON.stringify(samplePolicy));
changeContentCallback();
expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(false);
});
});

View File

@@ -1,162 +0,0 @@
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
import * as monaco from "monaco-editor";
import * as React from "react";
import * as Constants from "../../../../Common/Constants";
import * as DataModels from "../../../../Contracts/DataModels";
import { isCapabilityEnabled } from "../../../../Utils/CapabilityUtils";
import { loadMonaco } from "../../../LazyMonaco";
import { titleAndInputStackProps, unsavedEditorWarningMessage } from "../SettingsRenderUtils";
import { isDirty as isContentDirty } from "../SettingsUtils";
export interface DataMaskingComponentProps {
shouldDiscardDataMasking: boolean;
resetShouldDiscardDataMasking: () => void;
dataMaskingContent: DataModels.DataMaskingPolicy;
dataMaskingContentBaseline: DataModels.DataMaskingPolicy;
onDataMaskingContentChange: (dataMasking: DataModels.DataMaskingPolicy) => void;
onDataMaskingDirtyChange: (isDirty: boolean) => void;
validationErrors: string[];
}
interface DataMaskingComponentState {
isDirty: boolean;
dataMaskingContentIsValid: boolean;
}
const emptyDataMaskingPolicy: DataModels.DataMaskingPolicy = {
includedPaths: [
{
path: "/",
strategy: "Default",
startPosition: 0,
length: -1,
},
],
excludedPaths: [],
isPolicyEnabled: true,
};
export class DataMaskingComponent extends React.Component<DataMaskingComponentProps, DataMaskingComponentState> {
private dataMaskingDiv = React.createRef<HTMLDivElement>();
private dataMaskingEditor: monaco.editor.IStandaloneCodeEditor;
private shouldCheckComponentIsDirty = true;
constructor(props: DataMaskingComponentProps) {
super(props);
this.state = {
isDirty: false,
dataMaskingContentIsValid: true,
};
}
public componentDidUpdate(): void {
if (this.props.shouldDiscardDataMasking) {
this.resetDataMaskingEditor();
this.props.resetShouldDiscardDataMasking();
}
this.onComponentUpdate();
}
componentDidMount(): void {
this.resetDataMaskingEditor();
this.onComponentUpdate();
}
private onComponentUpdate = (): void => {
if (!this.shouldCheckComponentIsDirty) {
this.shouldCheckComponentIsDirty = true;
return;
}
this.props.onDataMaskingDirtyChange(this.IsComponentDirty());
this.shouldCheckComponentIsDirty = false;
};
public IsComponentDirty = (): boolean => {
if (
isContentDirty(this.props.dataMaskingContent, this.props.dataMaskingContentBaseline) &&
this.state.dataMaskingContentIsValid
) {
return true;
}
return false;
};
private resetDataMaskingEditor = (): void => {
if (!this.dataMaskingEditor) {
this.createDataMaskingEditor();
} else {
const dataMaskingEditorModel = this.dataMaskingEditor.getModel();
const value: string = JSON.stringify(this.props.dataMaskingContent || emptyDataMaskingPolicy, undefined, 4);
dataMaskingEditorModel.setValue(value);
}
this.onComponentUpdate();
};
private async createDataMaskingEditor(): Promise<void> {
const value: string = JSON.stringify(this.props.dataMaskingContent || emptyDataMaskingPolicy, undefined, 4);
const monaco = await loadMonaco();
this.dataMaskingEditor = monaco.editor.create(this.dataMaskingDiv.current, {
value: value,
language: "json",
automaticLayout: true,
ariaLabel: "Data Masking Policy",
fontSize: 13,
minimap: { enabled: false },
wordWrap: "off",
scrollBeyondLastLine: false,
lineNumbers: "on",
});
if (this.dataMaskingEditor) {
const dataMaskingEditorModel = this.dataMaskingEditor.getModel();
dataMaskingEditorModel.onDidChangeContent(this.onEditorContentChange.bind(this));
}
}
private onEditorContentChange = (): void => {
const dataMaskingEditorModel = this.dataMaskingEditor.getModel();
try {
const newContent = JSON.parse(dataMaskingEditorModel.getValue()) as DataModels.DataMaskingPolicy;
// Always call parent's validation - it will handle validation and store errors
this.props.onDataMaskingContentChange(newContent);
const isDirty = isContentDirty(newContent, this.props.dataMaskingContentBaseline);
this.setState(
{
dataMaskingContentIsValid: this.props.validationErrors.length === 0,
isDirty,
},
() => {
this.props.onDataMaskingDirtyChange(isDirty);
},
);
} catch (e) {
// Invalid JSON - mark as invalid without propagating
this.setState({
dataMaskingContentIsValid: false,
isDirty: false,
});
}
};
public render(): JSX.Element {
if (!isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking)) {
return null;
}
const isDirty = this.IsComponentDirty();
return (
<Stack {...titleAndInputStackProps}>
{isDirty && (
<MessageBar messageBarType={MessageBarType.warning}>{unsavedEditorWarningMessage("dataMasking")}</MessageBar>
)}
{this.props.validationErrors.length > 0 && (
<MessageBar messageBarType={MessageBarType.error}>
Validation failed: {this.props.validationErrors.join(", ")}
</MessageBar>
)}
<div className="settingsV2Editor" tabIndex={0} ref={this.dataMaskingDiv}></div>
</Stack>
);
}
}

View File

@@ -5,12 +5,7 @@ import {
getMongoIndexType,
getMongoIndexTypeText,
getMongoNotification,
getPartitionKeyName,
getPartitionKeyPlaceHolder,
getPartitionKeySubtext,
getPartitionKeyTooltipText,
getSanitizedInputValue,
getTabTitle,
hasDatabaseSharedThroughput,
isDirty,
isIndexTransforming,
@@ -19,7 +14,6 @@ import {
MongoWildcardPlaceHolder,
parseConflictResolutionMode,
parseConflictResolutionProcedure,
SettingsV2TabTypes,
SingleFieldText,
WildcardText,
} from "./SettingsUtils";
@@ -56,46 +50,14 @@ describe("SettingsUtils", () => {
expect(hasDatabaseSharedThroughput(newCollection)).toEqual(true);
});
describe("parseConflictResolutionMode", () => {
it("parses valid modes correctly", () => {
expect(parseConflictResolutionMode("custom")).toEqual(DataModels.ConflictResolutionMode.Custom);
expect(parseConflictResolutionMode("lastwriterwins")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
expect(parseConflictResolutionMode("Custom")).toEqual(DataModels.ConflictResolutionMode.Custom);
expect(parseConflictResolutionMode("CUSTOM")).toEqual(DataModels.ConflictResolutionMode.Custom);
expect(parseConflictResolutionMode("LastWriterWins")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
});
it("handles empty/undefined input", () => {
expect(parseConflictResolutionMode(undefined)).toBeUndefined();
expect(parseConflictResolutionMode(null)).toBeUndefined();
expect(parseConflictResolutionMode("")).toBeUndefined();
});
it("defaults to LastWriterWins for invalid inputs", () => {
expect(parseConflictResolutionMode("invalid")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
expect(parseConflictResolutionMode("123")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
});
it("parseConflictResolutionMode", () => {
expect(parseConflictResolutionMode("custom")).toEqual(DataModels.ConflictResolutionMode.Custom);
expect(parseConflictResolutionMode("lastwriterwins")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
});
describe("parseConflictResolutionProcedure", () => {
it("extracts procedure name from valid paths", () => {
expect(parseConflictResolutionProcedure("/dbs/db/colls/coll/sprocs/conflictResSproc")).toEqual(
"conflictResSproc",
);
expect(parseConflictResolutionProcedure("conflictResSproc")).toEqual("conflictResSproc");
expect(parseConflictResolutionProcedure("/dbs/mydb/colls/mycoll/sprocs/myProc")).toEqual("myProc");
});
it("handles empty/undefined input", () => {
expect(parseConflictResolutionProcedure(undefined)).toBeUndefined();
expect(parseConflictResolutionProcedure(null)).toBeUndefined();
expect(parseConflictResolutionProcedure("")).toBeUndefined();
});
it("handles invalid path formats", () => {
expect(parseConflictResolutionProcedure("/invalid/path")).toBeUndefined();
expect(parseConflictResolutionProcedure("/dbs/db/colls/coll/wrongtype/name")).toBeUndefined();
});
it("parseConflictResolutionProcedure", () => {
expect(parseConflictResolutionProcedure("/dbs/db/colls/coll/sprocs/conflictResSproc")).toEqual("conflictResSproc");
expect(parseConflictResolutionProcedure("conflictResSproc")).toEqual("conflictResSproc");
});
describe("isDirty", () => {
@@ -106,235 +68,68 @@ describe("SettingsUtils", () => {
excludedPaths: [],
} as DataModels.IndexingPolicy;
describe("primitive types", () => {
it("handles strings", () => {
expect(isDirty("baseline", "baseline")).toBeFalsy();
expect(isDirty("baseline", "current")).toBeTruthy();
expect(isDirty("", "")).toBeFalsy();
expect(isDirty("test", "")).toBeTruthy();
});
it("works on all types", () => {
expect(isDirty("baseline", "baseline")).toEqual(false);
expect(isDirty(0, 0)).toEqual(false);
expect(isDirty(true, true)).toEqual(false);
expect(isDirty(undefined, undefined)).toEqual(false);
expect(isDirty(indexingPolicy, indexingPolicy)).toEqual(false);
it("handles numbers", () => {
expect(isDirty(0, 0)).toBeFalsy();
expect(isDirty(1, 1)).toBeFalsy();
expect(isDirty(0, 1)).toBeTruthy();
expect(isDirty(-1, 1)).toBeTruthy();
});
it("handles booleans", () => {
expect(isDirty(true, true)).toBeFalsy();
expect(isDirty(false, false)).toBeFalsy();
expect(isDirty(true, false)).toBeTruthy();
});
it("handles undefined and null", () => {
expect(isDirty(undefined, undefined)).toBeFalsy();
expect(isDirty(null, null)).toBeFalsy();
expect(isDirty(undefined, null)).toBeTruthy();
expect(isDirty(undefined, "value")).toBeTruthy();
});
});
describe("complex types", () => {
it("handles indexing policy", () => {
expect(isDirty(indexingPolicy, indexingPolicy)).toBeFalsy();
expect(isDirty(indexingPolicy, { ...indexingPolicy, automatic: false })).toBeTruthy();
expect(isDirty(indexingPolicy, { ...indexingPolicy, includedPaths: ["/path"] })).toBeTruthy();
});
it("handles array type policies", () => {
const computedProperties: DataModels.ComputedProperties = [
{ name: "prop1", query: "SELECT * FROM c" },
{ name: "prop2", query: "SELECT * FROM c" },
];
const otherProperties: DataModels.ComputedProperties = [{ name: "prop1", query: "SELECT * FROM c" }];
expect(isDirty(computedProperties, computedProperties)).toBeFalsy();
expect(isDirty(computedProperties, otherProperties)).toBeTruthy();
});
});
describe("type mismatch handling", () => {
it("throws error for mismatched types", () => {
expect(() => isDirty("string", 123)).toThrow("current and baseline values are not of the same type");
expect(() => isDirty(true, "true")).toThrow("current and baseline values are not of the same type");
expect(() => isDirty(0, false)).toThrow("current and baseline values are not of the same type");
});
expect(isDirty("baseline", "current")).toEqual(true);
expect(isDirty(0, 1)).toEqual(true);
expect(isDirty(true, false)).toEqual(true);
expect(isDirty(undefined, indexingPolicy)).toEqual(true);
expect(isDirty(indexingPolicy, { ...indexingPolicy, automatic: false })).toEqual(true);
});
});
describe("getSanitizedInputValue", () => {
it("getSanitizedInputValue", () => {
const max = 100;
it("handles empty or invalid inputs", () => {
expect(getSanitizedInputValue("", max)).toEqual(0);
expect(getSanitizedInputValue("abc", max)).toEqual(0);
expect(getSanitizedInputValue("!@#", max)).toEqual(0);
expect(getSanitizedInputValue(null, max)).toEqual(0);
expect(getSanitizedInputValue(undefined, max)).toEqual(0);
});
it("handles valid inputs within max", () => {
expect(getSanitizedInputValue("10", max)).toEqual(10);
expect(getSanitizedInputValue("50", max)).toEqual(50);
expect(getSanitizedInputValue("100", max)).toEqual(100);
});
it("handles inputs exceeding max", () => {
expect(getSanitizedInputValue("101", max)).toEqual(100);
expect(getSanitizedInputValue("999", max)).toEqual(100);
expect(getSanitizedInputValue("1000000", max)).toEqual(100);
});
it("handles inputs without max constraint", () => {
expect(getSanitizedInputValue("10")).toEqual(10);
expect(getSanitizedInputValue("1000")).toEqual(1000);
expect(getSanitizedInputValue("999999")).toEqual(999999);
});
it("handles negative numbers", () => {
expect(getSanitizedInputValue("-10", max)).toEqual(-10);
expect(getSanitizedInputValue("-999", max)).toEqual(-999);
});
expect(getSanitizedInputValue("", max)).toEqual(0);
expect(getSanitizedInputValue("999", max)).toEqual(100);
expect(getSanitizedInputValue("10", max)).toEqual(10);
});
describe("getMongoIndexType", () => {
it("correctly identifies single field indexes", () => {
expect(getMongoIndexType(["Single"])).toEqual(MongoIndexTypes.Single);
expect(getMongoIndexType(["field1"])).toEqual(MongoIndexTypes.Single);
expect(getMongoIndexType(["name"])).toEqual(MongoIndexTypes.Single);
});
it("correctly identifies wildcard indexes", () => {
expect(getMongoIndexType(["Wildcard.$**"])).toEqual(MongoIndexTypes.Wildcard);
expect(getMongoIndexType(["field.$**"])).toEqual(MongoIndexTypes.Wildcard);
expect(getMongoIndexType(["nested.path.$**"])).toEqual(MongoIndexTypes.Wildcard);
});
it("returns undefined for invalid or compound indexes", () => {
expect(getMongoIndexType(["Key1", "Key2"])).toBeUndefined();
expect(getMongoIndexType([])).toBeUndefined();
expect(getMongoIndexType(undefined)).toBeUndefined();
});
it("getMongoIndexType", () => {
expect(getMongoIndexType(["Single"])).toEqual(MongoIndexTypes.Single);
expect(getMongoIndexType(["Wildcard.$**"])).toEqual(MongoIndexTypes.Wildcard);
expect(getMongoIndexType(["Key1", "Key2"])).toEqual(undefined);
});
describe("getMongoIndexTypeText", () => {
it("returns correct text for single field indexes", () => {
expect(getMongoIndexTypeText(MongoIndexTypes.Single)).toEqual(SingleFieldText);
});
it("returns correct text for wildcard indexes", () => {
expect(getMongoIndexTypeText(MongoIndexTypes.Wildcard)).toEqual(WildcardText);
});
it("getMongoIndexTypeText", () => {
expect(getMongoIndexTypeText(MongoIndexTypes.Single)).toEqual(SingleFieldText);
expect(getMongoIndexTypeText(MongoIndexTypes.Wildcard)).toEqual(WildcardText);
});
describe("getMongoNotification", () => {
it("getMongoNotification", () => {
const singleIndexDescription = "sampleKey";
const wildcardIndexDescription = "sampleKey.$**";
describe("type validation", () => {
it("returns warning when type is missing", () => {
const notification = getMongoNotification(singleIndexDescription, undefined);
expect(notification.message).toEqual("Please select a type for each index.");
expect(notification.type).toEqual(MongoNotificationType.Warning);
});
let notification = getMongoNotification(singleIndexDescription, undefined);
expect(notification.message).toEqual("Please select a type for each index.");
expect(notification.type).toEqual(MongoNotificationType.Warning);
it("returns undefined for valid type and description combinations", () => {
expect(getMongoNotification(singleIndexDescription, MongoIndexTypes.Single)).toBeUndefined();
expect(getMongoNotification(wildcardIndexDescription, MongoIndexTypes.Wildcard)).toBeUndefined();
});
});
notification = getMongoNotification(singleIndexDescription, MongoIndexTypes.Single);
expect(notification).toEqual(undefined);
describe("field name validation", () => {
it("returns error when field name is empty", () => {
const notification = getMongoNotification("", MongoIndexTypes.Single);
expect(notification.message).toEqual("Please enter a field name.");
expect(notification.type).toEqual(MongoNotificationType.Error);
notification = getMongoNotification(wildcardIndexDescription, MongoIndexTypes.Wildcard);
expect(notification).toEqual(undefined);
const whitespaceNotification = getMongoNotification(" ", MongoIndexTypes.Single);
expect(whitespaceNotification.message).toEqual("Please enter a field name.");
expect(whitespaceNotification.type).toEqual(MongoNotificationType.Error);
});
notification = getMongoNotification("", MongoIndexTypes.Single);
expect(notification.message).toEqual("Please enter a field name.");
expect(notification.type).toEqual(MongoNotificationType.Error);
it("returns error when wildcard index is missing $** pattern", () => {
const notification = getMongoNotification(singleIndexDescription, MongoIndexTypes.Wildcard);
expect(notification.message).toEqual(
"Wildcard path is not present in the field name. Use a pattern like " + MongoWildcardPlaceHolder,
);
expect(notification.type).toEqual(MongoNotificationType.Error);
});
});
it("handles undefined field name", () => {
const notification = getMongoNotification(undefined, MongoIndexTypes.Single);
expect(notification.message).toEqual("Please enter a field name.");
expect(notification.type).toEqual(MongoNotificationType.Error);
});
});
it("isIndexingTransforming", () => {
expect(isIndexTransforming(undefined)).toBeFalsy();
expect(isIndexTransforming(0)).toBeTruthy();
expect(isIndexTransforming(90)).toBeTruthy();
expect(isIndexTransforming(100)).toBeFalsy();
});
describe("getTabTitle", () => {
it("returns correct titles for each tab type", () => {
expect(getTabTitle(SettingsV2TabTypes.ScaleTab)).toBe("Scale");
expect(getTabTitle(SettingsV2TabTypes.ConflictResolutionTab)).toBe("Conflict Resolution");
expect(getTabTitle(SettingsV2TabTypes.SubSettingsTab)).toBe("Settings");
expect(getTabTitle(SettingsV2TabTypes.IndexingPolicyTab)).toBe("Indexing Policy");
expect(getTabTitle(SettingsV2TabTypes.ComputedPropertiesTab)).toBe("Computed Properties");
expect(getTabTitle(SettingsV2TabTypes.ContainerVectorPolicyTab)).toBe("Container Policies");
expect(getTabTitle(SettingsV2TabTypes.ThroughputBucketsTab)).toBe("Throughput Buckets");
expect(getTabTitle(SettingsV2TabTypes.DataMaskingTab)).toBe("Masking Policy (preview)");
});
it("handles partition key tab title based on fabric native", () => {
// Assuming initially not fabric native
expect(getTabTitle(SettingsV2TabTypes.PartitionKeyTab)).toBe("Partition Keys (preview)");
});
it("throws error for unknown tab type", () => {
expect(() => getTabTitle(999 as SettingsV2TabTypes)).toThrow("Unknown tab 999");
});
});
describe("partition key utils", () => {
it("getPartitionKeyName returns correct name based on API type", () => {
expect(getPartitionKeyName("Mongo")).toBe("Shard key");
expect(getPartitionKeyName("SQL")).toBe("Partition key");
expect(getPartitionKeyName("Mongo", true)).toBe("shard key");
expect(getPartitionKeyName("SQL", true)).toBe("partition key");
});
it("getPartitionKeyTooltipText returns correct tooltip based on API type", () => {
const mongoTooltip = getPartitionKeyTooltipText("Mongo");
expect(mongoTooltip).toContain("shard key");
expect(mongoTooltip).toContain("replica sets");
const sqlTooltip = getPartitionKeyTooltipText("SQL");
expect(sqlTooltip).toContain("partition key");
expect(sqlTooltip).toContain("id is often a good choice");
});
it("getPartitionKeySubtext returns correct subtext", () => {
expect(getPartitionKeySubtext(true, "SQL")).toBe(
"For small workloads, the item ID is a suitable choice for the partition key.",
);
expect(getPartitionKeySubtext(true, "Mongo")).toBe(
"For small workloads, the item ID is a suitable choice for the partition key.",
);
expect(getPartitionKeySubtext(false, "SQL")).toBe("");
expect(getPartitionKeySubtext(true, "Other")).toBe("");
});
it("getPartitionKeyPlaceHolder returns correct placeholder based on API type", () => {
expect(getPartitionKeyPlaceHolder("Mongo")).toBe("e.g., categoryId");
expect(getPartitionKeyPlaceHolder("Gremlin")).toBe("e.g., /address");
expect(getPartitionKeyPlaceHolder("SQL")).toBe("Required - first partition key e.g., /TenantId");
expect(getPartitionKeyPlaceHolder("SQL", 0)).toBe("second partition key e.g., /UserId");
expect(getPartitionKeyPlaceHolder("SQL", 1)).toBe("third partition key e.g., /SessionId");
expect(getPartitionKeyPlaceHolder("Other")).toBe("e.g., /address/zipCode");
});
notification = getMongoNotification(singleIndexDescription, MongoIndexTypes.Wildcard);
expect(notification.message).toEqual(
"Wildcard path is not present in the field name. Use a pattern like " + MongoWildcardPlaceHolder,
);
expect(notification.type).toEqual(MongoNotificationType.Error);
});
});
it("isIndexingTransforming", () => {
expect(isIndexTransforming(undefined)).toBeFalsy();
expect(isIndexTransforming(0)).toBeTruthy();
expect(isIndexTransforming(90)).toBeTruthy();
expect(isIndexTransforming(100)).toBeFalsy();
});

View File

@@ -13,8 +13,7 @@ export type isDirtyTypes =
| DataModels.ComputedProperties
| DataModels.VectorEmbedding[]
| DataModels.FullTextPolicy
| DataModels.ThroughputBucket[]
| DataModels.DataMaskingPolicy;
| DataModels.ThroughputBucket[];
export const TtlOff = "off";
export const TtlOn = "on";
export const TtlOnNoDefault = "on-nodefault";
@@ -60,7 +59,6 @@ export enum SettingsV2TabTypes {
ContainerVectorPolicyTab,
ThroughputBucketsTab,
GlobalSecondaryIndexTab,
DataMaskingTab,
}
export enum ContainerPolicyTabTypes {
@@ -177,8 +175,6 @@ export const getTabTitle = (tab: SettingsV2TabTypes): string => {
return "Throughput Buckets";
case SettingsV2TabTypes.GlobalSecondaryIndexTab:
return "Global Secondary Index (Preview)";
case SettingsV2TabTypes.DataMaskingTab:
return "Masking Policy (preview)";
default:
throw new Error(`Unknown tab ${tab}`);
}

View File

@@ -65,11 +65,6 @@ export const collection = {
sourceCollectionId: "source1",
sourceCollectionRid: "rid123",
}),
dataMaskingPolicy: ko.observable<DataModels.DataMaskingPolicy>({
includedPaths: [],
excludedPaths: ["/excludedPath"],
isPolicyEnabled: true,
}),
readSettings: () => {
return;
},

View File

@@ -53,7 +53,6 @@ exports[`SettingsComponent renders 1`] = `
"parameters": [Function],
},
},
"dataMaskingPolicy": [Function],
"databaseId": "test",
"defaultTtl": [Function],
"fullTextPolicy": [Function],
@@ -146,7 +145,6 @@ exports[`SettingsComponent renders 1`] = `
"parameters": [Function],
},
},
"dataMaskingPolicy": [Function],
"databaseId": "test",
"defaultTtl": [Function],
"fullTextPolicy": [Function],
@@ -304,7 +302,6 @@ exports[`SettingsComponent renders 1`] = `
"parameters": [Function],
},
},
"dataMaskingPolicy": [Function],
"databaseId": "test",
"defaultTtl": [Function],
"fullTextPolicy": [Function],
@@ -445,7 +442,6 @@ exports[`SettingsComponent renders 1`] = `
"parameters": [Function],
},
},
"dataMaskingPolicy": [Function],
"databaseId": "test",
"defaultTtl": [Function],
"fullTextPolicy": [Function],

View File

@@ -359,14 +359,6 @@ export default class Explorer {
);
}
public async openContainerCopyFeedbackBlade(): Promise<void> {
sendMessage({ type: MessageTypes.OpenContainerCopyFeedbackBlade });
Logger.logInfo(
`Container Copy Feedback logging current date when survey is shown ${Date.now().toString()}`,
"Explorer/openContainerCopyFeedbackBlade",
);
}
public async refreshDatabaseForResourceToken(): Promise<void> {
const databaseId = userContext.parsedResourceToken?.databaseId;
const collectionId = userContext.parsedResourceToken?.collectionId;
@@ -1024,7 +1016,7 @@ export default class Explorer {
break;
case ViewModels.TerminalKind.VCoreMongo:
title = "Mongo Shell";
title = "VCoreMongo Shell";
break;
default:

View File

@@ -136,7 +136,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
describe("Open Postgres and vCore Mongo buttons", () => {
const openPostgresShellButtonLabel = "Open PSQL shell";
const openVCoreMongoShellButtonLabel = "Open MongoDB (DocumentDB) shell";
const openVCoreMongoShellButtonLabel = "Open MongoDB (vCore) shell";
beforeAll(() => {
mockExplorer = {} as Explorer;

View File

@@ -450,7 +450,7 @@ function createOpenTerminalButtonByKind(
case ViewModels.TerminalKind.Postgres:
return "PSQL";
case ViewModels.TerminalKind.VCoreMongo:
return "MongoDB (DocumentDB)";
return "MongoDB (vCore)";
default:
return "";
}

View File

@@ -16,6 +16,7 @@ import InfoIcon from "../../../../images/info_color.svg";
import LoadingIcon from "../../../../images/loading.svg";
import WarningIcon from "../../../../images/warning.svg";
import { ClientDefaults, KeyCodes } from "../../../Common/Constants";
import { userContext } from "../../../UserContext";
import { useNotificationConsole } from "../../../hooks/useNotificationConsole";
import { ConsoleData, ConsoleDataType } from "./ConsoleData";
@@ -126,6 +127,7 @@ export class NotificationConsoleComponent extends React.Component<
<span className="numWarningItems">{numWarningItems}</span>
</span>
</span>
{userContext.features.pr && <PrPreview pr={userContext.features.pr} />}
<span className="consoleSplitter" />
<span className="headerStatus">
<span className="headerStatusEllipsis" aria-live="assertive" aria-atomic="true">
@@ -291,6 +293,21 @@ export class NotificationConsoleComponent extends React.Component<
};
}
const PrPreview = (props: { pr: string }) => {
const url = new URL(props.pr);
const [, ref] = url.hash.split("#");
url.hash = "";
return (
<>
<span className="consoleSplitter" />
<a target="_blank" rel="noreferrer" href={url.href} style={{ marginRight: "1em", fontWeight: "bold" }}>
{ref}
</a>
</>
);
};
export const NotificationConsole: React.FC = () => {
const setIsExpanded = useNotificationConsole((state) => state.setIsExpanded);
const isExpanded = useNotificationConsole((state) => state.isExpanded);

View File

@@ -65,8 +65,6 @@ export interface AddCollectionPanelProps {
explorer: Explorer;
databaseId?: string;
isQuickstart?: boolean;
isCopyJobFlow?: boolean;
onSubmitSuccess?: (collectionData: { databaseId: string; collectionId: string }) => void;
}
export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = {
@@ -895,8 +893,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
) => {
this.setState({ fullTextPolicy, fullTextIndexes, fullTextPolicyValidated });
}}
// Remove when multi language support on container create issue is fixed
englishOnly={true}
/>
</Stack>
</Stack>
@@ -977,9 +973,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
)}
</div>
{!this.props.isCopyJobFlow && (
<PanelFooterComponent buttonLabel="OK" isButtonDisabled={this.state.isThroughputCapExceeded} />
)}
<PanelFooterComponent buttonLabel="OK" isButtonDisabled={this.state.isThroughputCapExceeded} />
{this.state.isExecuting && (
<div>
@@ -1419,13 +1413,8 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
}
}
this.setState({ isExecuting: false });
if (this.props.isCopyJobFlow && this.props.onSubmitSuccess) {
this.props.onSubmitSuccess({ databaseId, collectionId });
} else {
TelemetryProcessor.traceSuccess(Action.CreateCollection, telemetryData, startKey);
useSidePanel.getState().closeSidePanel();
}
TelemetryProcessor.traceSuccess(Action.CreateCollection, telemetryData, startKey);
useSidePanel.getState().closeSidePanel();
} catch (error) {
const errorMessage: string = getErrorMessage(error);
this.setState({ isExecuting: false, errorMessage, showErrorDetails: true });

View File

@@ -516,7 +516,6 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
}
>
<FullTextPoliciesComponent
englishOnly={true}
fullTextPolicy={
{
"defaultLanguage": "en-US",

View File

@@ -3,24 +3,11 @@ import React from "react";
import { PanelContainerComponent, PanelContainerProps } from "./PanelContainerComponent";
describe("PaneContainerComponent test", () => {
it("should not render console with panel", () => {
const panelContainerProps: PanelContainerProps = {
headerText: "test",
panelContent: <div></div>,
isOpen: true,
hasConsole: false,
isConsoleExpanded: false,
};
const wrapper = shallow(<PanelContainerComponent {...panelContainerProps} />);
expect(wrapper).toMatchSnapshot();
});
it("should render with panel content and header", () => {
const panelContainerProps: PanelContainerProps = {
headerText: "test",
panelContent: <div></div>,
isOpen: true,
hasConsole: true,
isConsoleExpanded: false,
};
const wrapper = shallow(<PanelContainerComponent {...panelContainerProps} />);
@@ -32,7 +19,6 @@ describe("PaneContainerComponent test", () => {
headerText: "test",
panelContent: undefined,
isOpen: true,
hasConsole: true,
isConsoleExpanded: false,
};
const wrapper = shallow(<PanelContainerComponent {...panelContainerProps} />);
@@ -44,7 +30,6 @@ describe("PaneContainerComponent test", () => {
headerText: "test",
panelContent: <div></div>,
isOpen: true,
hasConsole: true,
isConsoleExpanded: true,
};
const wrapper = shallow(<PanelContainerComponent {...panelContainerProps} />);

View File

@@ -8,7 +8,7 @@ export interface PanelContainerProps {
panelContent?: JSX.Element;
isConsoleExpanded: boolean;
isOpen: boolean;
hasConsole: boolean;
hasConsole?: boolean;
isConsoleAnimationFinished?: boolean;
panelWidth?: string;
onRenderNavigationContent?: IRenderFunction<IPanelProps>;

View File

@@ -5,12 +5,6 @@ import { updateUserContext } from "../../../UserContext";
import { SettingsPane } from "./SettingsPane";
describe("Settings Pane", () => {
beforeEach(() => {
updateUserContext({
sessionId: "1234-5678",
});
});
it("should render Default properly", () => {
const wrapper = shallow(<SettingsPane explorer={null} />);
expect(wrapper).toMatchSnapshot();

View File

@@ -13,7 +13,6 @@ import {
IToggleStyles,
Position,
SpinButton,
Stack,
Toggle,
} from "@fluentui/react";
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, makeStyles } from "@fluentui/react-components";
@@ -205,14 +204,10 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
? (LocalStorageUtility.getEntryString(StorageKey.MongoGuidRepresentation) as Constants.MongoGuidRepresentation)
: Constants.MongoGuidRepresentation.CSharpLegacy,
);
const [ignorePartitionKeyOnDocumentUpdate, setIgnorePartitionKeyOnDocumentUpdate] = useState<boolean>(
LocalStorageUtility.getEntryBoolean(StorageKey.IgnorePartitionKeyOnDocumentUpdate),
);
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 =
@@ -429,12 +424,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
LocalStorageUtility.setEntryString(StorageKey.MongoGuidRepresentation, mongoGuidRepresentation);
}
// Advanced settings
LocalStorageUtility.setEntryBoolean(
StorageKey.IgnorePartitionKeyOnDocumentUpdate,
ignorePartitionKeyOnDocumentUpdate,
);
setIsExecuting(false);
logConsoleInfo(
`Updated items per page setting to ${LocalStorageUtility.getEntryNumber(StorageKey.ActualItemPerPage)}`,
@@ -464,10 +453,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
);
}
logConsoleInfo(
`${ignorePartitionKeyOnDocumentUpdate ? "Enabled" : "Disabled"} ignoring partition key on document update`,
);
refreshExplorer && (await explorer.refreshExplorer());
closeSidePanel();
};
@@ -608,13 +593,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
setMongoGuidRepresentation(option.key as Constants.MongoGuidRepresentation);
};
const handleOnIgnorePartitionKeyOnDocumentUpdateChange = (
ev: React.MouseEvent<HTMLElement>,
checked?: boolean,
): void => {
setIgnorePartitionKeyOnDocumentUpdate(!!checked);
};
const choiceButtonStyles = {
root: {
clear: "both",
@@ -1159,29 +1137,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
</AccordionPanel>
</AccordionItem>
)}
<AccordionItem value="15">
<AccordionHeader>
<div className={styles.header}>Advanced Settings</div>
</AccordionHeader>
<AccordionPanel>
<div className={styles.settingsSectionContainer}>
<Stack horizontal verticalAlign="center" tokens={{ childrenGap: 4 }}>
<Checkbox
styles={{ label: { padding: 0 } }}
className="padding"
ariaLabel="Ignore partition key on document update"
checked={ignorePartitionKeyOnDocumentUpdate}
onChange={handleOnIgnorePartitionKeyOnDocumentUpdateChange}
label="Ignore partition key on document update"
/>
<InfoTooltip className={styles.headerIcon}>
If checked, the partition key value will not be used to locate the document during update
operations. Only use this if document updates are failing due to an abnormal partition key.
</InfoTooltip>
</Stack>
</div>
</AccordionPanel>
</AccordionItem>
</Accordion>
)}
@@ -1228,12 +1183,6 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
<div>{explorerVersion}</div>
</div>
</div>
<div className="settingsSection">
<div className="settingsSectionPart">
<div className="settingsSectionLabel">Session ID</div>
<div>{sessionId}</div>
</div>
</div>
</div>
</RightPaneForm>
);

View File

@@ -575,52 +575,6 @@ exports[`Settings Pane should render Default properly 1`] = `
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="15"
>
<AccordionHeader>
<div
className="___15c001r_0000000 fq02s40"
>
Advanced Settings
</div>
</AccordionHeader>
<AccordionPanel>
<div
className="___1dfa554_0000000 fo7qwa0"
>
<Stack
horizontal={true}
tokens={
{
"childrenGap": 4,
}
}
verticalAlign="center"
>
<StyledCheckboxBase
ariaLabel="Ignore partition key on document update"
checked={false}
className="padding"
label="Ignore partition key on document update"
onChange={[Function]}
styles={
{
"label": {
"padding": 0,
},
}
}
/>
<InfoTooltip
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
>
If checked, the partition key value will not be used to locate the document during update operations. Only use this if document updates are failing due to an abnormal partition key.
</InfoTooltip>
</Stack>
</div>
</AccordionPanel>
</AccordionItem>
</Accordion>
<div
className="settingsSection"
@@ -649,22 +603,6 @@ exports[`Settings Pane should render Default properly 1`] = `
<div />
</div>
</div>
<div
className="settingsSection"
>
<div
className="settingsSectionPart"
>
<div
className="settingsSectionLabel"
>
Session ID
</div>
<div>
1234-5678
</div>
</div>
</div>
</div>
</RightPaneForm>
`;
@@ -900,52 +838,6 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
</div>
</AccordionPanel>
</AccordionItem>
<AccordionItem
value="15"
>
<AccordionHeader>
<div
className="___15c001r_0000000 fq02s40"
>
Advanced Settings
</div>
</AccordionHeader>
<AccordionPanel>
<div
className="___1dfa554_0000000 fo7qwa0"
>
<Stack
horizontal={true}
tokens={
{
"childrenGap": 4,
}
}
verticalAlign="center"
>
<StyledCheckboxBase
ariaLabel="Ignore partition key on document update"
checked={false}
className="padding"
label="Ignore partition key on document update"
onChange={[Function]}
styles={
{
"label": {
"padding": 0,
},
}
}
/>
<InfoTooltip
className="___vtc5hy0_0000000 f10ra9hq f1k6fduh"
>
If checked, the partition key value will not be used to locate the document during update operations. Only use this if document updates are failing due to an abnormal partition key.
</InfoTooltip>
</Stack>
</div>
</AccordionPanel>
</AccordionItem>
</Accordion>
<div
className="settingsSection"
@@ -974,22 +866,6 @@ exports[`Settings Pane should render Gremlin properly 1`] = `
<div />
</div>
</div>
<div
className="settingsSection"
>
<div
className="settingsSectionPart"
>
<div
className="settingsSectionLabel"
>
Session ID
</div>
<div>
1234-5678
</div>
</div>
</div>
</div>
</RightPaneForm>
`;

View File

@@ -13,7 +13,7 @@ import {
import { Upload } from "Common/Upload/Upload";
import { UploadDetailsRecord } from "Contracts/ViewModels";
import { logConsoleError } from "Utils/NotificationConsoleUtils";
import React, { ChangeEvent, FunctionComponent, useReducer, useState } from "react";
import React, { ChangeEvent, FunctionComponent, useState } from "react";
import { getErrorMessage } from "../../Tables/Utilities";
import { useSelectedNode } from "../../useSelectedNode";
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
@@ -57,7 +57,6 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
const [uploadFileData, setUploadFileData] = useState<UploadDetailsRecord[]>([]);
const [formError, setFormError] = useState<string>("");
const [isExecuting, setIsExecuting] = useState<boolean>();
const [reducer, setReducer] = useReducer((x) => x + 1, 1);
const onSubmit = () => {
setFormError("");
@@ -76,7 +75,6 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
(uploadDetails) => {
setUploadFileData(uploadDetails.data);
setFiles(undefined);
setReducer(); // Trigger a re-render to update the UI with new upload details
// Emit the upload details to the parent component
onUpload && onUpload(uploadDetails.data);
},
@@ -97,7 +95,6 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
const props: RightPaneFormProps = {
formError,
isExecuting: isExecuting,
isSubmitButtonDisabled: !files || files.length === 0,
submitButtonText: "Upload",
onSubmit,
};
@@ -195,7 +192,6 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
<RightPaneForm {...props}>
<div className="paneMainContent">
<Upload
key={reducer} // Force re-render on state change
label="Select JSON Files"
onUpload={updateSelectedFiles}
accept="application/json"

View File

@@ -3,7 +3,6 @@
exports[`Upload Items Pane should render Default properly 1`] = `
<RightPaneForm
formError=""
isSubmitButtonDisabled={true}
onSubmit={[Function]}
submitButtonText="Upload"
>
@@ -12,7 +11,6 @@ exports[`Upload Items Pane should render Default properly 1`] = `
>
<Upload
accept="application/json"
key="1"
label="Select JSON Files"
multiple={true}
onUpload={[Function]}

View File

@@ -39,45 +39,6 @@ exports[`PaneContainerComponent test should be resize if notification console is
</StyledPanelBase>
`;
exports[`PaneContainerComponent test should not render console with panel 1`] = `
<StyledPanelBase
closeButtonAriaLabel="Close test"
customWidth="440px"
data-test="Panel:test"
headerClassName="panelHeader"
headerText="test"
isFooterAtBottom={true}
isLightDismiss={true}
isOpen={true}
onDismiss={[Function]}
style={
{
"height": "768px",
}
}
styles={
{
"commands": {
"marginTop": 8,
"paddingTop": 0,
},
"content": {
"padding": 0,
},
"header": {
"padding": "0 0 8px 34px",
},
"navigation": {
"borderBottom": "1px solid #cccccc",
},
}
}
type={7}
>
<div />
</StyledPanelBase>
`;
exports[`PaneContainerComponent test should render nothing if content is undefined 1`] = `<Fragment />`;
exports[`PaneContainerComponent test should render with panel content and header 1`] = `

View File

@@ -46,7 +46,7 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
<Stack style={{ paddingTop: 8, height: "100%", width: "100%" }}>
<Stack style={{ flexGrow: 1, padding: "0 20px", overflow: "auto" }}>
<Text variant="xxLarge">Quick start guide</Text>
<Text variant="small">Getting started in Azure DocumentDB (with MongoDB compatibility)</Text>
<Text variant="small">Getting started in Cosmos DB Mongo DB (vCore)</Text>
{currentStep < 5 && (
<Pivot
style={{ marginTop: 10, width: "100%" }}
@@ -68,13 +68,13 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
This tutorial guides you to create and query distributed tables using a sample dataset.
<br />
<br />
To start, input the admin password you used during the cluster creation process into the Document DB
To start, input the admin password you used during the cluster creation process into the MongoDB vCore
terminal.
<br />
<br />
<br />
Note: If you navigate out of the Quick start blade &#40;MongoDB Shell&#41;, the session will be closed
and all ongoing commands might be interrupted.
Note: If you navigate out of the Quick start blade &#40;MongoDB vCore Shell&#41;, the session will be
closed and all ongoing commands might be interrupted.
</Text>
</Stack>
</PivotItem>
@@ -295,7 +295,7 @@ export const VcoreMongoQuickstartGuide: React.FC = (): JSX.Element => {
<br />
<br />
Modernize your data seamlessly from an existing MongoDB cluster, whether it&apos;s on-premises or
hosted in the cloud, to Azure DocumentDB.&nbsp;
hosted in the cloud, to Azure Cosmos DB for MongoDB vCore.&nbsp;
<Link
target="_blank"
href="https://learn.microsoft.com/azure/cosmos-db/mongodb/vcore/migration-options"

View File

@@ -182,7 +182,7 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
},
{
title: "Sample Vector Data",
description: "Load sample vector data with text-embedding-ada-002",
description: "Load sample vector data in your database",
icon: <img src={AzureOpenAiIcon} alt={"Azure Open AI icon"} aria-hidden="true" />,
onClick: () => {
setSelectedSampleDataConfiguration({
@@ -203,7 +203,7 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
title: "Sample Gallery",
description: "Get real-world end-to-end samples",
icon: <img src={GithubIcon} alt={"GitHub icon"} aria-hidden="true" />,
onClick: () => window.open("https://aka.ms/CosmosFabricSamplesGallery", "_blank"),
onClick: () => window.open("https://azurecosmosdb.github.io/gallery/?tags=example&tags=analytics", "_blank"),
},
];

View File

@@ -36,56 +36,6 @@ export enum SampleDataFile {
FABRIC_SAMPLE_VECTOR_DATA = "FabricSampleVectorData",
}
const containerSettings: {
[key in SampleDataFile]: {
partitionKeyString: string;
vectorEmbeddingPolicy?: DataModels.VectorEmbeddingPolicy;
indexingPolicy?: DataModels.IndexingPolicy;
};
} = {
[SampleDataFile.COPILOT]: {
partitionKeyString: "category",
},
[SampleDataFile.FABRIC_SAMPLE_DATA]: {
partitionKeyString: "categoryName",
},
[SampleDataFile.FABRIC_SAMPLE_VECTOR_DATA]: {
partitionKeyString: "categoryName",
vectorEmbeddingPolicy: {
vectorEmbeddings: [
{
path: "/vectors",
dataType: "float32",
distanceFunction: "cosine",
dimensions: 1536,
},
],
},
indexingPolicy: {
automatic: true,
indexingMode: "consistent",
includedPaths: [
{
path: "/*",
},
],
excludedPaths: [
{
path: '/"_etag"/?',
},
],
fullTextIndexes: [],
vectorIndexes: [
{
path: "/vectors",
type: "quantizedFlat",
quantizationByteSize: 64,
},
],
},
},
};
export const createContainer = async (
databaseName: string,
containerName: string,
@@ -99,12 +49,48 @@ export const createContainer = async (
databaseId: databaseName,
databaseLevelThroughput: false,
partitionKey: {
paths: [`/${containerSettings[sampleDataFile].partitionKeyString}`],
paths: [`/${SAMPLE_DATA_PARTITION_KEY}`],
kind: "Hash",
version: BackendDefaults.partitionKeyVersion,
},
vectorEmbeddingPolicy: containerSettings[sampleDataFile].vectorEmbeddingPolicy,
indexingPolicy: containerSettings[sampleDataFile].indexingPolicy,
vectorEmbeddingPolicy:
sampleDataFile === SampleDataFile.FABRIC_SAMPLE_VECTOR_DATA
? {
vectorEmbeddings: [
{
path: "/descriptionVector",
dataType: "float32",
distanceFunction: "cosine",
dimensions: 512,
},
],
}
: undefined,
indexingPolicy:
sampleDataFile === SampleDataFile.FABRIC_SAMPLE_VECTOR_DATA
? {
automatic: true,
indexingMode: "consistent",
includedPaths: [
{
path: "/*",
},
],
excludedPaths: [
{
path: '/"_etag"/?',
},
],
fullTextIndexes: [],
vectorIndexes: [
{
path: "/descriptionVector",
type: "quantizedFlat",
quantizationByteSize: 64,
},
],
}
: undefined,
};
await createCollection(createRequest);
await explorer.refreshAllDatabases();
@@ -117,6 +103,8 @@ export const createContainer = async (
return newCollection;
};
const SAMPLE_DATA_PARTITION_KEY = "category"; // This pkey is specifically set for queryCopilotSampleData.json below
export const importData = async (sampleDataFile: SampleDataFile, collection: ViewModels.Collection): Promise<void> => {
let documents: JSONObject[] = undefined;
switch (sampleDataFile) {

View File

@@ -273,7 +273,7 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
subtitle = "Get started with our sample datasets, documentation, and additional tools.";
break;
case "VCoreMongo":
title = "Welcome to Azure DocumentDB (with MongoDB compatibility)";
title = "Welcome to Azure Cosmos DB for MongoDB (vCore)";
subtitle = "Get started with our sample datasets, documentation, and additional tools.";
break;
default:
@@ -456,7 +456,7 @@ export class SplashScreen extends React.Component<SplashScreenProps> {
if (userContext.apiType === "VCoreMongo") {
icon = VisualStudioIcon;
title = "Connect with VS Code";
description = "Query and Manage your MongoDB and DocumentDB clusters in Visual Studio Code";
description = "Query and Manage your MongoDB cluster in Visual Studio Code";
onClick = () => this.container.openInVsCode();
}

View File

@@ -286,7 +286,7 @@ export class CassandraAPIDataClient extends TableDataClient {
query,
paginationToken,
}),
beforeSend: this.setCommonHeaders as any,
beforeSend: this.setAuthorizationHeader as any,
cache: false,
});
shouldNotify &&
@@ -440,7 +440,7 @@ export class CassandraAPIDataClient extends TableDataClient {
keyspaceId: collection.databaseId,
tableId: collection.id(),
}),
beforeSend: this.setCommonHeaders as any,
beforeSend: this.setAuthorizationHeader as any,
cache: false,
})
.then(
@@ -482,7 +482,7 @@ export class CassandraAPIDataClient extends TableDataClient {
keyspaceId: collection.databaseId,
tableId: collection.id(),
}),
beforeSend: this.setCommonHeaders as any,
beforeSend: this.setAuthorizationHeader as any,
cache: false,
})
.then(
@@ -518,7 +518,7 @@ export class CassandraAPIDataClient extends TableDataClient {
resourceId: resourceId,
query: query,
}),
beforeSend: this.setCommonHeaders as any,
beforeSend: this.setAuthorizationHeader as any,
cache: false,
}).then(
(data: any) => {
@@ -547,7 +547,7 @@ export class CassandraAPIDataClient extends TableDataClient {
return cassandraEndpoint;
}
private setCommonHeaders: (xhr: XMLHttpRequest) => boolean = (xhr: XMLHttpRequest): boolean => {
private setAuthorizationHeader: (xhr: XMLHttpRequest) => boolean = (xhr: XMLHttpRequest): boolean => {
const authorizationHeaderMetadata: ViewModels.AuthorizationTokenHeaderMetadata = getAuthorizationHeader();
xhr.setRequestHeader(authorizationHeaderMetadata.header, authorizationHeaderMetadata.token);
@@ -555,7 +555,6 @@ export class CassandraAPIDataClient extends TableDataClient {
xhr.setRequestHeader(Constants.HttpHeaders.entraIdToken, userContext.aadToken);
}
xhr.setRequestHeader(Constants.HttpHeaders.sessionId, userContext.sessionId);
return true;
};

View File

@@ -1,6 +1,5 @@
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";
@@ -155,13 +154,13 @@ export default class TabsBase extends WaitsForTemplateViewModel {
const db = this.database?.id();
if (coll) {
if (coll.length > 8) {
return substringUtf(coll, 0, 5) + "…" + options.title;
return coll.slice(0, 5) + "…" + options.title;
} else {
return coll + "." + options.title;
}
} else if (db) {
if (db.length > 8) {
return substringUtf(db, 0, 5) + "…" + options.title;
return db.slice(0, 5) + "…" + options.title;
} else {
return db + "." + options.title;
}

View File

@@ -14,30 +14,17 @@ describe("Collection", () => {
defaultTtl: 1,
indexingPolicy: {} as DataModels.IndexingPolicy,
partitionKey,
_rid: "testRid",
_self: "testSelf",
_etag: "testEtag",
_rid: "",
_self: "",
_etag: "",
_ts: 1,
id: "testCollection",
id: "",
};
};
const generateMockCollectionWithDataModel = (data: DataModels.Collection): Collection => {
const mockContainer = {
isReadOnly: () => false,
isFabricCapable: () => true,
databaseAccount: () => ({
name: () => "testAccount",
id: () => "testAccount",
properties: {
enablePartitionKey: true,
partitionKeyDefinitionVersion: 2,
capabilities: [] as string[],
databaseAccountEndpoint: "test.documents.azure.com",
},
}),
} as unknown as Explorer;
return generateCollection(mockContainer, "testDb", data);
const mockContainer = {} as Explorer;
return generateCollection(mockContainer, "abc", data);
};
describe("Partition key path parsing", () => {
@@ -91,7 +78,7 @@ describe("Collection", () => {
expect(collection.partitionKeyPropertyHeaders[0]).toBe("/somePartitionKey");
});
it("should be empty if there is no partition key", () => {
it("should be null if there is no partition key", () => {
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
version: 2,
paths: [],
@@ -101,103 +88,4 @@ describe("Collection", () => {
expect(collection.partitionKeyPropertyHeaders.length).toBe(0);
});
});
describe("Collection properties", () => {
let collection: Collection;
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
paths: ["/id"],
kind: "Hash",
version: 2,
});
beforeEach(() => {
collection = generateMockCollectionWithDataModel(collectionsDataModel);
});
it("should return correct collection id", () => {
expect(collection.id()).toBe("testCollection");
});
it("should return correct rid", () => {
expect(collection.rid).toBe("testRid");
});
it("should return correct self", () => {
expect(collection.self).toBe("testSelf");
});
it("should return correct collection type", () => {
expect(collection.partitionKey).toBeDefined();
});
});
describe("Collection type", () => {
it("should identify large partitioned collection for v2 partition key", () => {
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
paths: ["/id"],
kind: "Hash",
version: 2,
});
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
expect(collection.partitionKey.version).toBe(2);
expect(collection.partitionKey.paths).toEqual(["/id"]);
});
it("should identify standard partitioned collection for v1 partition key", () => {
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
paths: ["/id"],
kind: "Hash",
version: 1,
});
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
expect(collection.partitionKey.version).toBe(1);
expect(collection.partitionKey.paths).toEqual(["/id"]);
});
it("should identify collection without partition key", () => {
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
paths: [],
kind: "Hash",
version: 2,
});
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
expect(collection.partitionKey.paths).toEqual([]);
});
});
describe("Partition key handling", () => {
it("should return correct partition key paths for multiple paths", () => {
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
paths: ["/id", "/pk"],
kind: "Hash",
version: 2,
});
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
expect(collection.partitionKey.paths).toEqual(["/id", "/pk"]);
expect(collection.partitionKeyProperties).toEqual(["id", "pk"]);
});
it("should handle empty partition key paths", () => {
const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({
paths: [],
kind: "Hash",
version: 2,
});
const collection = generateMockCollectionWithDataModel(collectionsDataModel);
expect(collection.partitionKey.paths).toEqual([]);
expect(collection.partitionKeyProperties).toEqual([]);
});
it("should handle undefined partition key", () => {
const collectionData = generateMockCollectionsDataModelWithPartitionKey({
paths: ["/id"],
kind: "Hash",
version: 2,
});
delete collectionData.partitionKey;
const collection = generateMockCollectionWithDataModel(collectionData);
expect(collection.partitionKey).toBeUndefined();
expect(collection.partitionKeyProperties).toEqual([]);
});
});
});

View File

@@ -67,7 +67,6 @@ export default class Collection implements ViewModels.Collection {
public computedProperties: ko.Observable<DataModels.ComputedProperties>;
public materializedViews: ko.Observable<DataModels.MaterializedView[]>;
public materializedViewDefinition: ko.Observable<DataModels.MaterializedViewDefinition>;
public dataMaskingPolicy: ko.Observable<DataModels.DataMaskingPolicy>;
public offer: ko.Observable<DataModels.Offer>;
public conflictResolutionPolicy: ko.Observable<DataModels.ConflictResolutionPolicy>;
@@ -137,35 +136,25 @@ export default class Collection implements ViewModels.Collection {
this.materializedViews = ko.observable(data.materializedViews);
this.materializedViewDefinition = ko.observable(data.materializedViewDefinition);
// Initialize dataMaskingPolicy with default values if not present
const defaultDataMaskingPolicy: DataModels.DataMaskingPolicy = {
includedPaths: Array<{ path: string; strategy: string; startPosition: number; length: number }>(),
excludedPaths: Array<string>(),
isPolicyEnabled: true,
};
const observablePolicy = ko.observable(data.dataMaskingPolicy || defaultDataMaskingPolicy);
observablePolicy.subscribe(() => {});
this.dataMaskingPolicy = observablePolicy;
this.partitionKeyPropertyHeaders = this.partitionKey?.paths || [];
this.partitionKeyProperties =
this.partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader, i) => {
// TODO fix this to only replace non-excaped single quotes
let partitionKeyProperty = partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, "");
this.partitionKeyPropertyHeaders = this.partitionKey?.paths;
this.partitionKeyProperties = this.partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader, i) => {
// TODO fix this to only replace non-excaped single quotes
let partitionKeyProperty = partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, "");
if (userContext.apiType === "Mongo" && partitionKeyProperty) {
if (~partitionKeyProperty.indexOf(`"`)) {
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
}
// TODO #10738269 : Add this logic in a derived class for Mongo
if (partitionKeyProperty.indexOf("$v") > -1) {
// From $v.shard.$v.key.$v > shard.key
partitionKeyProperty = partitionKeyProperty.replace(/.\$v/g, "").replace(/\$v./g, "");
this.partitionKeyPropertyHeaders[i] = "/" + partitionKeyProperty;
}
if (userContext.apiType === "Mongo" && partitionKeyProperty) {
if (~partitionKeyProperty.indexOf(`"`)) {
partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, "");
}
// TODO #10738269 : Add this logic in a derived class for Mongo
if (partitionKeyProperty.indexOf("$v") > -1) {
// From $v.shard.$v.key.$v > shard.key
partitionKeyProperty = partitionKeyProperty.replace(/.\$v/g, "").replace(/\$v./g, "");
this.partitionKeyPropertyHeaders[i] = "/" + partitionKeyProperty;
}
}
return partitionKeyProperty;
}) || [];
return partitionKeyProperty;
});
this.documentIds = ko.observableArray<DocumentId>([]);
this.isCollectionExpanded = ko.observable<boolean>(false);
@@ -174,6 +163,7 @@ export default class Collection implements ViewModels.Collection {
this.documentsFocused = ko.observable<boolean>();
this.documentsFocused.subscribe((focus) => {
console.log("Focus set on Documents: " + focus);
this.focusedSubnodeKind(ViewModels.CollectionTabKind.Documents);
});

View File

@@ -20,7 +20,7 @@ import "../externals/jquery.typeahead.min.css";
import "../externals/jquery.typeahead.min.js";
// Image Dependencies
import { Platform } from "ConfigContext";
import ContainerCopyPanel from "Explorer/ContainerCopy/ContainerCopyPanel";
import ContainerCopyPanel from "Explorer/ContainerCopy";
import Explorer from "Explorer/Explorer";
import { QueryCopilotCarousel } from "Explorer/QueryCopilot/CopilotCarousel";
import { SidebarContainer } from "Explorer/Sidebar";
@@ -78,16 +78,19 @@ const App: React.FunctionComponent = () => {
}
StyleConstants.updateStyles();
const explorer = useKnockoutExplorer(config?.platform);
// console.log("Using config: ", config);
if (!explorer) {
return <LoadingExplorer />;
}
// console.log("Using explorer: ", explorer);
// console.log("Using userContext: ", userContext);
return (
<KeyboardShortcutRoot>
<div className="flexContainer" aria-hidden="false" data-test="DataExplorerRoot">
{userContext.features.enableContainerCopy && userContext.apiType === "SQL" ? (
<ContainerCopyPanel explorer={explorer} />
<ContainerCopyPanel container={explorer} />
) : (
<DivExplorer explorer={explorer} />
)}

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="height=device-height, width=device-width, initial-scale=1.0" />
<title>Notebook Viewer</title>
<link rel="shortcut icon" href="../../images/CosmosDB_rgb_ui_lighttheme.ico" type="image/x-icon" />
</head>
<body>
<div class="notebookComponentContainer" id="notebookContent"></div>
</body>
</html>

View File

@@ -105,12 +105,6 @@ const requestAndStoreAccessToken = async (): Promise<void> => {
});
};
export const openRestoreContainerDialog = (): void => {
if (configContext.platform === Platform.Fabric) {
sendCachedDataMessage(FabricMessageTypes.RestoreContainer, []);
}
};
/**
* Check token validity and schedule a refresh if necessary
* @param tokenTimestamp

Some files were not shown because too many files have changed in this diff Show More