mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-26 04:11:24 +00:00
Compare commits
32 Commits
pixelCorre
...
users/aisa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30e106f600 | ||
|
|
383da73c52 | ||
|
|
ca858c08fb | ||
|
|
fa18b85364 | ||
|
|
d060f22357 | ||
|
|
9a6f090374 | ||
|
|
63cddeb4b8 | ||
|
|
bb0bbd8a6e | ||
|
|
a33429fd85 | ||
|
|
784dadce30 | ||
|
|
490309b403 | ||
|
|
0fac59967a | ||
|
|
c72d921866 | ||
|
|
125b1c86b7 | ||
|
|
beccab02e7 | ||
|
|
a2e90b3a38 | ||
|
|
33a7412cf3 | ||
|
|
6b150dbfa0 | ||
|
|
bbdf0ce57e | ||
|
|
2417da152d | ||
|
|
3718f5a16a | ||
|
|
08f55ded3d | ||
|
|
74cd4b2ff4 | ||
|
|
27e07bcd01 | ||
|
|
18ecaaba78 | ||
|
|
0578910b9e | ||
|
|
ff1eb6a78e | ||
|
|
31ec3c08bc | ||
|
|
abf4b3bd0f | ||
|
|
d0d615a85a | ||
|
|
2996120235 | ||
|
|
3cd6d5a65d |
11
.vscode/settings.json
vendored
11
.vscode/settings.json
vendored
@@ -24,14 +24,5 @@
|
||||
"source.organizeImports": "explicit"
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"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"
|
||||
}
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
|
||||
96
package-lock.json
generated
96
package-lock.json
generated
@@ -10,7 +10,7 @@
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@azure/arm-cosmosdb": "9.1.0",
|
||||
"@azure/cosmos": "4.5.0",
|
||||
"@azure/cosmos": "4.7.0",
|
||||
"@azure/cosmos-language-service": "0.0.5",
|
||||
"@azure/identity": "4.5.0",
|
||||
"@azure/msal-browser": "2.14.2",
|
||||
@@ -116,6 +116,7 @@
|
||||
"tinykeys": "2.1.0",
|
||||
"underscore": "1.12.1",
|
||||
"utility-types": "3.10.0",
|
||||
"uuid": "9.0.0",
|
||||
"zustand": "3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -391,9 +392,9 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@azure/cosmos": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/cosmos/-/cosmos-4.5.0.tgz",
|
||||
"integrity": "sha512-JsTh4twb6FcwP7rJwxQiNZQ/LGtuF6gmciaxY9Rnp6/A325Lhsw/SH4R2ArpT0yCvozbZpweIwdPfUkXVBtp5w==",
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@azure/cosmos/-/cosmos-4.7.0.tgz",
|
||||
"integrity": "sha512-a8OV7E41u/ZDaaaDAFdqTTiJ7c82jZc/+ot3XzNCIIilR25NBB+1ixzWQOAgP8SHRUIKfaUl6wAPdTuiG9I66A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/abort-controller": "^2.1.2",
|
||||
@@ -626,6 +627,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/ms-rest-js/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/ms-rest-js/node_modules/xml2js": {
|
||||
"version": "0.5.0",
|
||||
"license": "MIT",
|
||||
@@ -685,6 +694,14 @@
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/msal-node/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.24.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz",
|
||||
@@ -7595,6 +7612,14 @@
|
||||
"uuid": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/commutable/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/connected-components": {
|
||||
"version": "6.8.2",
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -9125,6 +9150,14 @@
|
||||
"uuid": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/fixtures/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/iron-icons": {
|
||||
"version": "1.0.0",
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -9282,6 +9315,14 @@
|
||||
"uuid": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/messaging/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/monaco-editor": {
|
||||
"version": "3.2.2",
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -9397,6 +9438,14 @@
|
||||
"version": "0.18.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nteract/monaco-editor/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/mythic-configuration": {
|
||||
"version": "1.0.12",
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -9665,6 +9714,14 @@
|
||||
"uuid": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/reducers/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/selectors": {
|
||||
"version": "3.2.0",
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -9888,6 +9945,14 @@
|
||||
"uuid": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nteract/types/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@octokit/auth-token": {
|
||||
"version": "4.0.0",
|
||||
"license": "MIT",
|
||||
@@ -26419,6 +26484,15 @@
|
||||
"xmlbuilder": "^15.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-trx-results-processor/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-util": {
|
||||
"version": "24.9.0",
|
||||
"license": "MIT",
|
||||
@@ -33753,6 +33827,15 @@
|
||||
"websocket-driver": "^0.7.4"
|
||||
}
|
||||
},
|
||||
"node_modules/sockjs/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -35619,8 +35702,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"license": "MIT",
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
|
||||
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@azure/arm-cosmosdb": "9.1.0",
|
||||
"@azure/cosmos": "4.5.0",
|
||||
"@azure/cosmos": "4.7.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/xterm": "5.5.0",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"allotment": "1.20.2",
|
||||
"applicationinsights": "1.8.0",
|
||||
"bootstrap": "3.4.1",
|
||||
@@ -111,6 +111,7 @@
|
||||
"tinykeys": "2.1.0",
|
||||
"underscore": "1.12.1",
|
||||
"utility-types": "3.10.0",
|
||||
"uuid": "9.0.0",
|
||||
"zustand": "3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -7,7 +7,6 @@ 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({
|
||||
@@ -57,11 +56,7 @@ app.get("/pull/:pr(\\d+)", (req, res) => {
|
||||
|
||||
fetch(`${githubApiUrl}/pulls/${pr}`)
|
||||
.then((response) => response.json())
|
||||
.then(({ head: { ref, sha } }) => {
|
||||
const prUrl = new URL(`${githubPullRequestUrl}/${pr}`);
|
||||
prUrl.hash = ref;
|
||||
search.set("feature.pr", prUrl.href);
|
||||
|
||||
.then(({ head: { sha } }) => {
|
||||
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
@@ -90,6 +90,10 @@ 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 {
|
||||
@@ -293,6 +297,7 @@ export class HttpHeaders {
|
||||
public static migrateOfferToManualThroughput: string = "x-ms-cosmos-migrate-offer-to-manual-throughput";
|
||||
public static migrateOfferToAutopilot: string = "x-ms-cosmos-migrate-offer-to-autopilot";
|
||||
public static xAPIKey: string = "X-API-Key";
|
||||
public static sessionId: string = "x-ms-client-session-id";
|
||||
}
|
||||
|
||||
export class ContentType {
|
||||
@@ -526,11 +531,6 @@ export class PriorityLevel {
|
||||
public static readonly Default = "low";
|
||||
}
|
||||
|
||||
export class ariaLabelForLearnMoreLink {
|
||||
public static readonly AnalyticalStore = "Learn more about analytical store.";
|
||||
public static readonly AzureSynapseLink = "Learn more about Azure Synapse Link.";
|
||||
}
|
||||
|
||||
export class GlobalSecondaryIndexLabels {
|
||||
public static readonly NewGlobalSecondaryIndex: string = "New Global Secondary Index";
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ export const getDatabaseEndpoint = (apiType: ApiType): string => {
|
||||
return "gremlinDatabases";
|
||||
case "Tables":
|
||||
return "tables";
|
||||
default:
|
||||
case "SQL":
|
||||
default:
|
||||
return "sqlDatabases";
|
||||
}
|
||||
};
|
||||
@@ -58,8 +58,8 @@ export const getCollectionEndpoint = (apiType: ApiType): string => {
|
||||
return "tables";
|
||||
case "Gremlin":
|
||||
return "graphs";
|
||||
default:
|
||||
case "SQL":
|
||||
default:
|
||||
return "containers";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,7 +23,10 @@ export const handleError = (error: string | ARMError | Error, area: string, cons
|
||||
};
|
||||
|
||||
export const getErrorMessage = (error: string | Error = ""): string => {
|
||||
const errorMessage = typeof error === "string" ? error : error.message;
|
||||
let errorMessage = typeof error === "string" ? error : error.message;
|
||||
if (!errorMessage) {
|
||||
errorMessage = JSON.stringify(error);
|
||||
}
|
||||
return replaceKnownError(errorMessage);
|
||||
};
|
||||
|
||||
|
||||
31
src/Common/LoadingOverlay.tsx
Normal file
31
src/Common/LoadingOverlay.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Overlay, Spinner, SpinnerSize } from "@fluentui/react";
|
||||
import React from "react";
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
isLoading: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading, label }) => {
|
||||
if (!isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Overlay
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: "rgba(255,255,255,0.9)",
|
||||
zIndex: 9999,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Spinner size={SpinnerSize.large} label={label} styles={{ label: { fontWeight: 600 } }} />
|
||||
</Overlay>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingOverlay;
|
||||
@@ -17,6 +17,7 @@ const defaultHeaders = {
|
||||
[HttpHeaders.apiType]: ApiType.MongoDB.toString(),
|
||||
[CosmosSDKConstants.HttpHeaders.MaxEntityCount]: "100",
|
||||
[CosmosSDKConstants.HttpHeaders.Version]: "2017-11-15",
|
||||
[HttpHeaders.sessionId]: userContext.sessionId,
|
||||
};
|
||||
|
||||
function authHeaders() {
|
||||
|
||||
13
src/Common/Pager/Pager.css
Normal file
13
src/Common/Pager/Pager.css
Normal file
@@ -0,0 +1,13 @@
|
||||
.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;
|
||||
}
|
||||
111
src/Common/Pager/index.tsx
Normal file
111
src/Common/Pager/index.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
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;
|
||||
@@ -11,19 +11,11 @@ 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` }, // Indent for hierarchy
|
||||
{ type: ShimmerElementType.gap, width: `${indent.level * 20}px` },
|
||||
{ type: ShimmerElementType.line, height: 16, width: indent.width || "100%" },
|
||||
]}
|
||||
style={{ marginBottom: 8 }}
|
||||
@@ -95,9 +95,6 @@ const createSqlContainer = async (params: DataModels.CreateCollectionParams): Pr
|
||||
const resource: ARMTypes.SqlContainerResource = {
|
||||
id: params.collectionId,
|
||||
};
|
||||
if (params.analyticalStorageTtl) {
|
||||
resource.analyticalStorageTtl = params.analyticalStorageTtl;
|
||||
}
|
||||
if (params.indexingPolicy) {
|
||||
resource.indexingPolicy = params.indexingPolicy;
|
||||
}
|
||||
@@ -138,9 +135,6 @@ const createMongoCollection = async (params: DataModels.CreateCollectionParams):
|
||||
const resource: ARMTypes.MongoDBCollectionResource = {
|
||||
id: params.collectionId,
|
||||
};
|
||||
if (params.analyticalStorageTtl) {
|
||||
resource.analyticalStorageTtl = params.analyticalStorageTtl;
|
||||
}
|
||||
if (params.partitionKey) {
|
||||
const partitionKeyPath: string = params.partitionKey.paths[0];
|
||||
resource.shardKey = { [partitionKeyPath]: "Hash" };
|
||||
@@ -179,9 +173,6 @@ const createCassandraTable = async (params: DataModels.CreateCollectionParams):
|
||||
const resource: ARMTypes.CassandraTableResource = {
|
||||
id: params.collectionId,
|
||||
};
|
||||
if (params.analyticalStorageTtl) {
|
||||
resource.analyticalStorageTtl = params.analyticalStorageTtl;
|
||||
}
|
||||
|
||||
const rpPayload: ARMTypes.CassandraTableCreateUpdateParameters = {
|
||||
properties: {
|
||||
@@ -282,7 +273,6 @@ const createCollectionWithSDK = async (params: DataModels.CreateCollectionParams
|
||||
partitionKey: params.partitionKey || undefined,
|
||||
indexingPolicy: params.indexingPolicy || undefined,
|
||||
uniqueKeyPolicy: params.uniqueKeyPolicy || undefined,
|
||||
analyticalStorageTtl: params.analyticalStorageTtl,
|
||||
vectorEmbeddingPolicy: params.vectorEmbeddingPolicy,
|
||||
fullTextPolicy: params.fullTextPolicy,
|
||||
} as ContainerRequest; // TODO: remove cast when https://github.com/Azure/azure-cosmos-js/issues/423 is fixed
|
||||
|
||||
@@ -23,9 +23,6 @@ export const createGlobalSecondaryIndex = async (params: CreateGlobalSecondaryIn
|
||||
if (params.materializedViewDefinition) {
|
||||
resource.materializedViewDefinition = params.materializedViewDefinition;
|
||||
}
|
||||
if (params.analyticalStorageTtl) {
|
||||
resource.analyticalStorageTtl = params.analyticalStorageTtl;
|
||||
}
|
||||
if (params.indexingPolicy) {
|
||||
resource.indexingPolicy = params.indexingPolicy;
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -23,10 +24,17 @@ 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(), getPartitionKeyValue(documentId))
|
||||
.item(documentId.id(), partitionKey)
|
||||
.replace(newDocument, options);
|
||||
|
||||
logConsoleInfo(`Successfully updated ${entityName} ${documentId.id()}`);
|
||||
|
||||
@@ -36,14 +36,6 @@ export interface DatabaseAccountSystemData {
|
||||
|
||||
export interface DatabaseAccountBackupPolicy {
|
||||
type: string;
|
||||
/* periodicModeProperties?: {
|
||||
backupIntervalInMinutes: number;
|
||||
backupRetentionIntervalInHours: number;
|
||||
backupStorageRedundancy: string;
|
||||
};
|
||||
continuousModeProperties?: {
|
||||
tier: string;
|
||||
}; */
|
||||
}
|
||||
|
||||
export interface DatabaseAccountExtendedProperties {
|
||||
@@ -73,6 +65,7 @@ export interface DatabaseAccountExtendedProperties {
|
||||
publicNetworkAccess?: string;
|
||||
enablePriorityBasedExecution?: boolean;
|
||||
vcoreMongoEndpoint?: string;
|
||||
enableAllVersionsAndDeletesChangeFeed?: boolean;
|
||||
}
|
||||
|
||||
export interface DatabaseAccountResponseLocation {
|
||||
@@ -210,6 +203,7 @@ export interface Collection extends Resource {
|
||||
geospatialConfig?: GeospatialConfig;
|
||||
vectorEmbeddingPolicy?: VectorEmbeddingPolicy;
|
||||
fullTextPolicy?: FullTextPolicy;
|
||||
dataMaskingPolicy?: DataMaskingPolicy;
|
||||
schema?: ISchema;
|
||||
requestSchema?: () => void;
|
||||
computedProperties?: ComputedProperties;
|
||||
@@ -274,6 +268,17 @@ 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;
|
||||
@@ -411,7 +416,6 @@ export interface CreateCollectionParamsBase {
|
||||
databaseId: string;
|
||||
databaseLevelThroughput: boolean;
|
||||
offerThroughput?: number;
|
||||
analyticalStorageTtl?: number;
|
||||
autoPilotMaxThroughput?: number;
|
||||
indexingPolicy?: IndexingPolicy;
|
||||
partitionKey?: PartitionKey;
|
||||
|
||||
@@ -49,4 +49,5 @@ export enum MessageTypes {
|
||||
Ready, // unused. Can be removed if the portal uses the same list of enums.
|
||||
OpenCESCVAFeedbackBlade,
|
||||
ActivateTab,
|
||||
OpenContainerCopyFeedbackBlade,
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ 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>;
|
||||
@@ -445,6 +446,7 @@ export interface DataExplorerInputsFrame {
|
||||
feedbackPolicies?: any;
|
||||
aadToken?: string;
|
||||
containerCopyEnabled?: boolean;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface SelfServeFrameInputs {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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,
|
||||
@@ -21,18 +23,30 @@ import {
|
||||
extractErrorMessage,
|
||||
formatUTCDateTime,
|
||||
getAccountDetailsFromResourceId,
|
||||
isIntraAccountCopy,
|
||||
} from "../CopyJobUtils";
|
||||
import CreateCopyJobScreensProvider from "../CreateCopyJob/Screens/CreateCopyJobScreensProvider";
|
||||
import { CopyJobActions, CopyJobStatusType } from "../Enums";
|
||||
import { CopyJobActions, CopyJobStatusType } from "../Enums/CopyJobEnums";
|
||||
import CopyJobDetails from "../MonitorCopyJobs/Components/CopyJobDetails";
|
||||
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
|
||||
import { CopyJobContextState, CopyJobError, CopyJobErrorType, CopyJobType } from "../Types";
|
||||
import { CopyJobContextState, CopyJobError, CopyJobErrorType, CopyJobType } from "../Types/CopyJobTypes";
|
||||
|
||||
export const openCreateCopyJobPanel = () => {
|
||||
export const openCreateCopyJobPanel = (explorer: Explorer) => {
|
||||
const sidePanelState = useSidePanel.getState();
|
||||
sidePanelState.setPanelHasConsole(false);
|
||||
sidePanelState.openSidePanel(
|
||||
ContainerCopyMessages.createCopyJobPanelTitle,
|
||||
<CreateCopyJobScreensProvider />,
|
||||
<CreateCopyJobScreensProvider explorer={explorer} />,
|
||||
"650px",
|
||||
);
|
||||
};
|
||||
|
||||
export const openCopyJobDetailsPanel = (job: CopyJobType) => {
|
||||
const sidePanelState = useSidePanel.getState();
|
||||
sidePanelState.setPanelHasConsole(false);
|
||||
sidePanelState.openSidePanel(
|
||||
ContainerCopyMessages.copyJobDetailsPanelTitle(job.Name),
|
||||
<CopyJobDetails job={job} />,
|
||||
"650px",
|
||||
);
|
||||
};
|
||||
@@ -40,12 +54,12 @@ export const openCreateCopyJobPanel = () => {
|
||||
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 || "",
|
||||
);
|
||||
@@ -62,7 +76,6 @@ export const getCopyJobs = async (): Promise<CopyJobType[]> => {
|
||||
}
|
||||
copyJobsAbortController = null;
|
||||
|
||||
/* added a lower bound to "0" and upper bound to "100" */
|
||||
const calculateCompletionPercentage = (processed: number, total: number): number => {
|
||||
if (
|
||||
typeof processed !== "number" ||
|
||||
@@ -96,6 +109,8 @@ 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),
|
||||
@@ -106,9 +121,15 @@ export const getCopyJobs = async (): Promise<CopyJobType[]> => {
|
||||
});
|
||||
return formattedJobs;
|
||||
} catch (error) {
|
||||
const errorContent = JSON.stringify(error.content || error);
|
||||
console.error(`Error fetching copy jobs: ${errorContent}`);
|
||||
throw 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -118,11 +139,12 @@ export const submitCreateCopyJob = async (state: CopyJobContextState, onSuccess:
|
||||
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
|
||||
userContext.databaseAccount?.id || "",
|
||||
);
|
||||
const isSameAccount = isIntraAccountCopy(source?.account?.id, target?.account?.id);
|
||||
const body = {
|
||||
properties: {
|
||||
source: {
|
||||
component: "CosmosDBSql",
|
||||
remoteAccountName: source?.account?.name,
|
||||
...(isSameAccount ? {} : { remoteAccountName: source?.account?.name }),
|
||||
databaseName: source?.databaseId,
|
||||
containerName: source?.containerId,
|
||||
},
|
||||
@@ -140,7 +162,8 @@ export const submitCreateCopyJob = async (state: CopyJobContextState, onSuccess:
|
||||
onSuccess();
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error submitting create copy job:", error);
|
||||
const errorMessage = error.message || "Error submitting create copy job. Please try again later.";
|
||||
logError(errorMessage, "CopyJob/CopyJobActions.submitCreateCopyJob");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -179,8 +202,7 @@ export const updateCopyJobStatus = async (job: CopyJobType, action: string): Pro
|
||||
pattern,
|
||||
`'${ContainerCopyMessages.MonitorJobs.Status.InProgress}'`,
|
||||
);
|
||||
|
||||
console.error(`Error updating copy job status: ${normalizedErrorMessage}`);
|
||||
logError(`Error updating copy job status: ${normalizedErrorMessage}`, "CopyJob/CopyJobActions.updateCopyJobStatus");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
import { ContainerCopyProps } from "../Types/CopyJobTypes";
|
||||
import { getCommandBarButtons } from "./Utils";
|
||||
|
||||
const backgroundColor = StyleConstants.BaseLight;
|
||||
@@ -13,8 +13,8 @@ const rootStyle = {
|
||||
},
|
||||
};
|
||||
|
||||
const CopyJobCommandBar: React.FC<ContainerCopyProps> = ({ container }) => {
|
||||
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(container);
|
||||
const CopyJobCommandBar: React.FC<ContainerCopyProps> = ({ explorer }) => {
|
||||
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer);
|
||||
const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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";
|
||||
import { CopyJobCommandBarBtnType } from "../Types/CopyJobTypes";
|
||||
|
||||
function getCopyJobBtns(): CopyJobCommandBarBtnType[] {
|
||||
function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] {
|
||||
const monitorCopyJobsRef = MonitorCopyJobsRefState((state) => state.ref);
|
||||
const buttons: CopyJobCommandBarBtnType[] = [
|
||||
{
|
||||
@@ -17,7 +17,7 @@ function getCopyJobBtns(): CopyJobCommandBarBtnType[] {
|
||||
iconSrc: AddIcon,
|
||||
label: ContainerCopyMessages.createCopyJobButtonLabel,
|
||||
ariaLabel: ContainerCopyMessages.createCopyJobButtonAriaLabel,
|
||||
onClick: Actions.openCreateCopyJobPanel,
|
||||
onClick: Actions.openCreateCopyJobPanel.bind(null, explorer),
|
||||
},
|
||||
{
|
||||
key: "refresh",
|
||||
@@ -33,7 +33,9 @@ function getCopyJobBtns(): CopyJobCommandBarBtnType[] {
|
||||
iconSrc: FeedbackIcon,
|
||||
label: ContainerCopyMessages.feedbackButtonLabel,
|
||||
ariaLabel: ContainerCopyMessages.feedbackButtonAriaLabel,
|
||||
onClick: () => {},
|
||||
onClick: () => {
|
||||
explorer.openContainerCopyFeedbackBlade();
|
||||
},
|
||||
});
|
||||
}
|
||||
return buttons;
|
||||
@@ -52,7 +54,6 @@ function btnMapper(config: CopyJobCommandBarBtnType): CommandButtonComponentProp
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function getCommandBarButtons(_container: Explorer): CommandButtonComponentProps[] {
|
||||
return getCopyJobBtns().map(btnMapper);
|
||||
export function getCommandBarButtons(explorer: Explorer): CommandButtonComponentProps[] {
|
||||
return getCopyJobBtns(explorer).map(btnMapper);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,13 @@ 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: "Copy container",
|
||||
createCopyJobPanelTitle: "Create copy job",
|
||||
|
||||
// Select Account Screen
|
||||
selectAccountDescription: "Please select a source account from which to copy.",
|
||||
@@ -31,6 +36,9 @@ export default {
|
||||
databaseDropdownPlaceholder: "Select a database",
|
||||
containerDropdownLabel: "Container",
|
||||
containerDropdownPlaceholder: "Select a container",
|
||||
createNewContainerSubHeading: "Select the properties for your container.",
|
||||
createContainerButtonLabel: "Create a new container",
|
||||
createContainerHeading: "Create new container",
|
||||
|
||||
// Preview and Create Screen
|
||||
jobNameLabel: "Job name",
|
||||
@@ -43,60 +51,97 @@ export default {
|
||||
|
||||
// Assign Permissions Screen
|
||||
assignPermissions: {
|
||||
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.",
|
||||
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.`,
|
||||
crossAccountConfiguration: {
|
||||
title: "Cross-account container copy",
|
||||
description: (sourceAccount: string, destinationAccount: string) =>
|
||||
`Please follow the instruction below to grant requisite permissions to copy data from "${sourceAccount}" to "${destinationAccount}".`,
|
||||
},
|
||||
onlineConfiguration: {
|
||||
title: "Online container copy",
|
||||
description: (accountName: string) =>
|
||||
`Please follow the instructions below to enable online copy on your "${accountName}" account.`,
|
||||
},
|
||||
},
|
||||
toggleBtn: {
|
||||
onText: "On",
|
||||
offText: "Off",
|
||||
},
|
||||
popoverOverlaySpinnerLabel: "Please wait while we process your request...",
|
||||
addManagedIdentity: {
|
||||
title: "System assigned managed identity enabled",
|
||||
title: "System-assigned managed identity enabled.",
|
||||
description:
|
||||
"Enable a system assigned managed identity for the destination account to allow the copy job to access it.",
|
||||
"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 don’t 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",
|
||||
toggleLabel: "System assigned managed identity",
|
||||
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.",
|
||||
tooltip: {
|
||||
content: "Learn more about",
|
||||
hrefText: "Managed Identities.",
|
||||
href: "https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/overview",
|
||||
},
|
||||
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: (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}'?`
|
||||
enablementDescription: (accountName: string) =>
|
||||
accountName
|
||||
? `Enable system-assigned managed identity on the ${accountName}. To confirm, click the "Yes" button. `
|
||||
: "",
|
||||
},
|
||||
defaultManagedIdentity: {
|
||||
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.",
|
||||
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",
|
||||
},
|
||||
popoverTitle: "System assigned managed identity set as default",
|
||||
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.",
|
||||
popoverDescription: (accountName: string) =>
|
||||
`Assign the system-assigned managed identity as the default for "${accountName}". To confirm, click the "Yes" button. `,
|
||||
},
|
||||
readPermissionAssigned: {
|
||||
title: "Read permission assigned to default identity",
|
||||
title: "Read permissions assigned to the default identity.",
|
||||
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: "Read permission assigned to default identity",
|
||||
"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.",
|
||||
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.",
|
||||
"Assign read permissions of the source account to the default identity of the destination account. To confirm click the “Yes” button. ",
|
||||
},
|
||||
pointInTimeRestore: {
|
||||
title: "Point In Time Restore enabled",
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
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",
|
||||
},
|
||||
buttonText: "Enable Point In Time Restore",
|
||||
},
|
||||
onlineCopyEnabled: {
|
||||
title: "Online copy enabled",
|
||||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
description: (accountName: string) =>
|
||||
`Enable online container copy by clicking the button below on your "${accountName}" account.`,
|
||||
hrefText: "Learn more about online copy jobs",
|
||||
href: "https://learn.microsoft.com/en-us/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#enable-online-copy",
|
||||
buttonText: "Enable Online Copy",
|
||||
validateAllVersionsAndDeletesChangeFeedSpinnerLabel:
|
||||
"Validating All versions and deletes change feed mode (preview)...",
|
||||
enablingAllVersionsAndDeletesChangeFeedSpinnerLabel:
|
||||
"Enabling All versions and deletes change feed mode (preview)...",
|
||||
enablingOnlineCopySpinnerLabel: (accountName: string) =>
|
||||
`Enabling online copy on your "${accountName}" account ...`,
|
||||
},
|
||||
MonitorJobs: {
|
||||
Columns: {
|
||||
|
||||
@@ -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";
|
||||
import { ContainerCopyProps } from "./Types/CopyJobTypes";
|
||||
|
||||
const ContainerCopyPanel: React.FC<ContainerCopyProps> = ({ container }) => {
|
||||
const ContainerCopyPanel: React.FC<ContainerCopyProps> = ({ explorer }) => {
|
||||
const monitorCopyJobsRef = React.useRef<MonitorCopyJobsRef>();
|
||||
useEffect(() => {
|
||||
if (monitorCopyJobsRef.current) {
|
||||
@@ -14,8 +14,8 @@ const ContainerCopyPanel: React.FC<ContainerCopyProps> = ({ container }) => {
|
||||
}, [monitorCopyJobsRef.current]);
|
||||
return (
|
||||
<div id="containerCopyWrapper" className="flexContainer hideOverflows">
|
||||
<CopyJobCommandBar container={container} />
|
||||
<MonitorCopyJobs ref={monitorCopyJobsRef} />
|
||||
<CopyJobCommandBar explorer={explorer} />
|
||||
<MonitorCopyJobs ref={monitorCopyJobsRef} explorer={explorer} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { Subscription } from "Contracts/DataModels";
|
||||
import React from "react";
|
||||
import { userContext } from "UserContext";
|
||||
import { CopyJobMigrationType } from "../Enums";
|
||||
import { CopyJobContextProviderType, CopyJobContextState, CopyJobFlowType } from "../Types";
|
||||
import { CopyJobMigrationType } from "../Enums/CopyJobEnums";
|
||||
import { CopyJobContextProviderType, CopyJobContextState, CopyJobFlowType } from "../Types/CopyJobTypes";
|
||||
|
||||
export const CopyJobContext = React.createContext<CopyJobContextProviderType>(null);
|
||||
export const useCopyJobContext = (): CopyJobContextProviderType => {
|
||||
@@ -14,6 +16,7 @@ export const useCopyJobContext = (): CopyJobContextProviderType => {
|
||||
|
||||
interface CopyJobContextProviderProps {
|
||||
children: React.ReactNode;
|
||||
explorer: Explorer;
|
||||
}
|
||||
|
||||
const getInitialCopyJobState = (): CopyJobContextState => {
|
||||
@@ -21,8 +24,10 @@ const getInitialCopyJobState = (): CopyJobContextState => {
|
||||
jobName: "",
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
source: {
|
||||
subscription: null,
|
||||
account: null,
|
||||
subscription: {
|
||||
subscriptionId: userContext.subscriptionId || "",
|
||||
} as Subscription,
|
||||
account: userContext.databaseAccount || null,
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
},
|
||||
@@ -39,16 +44,24 @@ 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());
|
||||
};
|
||||
|
||||
return (
|
||||
<CopyJobContext.Provider value={{ copyJobState, setCopyJobState, flow, setFlow, resetCopyJobState }}>
|
||||
{props.children}
|
||||
</CopyJobContext.Provider>
|
||||
);
|
||||
const contextValue: CopyJobContextProviderType = {
|
||||
contextError,
|
||||
setContextError,
|
||||
copyJobState,
|
||||
setCopyJobState,
|
||||
flow,
|
||||
setFlow,
|
||||
resetCopyJobState,
|
||||
explorer: props.explorer,
|
||||
};
|
||||
|
||||
return <CopyJobContext.Provider value={contextValue}>{props.children}</CopyJobContext.Provider>;
|
||||
};
|
||||
|
||||
export default CopyJobContextProvider;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import { CopyJobErrorType } from "./Types";
|
||||
import { CopyJobContextState, CopyJobErrorType, CopyJobType } from "./Types/CopyJobTypes";
|
||||
|
||||
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 for invalid format
|
||||
return null;
|
||||
}
|
||||
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 "0 seconds" if all parts are zero
|
||||
return formattedTimeParts || "0 seconds";
|
||||
}
|
||||
|
||||
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,3 +114,58 @@ 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 "";
|
||||
}
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { Link, Stack, Text, Toggle } from "@fluentui/react";
|
||||
import React, { useMemo } from "react";
|
||||
import React 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 = ContainerCopyMessages.addManagedIdentity.managedIdentityTooltip;
|
||||
const userAssignedTooltip = ContainerCopyMessages.addManagedIdentity.userAssignedIdentityTooltip;
|
||||
|
||||
const textStyle = { display: "flex", alignItems: "center" };
|
||||
|
||||
const managedIdentityTooltip = (
|
||||
<Text>
|
||||
{ContainerCopyMessages.addManagedIdentity.tooltip.content}
|
||||
<Link href={ContainerCopyMessages.addManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.addManagedIdentity.tooltip.hrefText}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
|
||||
|
||||
const AddManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
|
||||
@@ -22,35 +24,22 @@ 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} 
|
||||
<Link href={ContainerCopyMessages.addManagedIdentity.descriptionHref} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.addManagedIdentity.descriptionHrefText}
|
||||
</Link>{" "}
|
||||
|
||||
<InfoTooltip content={managedIdentityTooltip} />
|
||||
</Text>
|
||||
<Toggle
|
||||
label={
|
||||
<Text className="toggle-label" style={textStyle}>
|
||||
{ContainerCopyMessages.addManagedIdentity.toggleLabel}
|
||||
<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}
|
||||
<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}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Stack, Toggle } from "@fluentui/react";
|
||||
import React, { useCallback } from "react";
|
||||
import { Link, Stack, Text, Toggle } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import { logError } from "../../../../../Common/Logger";
|
||||
import { assignRole } from "../../../../../Utils/arm/RbacUtils";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
@@ -9,15 +10,22 @@ import PopoverMessage from "../Components/PopoverContainer";
|
||||
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
||||
import useToggle from "./hooks/useToggle";
|
||||
|
||||
const TooltipContent = ContainerCopyMessages.readPermissionAssigned.tooltip;
|
||||
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
|
||||
const TooltipContent = (
|
||||
<Text>
|
||||
{ContainerCopyMessages.readPermissionAssigned.tooltip.content}
|
||||
<Link href={ContainerCopyMessages.readPermissionAssigned.tooltip.href} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.readPermissionAssigned.tooltip.hrefText}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
type AddReadPermissionToDefaultIdentityProps = Partial<PermissionSectionConfig>;
|
||||
|
||||
const AddReadPermissionToDefaultIdentity: React.FC<AddManagedIdentityProps> = () => {
|
||||
const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIdentityProps> = () => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext();
|
||||
const [readPermissionAssigned, onToggle] = useToggle(false);
|
||||
|
||||
const handleAddReadPermission = useCallback(async () => {
|
||||
const handleAddReadPermission = async () => {
|
||||
const { source, target } = copyJobState;
|
||||
const selectedSourceAccount = source?.account;
|
||||
try {
|
||||
@@ -41,18 +49,21 @@ const AddReadPermissionToDefaultIdentity: React.FC<AddManagedIdentityProps> = ()
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error assigning read permission to default identity:", error);
|
||||
const errorMessage =
|
||||
error.message || "Error assigning read permission to default identity. Please try again later.";
|
||||
logError(errorMessage, "CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission");
|
||||
setContextError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [copyJobState, setCopyJobState]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<div className="toggle-label">
|
||||
{ContainerCopyMessages.readPermissionAssigned.description}
|
||||
<Text className="toggle-label">
|
||||
{ContainerCopyMessages.readPermissionAssigned.description} 
|
||||
<InfoTooltip content={TooltipContent} />
|
||||
</div>
|
||||
</Text>
|
||||
<Toggle
|
||||
checked={readPermissionAssigned}
|
||||
onText={ContainerCopyMessages.toggleBtn.onText}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
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;
|
||||
@@ -1,24 +1,33 @@
|
||||
import { Stack, Toggle } from "@fluentui/react";
|
||||
import { Link, Stack, Text, 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 = ContainerCopyMessages.defaultManagedIdentity.tooltip;
|
||||
const managedIdentityTooltip = (
|
||||
<Text>
|
||||
{ContainerCopyMessages.defaultManagedIdentity.tooltip.content}
|
||||
<Link href={ContainerCopyMessages.defaultManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.defaultManagedIdentity.tooltip.hrefText}
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
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}
|
||||
{ContainerCopyMessages.defaultManagedIdentity.description(copyJobState?.target?.account.name)}
|
||||
<InfoTooltip content={managedIdentityTooltip} />
|
||||
</div>
|
||||
<Toggle
|
||||
@@ -39,7 +48,7 @@ const DefaultManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
|
||||
onCancel={() => onToggle(null, false)}
|
||||
onPrimary={handleAddSystemIdentity}
|
||||
>
|
||||
{ContainerCopyMessages.defaultManagedIdentity.popoverDescription}
|
||||
{ContainerCopyMessages.defaultManagedIdentity.popoverDescription(copyJobState?.target?.account.name)}
|
||||
</PopoverMessage>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,26 +1,167 @@
|
||||
import { PrimaryButton, Stack } from "@fluentui/react";
|
||||
import { Link, PrimaryButton, Stack } from "@fluentui/react";
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import React from "react";
|
||||
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
|
||||
import { CapabilityNames } from "../../../../../Common/Constants";
|
||||
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
|
||||
import { logError } from "../../../../../Common/Logger";
|
||||
import { update as updateDatabaseAccount } from "../../../../../Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { buildResourceLink } from "../../../CopyJobUtils";
|
||||
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
||||
import useWindowOpenMonitor from "./hooks/useWindowOpenMonitor";
|
||||
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
|
||||
import { AccountValidatorFn } from "../../../Types/CopyJobTypes";
|
||||
|
||||
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 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 [loaderMessage, setLoaderMessage] = React.useState("");
|
||||
const [showRefreshButton, setShowRefreshButton] = React.useState(false);
|
||||
const intervalRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
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();
|
||||
}
|
||||
};
|
||||
const openWindowAndMonitor = useWindowOpenMonitor(onlineCopyUrl, onWindowClosed);
|
||||
|
||||
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 {
|
||||
setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.validateAllVersionsAndDeletesChangeFeedSpinnerLabel);
|
||||
const sourAccountBeforeUpdate = await fetchDatabaseAccount(
|
||||
sourceSubscriptionId,
|
||||
sourceResourceGroup,
|
||||
sourceAccountName,
|
||||
);
|
||||
if (!sourAccountBeforeUpdate?.properties.enableAllVersionsAndDeletesChangeFeed) {
|
||||
setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.enablingAllVersionsAndDeletesChangeFeedSpinnerLabel);
|
||||
await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
|
||||
properties: {
|
||||
enableAllVersionsAndDeletesChangeFeed: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
setLoaderMessage(ContainerCopyMessages.onlineCopyEnabled.enablingOnlineCopySpinnerLabel(sourceAccountName));
|
||||
await updateDatabaseAccount(sourceSubscriptionId, sourceResourceGroup, sourceAccountName, {
|
||||
properties: {
|
||||
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;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack className="onlineCopyContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<div className="toggle-label">{ContainerCopyMessages.onlineCopyEnabled.description}</div>
|
||||
<PrimaryButton text={ContainerCopyMessages.onlineCopyEnabled.buttonText} onClick={openWindowAndMonitor} />
|
||||
<LoadingOverlay isLoading={loading} label={loaderMessage} />
|
||||
<Stack.Item className="info-message">
|
||||
{ContainerCopyMessages.onlineCopyEnabled.description(source?.account?.name || "")} 
|
||||
<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>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,53 +1,143 @@
|
||||
import { PrimaryButton, Stack } from "@fluentui/react";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Link, PrimaryButton, Stack, Text } from "@fluentui/react";
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { fetchDatabaseAccount } from "Utils/arm/databaseAccountUtils";
|
||||
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
|
||||
import { logError } from "../../../../../Common/Logger";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { buildResourceLink, getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
|
||||
import { PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
||||
import useWindowOpenMonitor from "./hooks/useWindowOpenMonitor";
|
||||
import { AccountValidatorFn } from "../../../Types/CopyJobTypes";
|
||||
import InfoTooltip from "../Components/InfoTooltip";
|
||||
|
||||
type AddManagedIdentityProps = Partial<PermissionSectionConfig>;
|
||||
const PointInTimeRestore: React.FC<AddManagedIdentityProps> = () => {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const tooltipContent = (
|
||||
<Text>
|
||||
{ContainerCopyMessages.pointInTimeRestore.tooltip.content}
|
||||
<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);
|
||||
const { copyJobState: { source } = {}, setCopyJobState } = useCopyJobContext();
|
||||
const sourceAccountLink = buildResourceLink(source?.account);
|
||||
const pitrUrl = `${sourceAccountLink}/backupRestore`;
|
||||
const featureUrl = `${sourceAccountLink}/backupRestore`;
|
||||
const selectedSourceAccount = source?.account;
|
||||
const {
|
||||
subscriptionId: sourceSubscriptionId,
|
||||
resourceGroup: sourceResourceGroup,
|
||||
accountName: sourceAccountName,
|
||||
} = getAccountDetailsFromResourceId(selectedSourceAccount?.id);
|
||||
|
||||
const onWindowClosed = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFetchAccount = 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) {
|
||||
if (account && validatorFn(selectedSourceAccount, account)) {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
source: { ...prevState.source, account: account },
|
||||
}));
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching database account after PITR window closed:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const errorMessage =
|
||||
error.message || "Error fetching source account after Point-in-Time Restore. Please try again later.";
|
||||
logError(errorMessage, "CopyJob/PointInTimeRestore.handleFetchAccount");
|
||||
clearAccountFetchInterval();
|
||||
}
|
||||
}, []);
|
||||
const openWindowAndMonitor = useWindowOpenMonitor(pitrUrl, onWindowClosed);
|
||||
};
|
||||
|
||||
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,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="pointInTimeRestoreContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<div className="toggle-label">{ContainerCopyMessages.pointInTimeRestore.description}</div>
|
||||
<PrimaryButton
|
||||
text={loading ? "" : ContainerCopyMessages.pointInTimeRestore.buttonText}
|
||||
{...(loading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
|
||||
disabled={loading}
|
||||
onClick={openWindowAndMonitor}
|
||||
/>
|
||||
<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>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DatabaseAccount } from "Contracts/DataModels";
|
||||
import { useCallback, useState } from "react";
|
||||
import { logError } from "../../../../../../Common/Logger";
|
||||
import { useCopyJobContext } from "../../../../Context/CopyJobContext";
|
||||
import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils";
|
||||
|
||||
@@ -19,7 +20,7 @@ interface UseManagedIdentityUpdaterReturn {
|
||||
const useManagedIdentity = (
|
||||
updateIdentityFn: UseManagedIdentityUpdaterParams["updateIdentityFn"],
|
||||
): UseManagedIdentityUpdaterReturn => {
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const handleAddSystemIdentity = useCallback(async (): Promise<void> => {
|
||||
@@ -40,11 +41,12 @@ const useManagedIdentity = (
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error enabling system-assigned managed identity:", error);
|
||||
} finally {
|
||||
const errorMessage = error.message || "Error enabling system-assigned managed identity. Please try again later.";
|
||||
logError(errorMessage, "CopyJob/useManagedIdentity.handleAddSystemIdentity");
|
||||
setContextError(errorMessage);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [copyJobState, updateIdentityFn, setCopyJobState]);
|
||||
}, [copyJobState?.target?.account?.id, updateIdentityFn, setCopyJobState]);
|
||||
|
||||
return { loading, handleAddSystemIdentity };
|
||||
};
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CapabilityNames } from "../../../../../../Common/Constants";
|
||||
import { fetchRoleAssignments, fetchRoleDefinitions, RoleDefinitionType } from "../../../../../../Utils/arm/RbacUtils";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { getAccountDetailsFromResourceId } from "../../../../CopyJobUtils";
|
||||
import { BackupPolicyType, CopyJobMigrationType, DefaultIdentityType, IdentityType } from "../../../../Enums";
|
||||
import { CopyJobContextState } from "../../../../Types";
|
||||
import { getAccountDetailsFromResourceId, getContainerIdentifiers, isIntraAccountCopy } from "../../../../CopyJobUtils";
|
||||
import {
|
||||
BackupPolicyType,
|
||||
CopyJobMigrationType,
|
||||
DefaultIdentityType,
|
||||
IdentityType,
|
||||
} from "../../../../Enums/CopyJobEnums";
|
||||
import { CopyJobContextState } from "../../../../Types/CopyJobTypes";
|
||||
import { useCopyJobPrerequisitesCache } from "../../../Utils/useCopyJobPrerequisitesCache";
|
||||
import AddManagedIdentity from "../AddManagedIdentity";
|
||||
import AddReadPermissionToDefaultIdentity from "../AddReadPermissionToDefaultIdentity";
|
||||
@@ -20,7 +26,13 @@ export interface PermissionSectionConfig {
|
||||
validate?: (state: CopyJobContextState) => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
// Section IDs for maintainability
|
||||
export interface PermissionGroupConfig {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
sections: PermissionSectionConfig[];
|
||||
}
|
||||
|
||||
export const SECTION_IDS = {
|
||||
addManagedIdentity: "addManagedIdentity",
|
||||
defaultManagedIdentity: "defaultManagedIdentity",
|
||||
@@ -96,9 +108,12 @@ const PERMISSION_SECTIONS_FOR_ONLINE_JOBS: PermissionSectionConfig[] = [
|
||||
title: ContainerCopyMessages.onlineCopyEnabled.title,
|
||||
Component: OnlineCopyEnabled,
|
||||
disabled: true,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
validate: (_state: CopyJobContextState) => {
|
||||
return false;
|
||||
validate: (state: CopyJobContextState) => {
|
||||
const accountCapabilities = state?.source?.account?.properties?.capabilities ?? [];
|
||||
const onlineCopyCapability = accountCapabilities.find(
|
||||
(capability) => capability.name === CapabilityNames.EnableOnlineCopyFeature,
|
||||
);
|
||||
return !!onlineCopyCapability;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -119,21 +134,86 @@ export function checkTargetHasReaderRoleOnSource(roleDefinitions: RoleDefinition
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the permission sections configuration for the Assign Permissions screen.
|
||||
* Memoizes derived values for performance and decouples logic for testability.
|
||||
* Validates sections within a group sequentially.
|
||||
*/
|
||||
const usePermissionSections = (state: CopyJobContextState): PermissionSectionConfig[] => {
|
||||
const validateSectionsInGroup = async (
|
||||
sections: PermissionSectionConfig[],
|
||||
state: CopyJobContextState,
|
||||
validationCache: Map<string, boolean>,
|
||||
): Promise<PermissionSectionConfig[]> => {
|
||||
const result: PermissionSectionConfig[] = [];
|
||||
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const section = sections[i];
|
||||
|
||||
if (validationCache.has(section.id) && validationCache.get(section.id) === true) {
|
||||
result.push({ ...section, completed: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (section.validate) {
|
||||
const isValid = await section.validate(state);
|
||||
validationCache.set(section.id, isValid);
|
||||
result.push({ ...section, completed: isValid });
|
||||
|
||||
if (!isValid) {
|
||||
// Mark remaining sections in this group as incomplete
|
||||
for (let j = i + 1; j < sections.length; j++) {
|
||||
result.push({ ...sections[j], completed: false });
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
validationCache.set(section.id, false);
|
||||
result.push({ ...section, completed: false });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the permission groups configuration for the Assign Permissions screen.
|
||||
* Groups validate independently but sections within each group validate sequentially.
|
||||
*/
|
||||
const usePermissionSections = (state: CopyJobContextState): PermissionGroupConfig[] => {
|
||||
const sourceAccount = getContainerIdentifiers(state.source);
|
||||
const targetAccount = getContainerIdentifiers(state.target);
|
||||
|
||||
const { validationCache, setValidationCache } = useCopyJobPrerequisitesCache();
|
||||
const [permissionSections, setPermissionSections] = useState<PermissionSectionConfig[] | null>(null);
|
||||
const [permissionGroups, setPermissionGroups] = useState<PermissionGroupConfig[] | null>(null);
|
||||
const isValidatingRef = useRef(false);
|
||||
|
||||
const sectionToValidate = useMemo(() => {
|
||||
const baseSections = [...PERMISSION_SECTIONS_CONFIG];
|
||||
if (state.migrationType === CopyJobMigrationType.Online) {
|
||||
return [...baseSections, ...PERMISSION_SECTIONS_FOR_ONLINE_JOBS];
|
||||
const groupsToValidate = useMemo(() => {
|
||||
const isSameAccount = isIntraAccountCopy(sourceAccount.accountId, targetAccount.accountId);
|
||||
const crossAccountSections = isSameAccount ? [] : [...PERMISSION_SECTIONS_CONFIG];
|
||||
const groups: PermissionGroupConfig[] = [];
|
||||
const sourceAccountName = state.source?.account?.name || "";
|
||||
const targetAccountName = state.target?.account?.name || "";
|
||||
|
||||
if (crossAccountSections.length > 0) {
|
||||
groups.push({
|
||||
id: "crossAccountConfigs",
|
||||
title: ContainerCopyMessages.assignPermissions.crossAccountConfiguration.title,
|
||||
description: ContainerCopyMessages.assignPermissions.crossAccountConfiguration.description(
|
||||
sourceAccountName,
|
||||
targetAccountName,
|
||||
),
|
||||
sections: crossAccountSections,
|
||||
});
|
||||
}
|
||||
return baseSections;
|
||||
}, [state.migrationType]);
|
||||
|
||||
if (state.migrationType === CopyJobMigrationType.Online) {
|
||||
groups.push({
|
||||
id: "onlineConfigs",
|
||||
title: ContainerCopyMessages.assignPermissions.onlineConfiguration.title,
|
||||
description: ContainerCopyMessages.assignPermissions.onlineConfiguration.description(sourceAccountName),
|
||||
sections: [...PERMISSION_SECTIONS_FOR_ONLINE_JOBS],
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [sourceAccount.accountId, targetAccount.accountId, state.migrationType]);
|
||||
|
||||
const memoizedValidationCache = useMemo(() => {
|
||||
if (state.migrationType === CopyJobMigrationType.Offline) {
|
||||
@@ -144,55 +224,39 @@ const usePermissionSections = (state: CopyJobContextState): PermissionSectionCon
|
||||
}, [state.migrationType]);
|
||||
|
||||
useEffect(() => {
|
||||
const validateSections = async () => {
|
||||
const validateGroups = async () => {
|
||||
if (isValidatingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isValidatingRef.current = true;
|
||||
const result: PermissionSectionConfig[] = [];
|
||||
const newValidationCache = new Map(memoizedValidationCache);
|
||||
|
||||
for (let i = 0; i < sectionToValidate.length; i++) {
|
||||
const section = sectionToValidate[i];
|
||||
// Validate all groups independently (in parallel)
|
||||
const validatedGroups = await Promise.all(
|
||||
groupsToValidate.map(async (group) => {
|
||||
const validatedSections = await validateSectionsInGroup(group.sections, state, newValidationCache);
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
return {
|
||||
...group,
|
||||
sections: validatedSections,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
setValidationCache(newValidationCache);
|
||||
setPermissionSections(result);
|
||||
setPermissionGroups(validatedGroups);
|
||||
isValidatingRef.current = false;
|
||||
};
|
||||
|
||||
validateSections();
|
||||
validateGroups();
|
||||
|
||||
return () => {
|
||||
isValidatingRef.current = false;
|
||||
};
|
||||
}, [state, sectionToValidate]);
|
||||
}, [state, groupsToValidate]);
|
||||
|
||||
return permissionSections ?? [];
|
||||
return permissionGroups ?? [];
|
||||
};
|
||||
|
||||
export default usePermissionSections;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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;
|
||||
@@ -1,66 +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";
|
||||
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;
|
||||
@@ -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 }> = ({ content }) => {
|
||||
const InfoTooltip: React.FC<{ content?: string | JSX.Element }> = ({ content }) => {
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import { DefaultButton, PrimaryButton, Stack, Text } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import LoadingOverlay from "../../../../../Common/LoadingOverlay";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
|
||||
interface PopoverContainerProps {
|
||||
isLoading?: boolean;
|
||||
@@ -19,17 +21,13 @@ const PopoverContainer: React.FC<PopoverContainerProps> = React.memo(
|
||||
tokens={{ childrenGap: 20 }}
|
||||
style={{ maxWidth: 450 }}
|
||||
>
|
||||
<LoadingOverlay isLoading={isLoading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
|
||||
<Text variant="mediumPlus" style={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text>{children}</Text>
|
||||
<Stack horizontal tokens={{ childrenGap: 20 }}>
|
||||
<PrimaryButton
|
||||
text={isLoading ? "" : "Yes"}
|
||||
{...(isLoading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
|
||||
onClick={onPrimary}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<PrimaryButton text={"Yes"} onClick={onPrimary} disabled={isLoading} />
|
||||
<DefaultButton text="No" onClick={onCancel} disabled={isLoading} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Stack, Text } from "@fluentui/react";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
import { produce } from "immer";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import { AddCollectionPanel } from "../../../../Panes/AddCollectionPanel/AddCollectionPanel";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
|
||||
type AddCollectionPanelWrapperProps = {
|
||||
explorer?: Explorer;
|
||||
goBack?: () => void;
|
||||
};
|
||||
|
||||
const AddCollectionPanelWrapper: React.FunctionComponent<AddCollectionPanelWrapperProps> = ({ explorer, goBack }) => {
|
||||
const { setCopyJobState } = useCopyJobContext();
|
||||
|
||||
useEffect(() => {
|
||||
const sidePanelStore = useSidePanel.getState();
|
||||
if (sidePanelStore.headerText !== ContainerCopyMessages.createContainerHeading) {
|
||||
sidePanelStore.setHeaderText(ContainerCopyMessages.createContainerHeading);
|
||||
}
|
||||
return () => {
|
||||
sidePanelStore.setHeaderText(ContainerCopyMessages.createCopyJobPanelTitle);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAddCollectionSuccess = useCallback(
|
||||
(collectionData: { databaseId: string; collectionId: string }) => {
|
||||
setCopyJobState(
|
||||
produce((state) => {
|
||||
state.target.databaseId = collectionData.databaseId;
|
||||
state.target.containerId = collectionData.collectionId;
|
||||
}),
|
||||
);
|
||||
goBack?.();
|
||||
},
|
||||
[goBack],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack className="addCollectionPanelWrapper">
|
||||
<Stack.Item className="addCollectionPanelHeader">
|
||||
<Text>{ContainerCopyMessages.createNewContainerSubHeading}</Text>
|
||||
</Stack.Item>
|
||||
<Stack.Item className="addCollectionPanelBody">
|
||||
<AddCollectionPanel explorer={explorer} isCopyJobFlow={true} onSubmitSuccess={handleAddCollectionSuccess} />
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddCollectionPanelWrapper;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Stack } from "@fluentui/react";
|
||||
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import { useCopyJobContext } from "../../Context/CopyJobContext";
|
||||
import { useCopyJobNavigation } from "../Utils/useCopyJobNavigation";
|
||||
import NavigationControls from "./Components/NavigationControls";
|
||||
|
||||
@@ -12,11 +13,28 @@ const CreateCopyJobScreens: React.FC = () => {
|
||||
handlePrevious,
|
||||
handleCancel,
|
||||
primaryBtnText,
|
||||
showAddCollectionPanel,
|
||||
} = useCopyJobNavigation();
|
||||
const { contextError, setContextError } = useCopyJobContext();
|
||||
|
||||
return (
|
||||
<Stack verticalAlign="space-between" className="createCopyJobScreensContainer">
|
||||
<Stack.Item className="createCopyJobScreensContent">{currentScreen?.component}</Stack.Item>
|
||||
<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="createCopyJobScreensFooter">
|
||||
<NavigationControls
|
||||
primaryBtnText={primaryBtnText}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import React from "react";
|
||||
import CopyJobContextProvider from "../../Context/CopyJobContext";
|
||||
import CreateCopyJobScreens from "./CreateCopyJobScreens";
|
||||
|
||||
const CreateCopyJobScreensProvider = () => {
|
||||
const CreateCopyJobScreensProvider = ({ explorer }: { explorer: Explorer }) => {
|
||||
return (
|
||||
<CopyJobContextProvider>
|
||||
<CopyJobContextProvider explorer={explorer}>
|
||||
<CreateCopyJobScreens />
|
||||
</CopyJobContextProvider>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { DetailsList, DetailsListLayoutMode, Stack, Text, TextField } from "@fluentui/react";
|
||||
import FieldRow from "Explorer/ContainerCopy/CreateCopyJob/Screens/Components/FieldRow";
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { getDefaultJobName } from "../../../CopyJobUtils";
|
||||
import FieldRow from "../Components/FieldRow";
|
||||
import { getPreviewCopyJobDetailsListColumns } from "./Utils/PreviewCopyJobUtils";
|
||||
|
||||
const PreviewCopyJob: React.FC = () => {
|
||||
@@ -16,6 +17,11 @@ const PreviewCopyJob: React.FC = () => {
|
||||
targetContainerName: copyJobState.target?.containerId,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
onJobNameChange(undefined, getDefaultJobName(selectedDatabaseAndContainers));
|
||||
}, []);
|
||||
|
||||
const jobName = copyJobState.jobName;
|
||||
|
||||
const onJobNameChange = (_ev?: React.FormEvent, newValue?: string) => {
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Dropdown } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { DropdownOptionType } from "../../../../Types";
|
||||
import { DropdownOptionType } from "../../../../Types/CopyJobTypes";
|
||||
import FieldRow from "../../Components/FieldRow";
|
||||
|
||||
interface AccountDropdownProps {
|
||||
@@ -27,4 +27,5 @@ export const AccountDropdown: React.FC<AccountDropdownProps> = React.memo(
|
||||
/>
|
||||
</FieldRow>
|
||||
),
|
||||
(prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey,
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Dropdown } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { DropdownOptionType } from "../../../../Types";
|
||||
import { DropdownOptionType } from "../../../../Types/CopyJobTypes";
|
||||
import FieldRow from "../../Components/FieldRow";
|
||||
|
||||
interface SubscriptionDropdownProps {
|
||||
@@ -25,4 +25,5 @@ export const SubscriptionDropdown: React.FC<SubscriptionDropdownProps> = React.m
|
||||
/>
|
||||
</FieldRow>
|
||||
),
|
||||
(prev, next) => prev.options.length === next.options.length && prev.selectedKey === next.selectedKey,
|
||||
);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/* 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";
|
||||
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
|
||||
import { AccountDropdown } from "./Components/AccountDropdown";
|
||||
import { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox";
|
||||
import { SubscriptionDropdown } from "./Components/SubscriptionDropdown";
|
||||
@@ -15,12 +16,11 @@ 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) => account.type === "SQL" || account.kind === "GlobalDocumentDB",
|
||||
);
|
||||
const sqlApiOnlyAccounts: DatabaseAccount[] = allAccounts?.filter((account) => apiType(account) === "SQL");
|
||||
|
||||
const { subscriptionOptions, accountOptions } = useDropdownOptions(subscriptions, sqlApiOnlyAccounts);
|
||||
const { handleSelectSourceAccount, handleMigrationTypeChange } = useEventHandlers(setCopyJobState);
|
||||
@@ -39,7 +39,7 @@ const SelectAccount = React.memo(() => {
|
||||
|
||||
<AccountDropdown
|
||||
options={accountOptions}
|
||||
selectedKey={copyJobState?.source?.account?.id}
|
||||
selectedKey={selectedSourceAccountId}
|
||||
disabled={!selectedSubscriptionId}
|
||||
onChange={(_ev, option) => handleSelectSourceAccount("account", option?.data)}
|
||||
/>
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from "react";
|
||||
import { DatabaseAccount, Subscription } from "../../../../../../Contracts/DataModels";
|
||||
import { CopyJobMigrationType } from "../../../../Enums";
|
||||
import { CopyJobContextProviderType, CopyJobContextState, DropdownOptionType } from "../../../../Types";
|
||||
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
|
||||
import { CopyJobContextProviderType, CopyJobContextState, DropdownOptionType } from "../../../../Types/CopyJobTypes";
|
||||
import { useCopyJobPrerequisitesCache } from "../../../Utils/useCopyJobPrerequisitesCache";
|
||||
|
||||
export function useDropdownOptions(
|
||||
subscriptions: Subscription[],
|
||||
@@ -10,25 +11,19 @@ export function useDropdownOptions(
|
||||
subscriptionOptions: DropdownOptionType[];
|
||||
accountOptions: DropdownOptionType[];
|
||||
} {
|
||||
const subscriptionOptions = React.useMemo(
|
||||
() =>
|
||||
subscriptions?.map((sub) => ({
|
||||
key: sub.subscriptionId,
|
||||
text: sub.displayName,
|
||||
data: sub,
|
||||
})) || [],
|
||||
[subscriptions],
|
||||
);
|
||||
const subscriptionOptions =
|
||||
subscriptions?.map((sub) => ({
|
||||
key: sub.subscriptionId,
|
||||
text: sub.displayName,
|
||||
data: sub,
|
||||
})) || [];
|
||||
|
||||
const accountOptions = React.useMemo(
|
||||
() =>
|
||||
accounts?.map((account) => ({
|
||||
key: account.id,
|
||||
text: account.name,
|
||||
data: account,
|
||||
})) || [],
|
||||
[accounts],
|
||||
);
|
||||
const accountOptions =
|
||||
accounts?.map((account) => ({
|
||||
key: account.id,
|
||||
text: account.name,
|
||||
data: account,
|
||||
})) || [];
|
||||
|
||||
return { subscriptionOptions, accountOptions };
|
||||
}
|
||||
@@ -36,43 +31,43 @@ export function useDropdownOptions(
|
||||
type setCopyJobStateType = CopyJobContextProviderType["setCopyJobState"];
|
||||
|
||||
export function useEventHandlers(setCopyJobState: setCopyJobStateType) {
|
||||
const handleSelectSourceAccount = React.useCallback(
|
||||
(type: "subscription" | "account", data: (Subscription & DatabaseAccount) | undefined) => {
|
||||
setCopyJobState((prevState: CopyJobContextState) => {
|
||||
if (type === "subscription") {
|
||||
return {
|
||||
...prevState,
|
||||
source: {
|
||||
...prevState.source,
|
||||
subscription: data || null,
|
||||
account: null, // reset on subscription change
|
||||
},
|
||||
};
|
||||
}
|
||||
if (type === "account") {
|
||||
return {
|
||||
...prevState,
|
||||
source: {
|
||||
...prevState.source,
|
||||
account: data || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
return prevState;
|
||||
});
|
||||
},
|
||||
[setCopyJobState],
|
||||
);
|
||||
const { setValidationCache } = useCopyJobPrerequisitesCache();
|
||||
const handleSelectSourceAccount = (
|
||||
type: "subscription" | "account",
|
||||
data: (Subscription & DatabaseAccount) | undefined,
|
||||
) => {
|
||||
setCopyJobState((prevState: CopyJobContextState) => {
|
||||
if (type === "subscription") {
|
||||
return {
|
||||
...prevState,
|
||||
source: {
|
||||
...prevState.source,
|
||||
subscription: data || null,
|
||||
account: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (type === "account") {
|
||||
return {
|
||||
...prevState,
|
||||
source: {
|
||||
...prevState.source,
|
||||
account: data || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
return prevState;
|
||||
});
|
||||
setValidationCache(new Map<string, boolean>());
|
||||
};
|
||||
|
||||
const handleMigrationTypeChange = React.useCallback(
|
||||
(_ev?: React.FormEvent<HTMLElement>, checked?: boolean) => {
|
||||
setCopyJobState((prevState: CopyJobContextState) => ({
|
||||
...prevState,
|
||||
migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
|
||||
}));
|
||||
},
|
||||
[setCopyJobState],
|
||||
);
|
||||
const handleMigrationTypeChange = React.useCallback((_ev?: React.FormEvent<HTMLElement>, checked?: boolean) => {
|
||||
setCopyJobState((prevState: CopyJobContextState) => ({
|
||||
...prevState,
|
||||
migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
|
||||
}));
|
||||
setValidationCache(new Map<string, boolean>());
|
||||
}, []);
|
||||
|
||||
return { handleSelectSourceAccount, handleMigrationTypeChange };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { CopyJobContextState, DropdownOptionType } from "../../../../Types";
|
||||
import { CopyJobContextState, DropdownOptionType } from "../../../../Types/CopyJobTypes";
|
||||
|
||||
export function dropDownChangeHandler(setCopyJobState: React.Dispatch<React.SetStateAction<CopyJobContextState>>) {
|
||||
return (type: "sourceDatabase" | "sourceContainer" | "targetDatabase" | "targetContainer") =>
|
||||
|
||||
@@ -7,38 +7,44 @@ import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { DatabaseContainerSection } from "./components/DatabaseContainerSection";
|
||||
import { dropDownChangeHandler } from "./Events/DropDownChangeHandler";
|
||||
import { useMemoizedSourceAndTargetData } from "./memoizedData";
|
||||
import { useSourceAndTargetData } from "./memoizedData";
|
||||
|
||||
const SelectSourceAndTargetContainers = () => {
|
||||
type SelectSourceAndTargetContainers = {
|
||||
showAddCollectionPanel?: () => void;
|
||||
};
|
||||
|
||||
const SelectSourceAndTargetContainers = ({ showAddCollectionPanel }: SelectSourceAndTargetContainers) => {
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
const { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams } =
|
||||
useMemoizedSourceAndTargetData(copyJobState);
|
||||
useSourceAndTargetData(copyJobState);
|
||||
|
||||
// Custom hooks
|
||||
const sourceDatabases = useDatabases(...sourceDbParams) || [];
|
||||
const sourceContainers = useDataContainers(...sourceContainerParams) || [];
|
||||
const targetDatabases = useDatabases(...targetDbParams) || [];
|
||||
const targetContainers = useDataContainers(...targetContainerParams) || [];
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceDatabases = useDatabases(...sourceDbParams);
|
||||
const sourceContainers = useDataContainers(...sourceContainerParams);
|
||||
const targetDatabases = useDatabases(...targetDbParams);
|
||||
const targetContainers = useDataContainers(...targetContainerParams);
|
||||
|
||||
// Memoize option objects for dropdowns
|
||||
const sourceDatabaseOptions = React.useMemo(
|
||||
() => sourceDatabases.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })),
|
||||
() => sourceDatabases?.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })) || [],
|
||||
[sourceDatabases],
|
||||
);
|
||||
const sourceContainerOptions = React.useMemo(
|
||||
() => sourceContainers.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })),
|
||||
() => sourceContainers?.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })) || [],
|
||||
[sourceContainers],
|
||||
);
|
||||
const targetDatabaseOptions = React.useMemo(
|
||||
() => targetDatabases.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })),
|
||||
() => targetDatabases?.map((db: DatabaseModel) => ({ key: db.name, text: db.name, data: db })) || [],
|
||||
[targetDatabases],
|
||||
);
|
||||
const targetContainerOptions = React.useMemo(
|
||||
() => targetContainers.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })),
|
||||
() => targetContainers?.map((c: DatabaseModel) => ({ key: c.name, text: c.name, data: c })) || [],
|
||||
[targetContainers],
|
||||
);
|
||||
|
||||
const onDropdownChange = React.useCallback(dropDownChangeHandler(setCopyJobState), [setCopyJobState]);
|
||||
const onDropdownChange = dropDownChangeHandler(setCopyJobState);
|
||||
|
||||
return (
|
||||
<Stack className="selectSourceAndTargetContainers" tokens={{ childrenGap: 25 }}>
|
||||
@@ -64,6 +70,7 @@ const SelectSourceAndTargetContainers = () => {
|
||||
selectedContainer={target?.containerId}
|
||||
containerDisabled={!target?.databaseId}
|
||||
containerOnChange={onDropdownChange("targetContainer")}
|
||||
handleOnDemandCreateContainer={showAddCollectionPanel}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Dropdown, Stack } from "@fluentui/react";
|
||||
import { ActionButton, Dropdown, Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { DatabaseContainerSectionProps } from "../../../../Types";
|
||||
import { DatabaseContainerSectionProps } from "../../../../Types/CopyJobTypes";
|
||||
import FieldRow from "../../Components/FieldRow";
|
||||
|
||||
export const DatabaseContainerSection = ({
|
||||
@@ -14,6 +14,7 @@ export const DatabaseContainerSection = ({
|
||||
selectedContainer,
|
||||
containerDisabled,
|
||||
containerOnChange,
|
||||
handleOnDemandCreateContainer,
|
||||
}: DatabaseContainerSectionProps) => (
|
||||
<Stack tokens={{ childrenGap: 15 }} className="databaseContainerSection">
|
||||
<label className="subHeading">{heading}</label>
|
||||
@@ -29,15 +30,22 @@ export const DatabaseContainerSection = ({
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={ContainerCopyMessages.containerDropdownLabel}>
|
||||
<Dropdown
|
||||
placeholder={ContainerCopyMessages.containerDropdownPlaceholder}
|
||||
ariaLabel={ContainerCopyMessages.containerDropdownLabel}
|
||||
options={containerOptions}
|
||||
required
|
||||
disabled={!!containerDisabled}
|
||||
selectedKey={selectedContainer}
|
||||
onChange={containerOnChange}
|
||||
/>
|
||||
<Stack>
|
||||
<Dropdown
|
||||
placeholder={ContainerCopyMessages.containerDropdownPlaceholder}
|
||||
ariaLabel={ContainerCopyMessages.containerDropdownLabel}
|
||||
options={containerOptions}
|
||||
required
|
||||
disabled={!!containerDisabled}
|
||||
selectedKey={selectedContainer}
|
||||
onChange={containerOnChange}
|
||||
/>
|
||||
{handleOnDemandCreateContainer && (
|
||||
<ActionButton className="create-container-link-btn" onClick={() => handleOnDemandCreateContainer()}>
|
||||
{ContainerCopyMessages.createContainerButtonLabel}
|
||||
</ActionButton>
|
||||
)}
|
||||
</Stack>
|
||||
</FieldRow>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from "react";
|
||||
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
|
||||
import { CopyJobContextState, DatabaseParams, DataContainerParams } from "../../../Types";
|
||||
import { CopyJobContextState, DatabaseParams, DataContainerParams } from "../../../Types/CopyJobTypes";
|
||||
|
||||
export function useMemoizedSourceAndTargetData(copyJobState: CopyJobContextState) {
|
||||
export function useSourceAndTargetData(copyJobState: CopyJobContextState) {
|
||||
const { source, target } = copyJobState ?? {};
|
||||
const selectedSourceAccount = source?.account;
|
||||
const selectedTargetAccount = target?.account;
|
||||
@@ -17,27 +16,22 @@ export function useMemoizedSourceAndTargetData(copyJobState: CopyJobContextState
|
||||
accountName: targetAccountName,
|
||||
} = getAccountDetailsFromResourceId(selectedTargetAccount?.id);
|
||||
|
||||
const sourceDbParams = React.useMemo(
|
||||
() => [sourceSubscriptionId, sourceResourceGroup, sourceAccountName, "SQL"] as DatabaseParams,
|
||||
[sourceSubscriptionId, sourceResourceGroup, sourceAccountName],
|
||||
);
|
||||
|
||||
const sourceContainerParams = React.useMemo(
|
||||
() =>
|
||||
[sourceSubscriptionId, sourceResourceGroup, sourceAccountName, source?.databaseId, "SQL"] as DataContainerParams,
|
||||
[sourceSubscriptionId, sourceResourceGroup, sourceAccountName, source?.databaseId],
|
||||
);
|
||||
|
||||
const targetDbParams = React.useMemo(
|
||||
() => [targetSubscriptionId, targetResourceGroup, targetAccountName, "SQL"] as DatabaseParams,
|
||||
[targetSubscriptionId, targetResourceGroup, targetAccountName],
|
||||
);
|
||||
|
||||
const targetContainerParams = React.useMemo(
|
||||
() =>
|
||||
[targetSubscriptionId, targetResourceGroup, targetAccountName, target?.databaseId, "SQL"] as DataContainerParams,
|
||||
[targetSubscriptionId, targetResourceGroup, targetAccountName, target?.databaseId],
|
||||
);
|
||||
const sourceDbParams = [sourceSubscriptionId, sourceResourceGroup, sourceAccountName, "SQL"] as DatabaseParams;
|
||||
const sourceContainerParams = [
|
||||
sourceSubscriptionId,
|
||||
sourceResourceGroup,
|
||||
sourceAccountName,
|
||||
source?.databaseId,
|
||||
"SQL",
|
||||
] as DataContainerParams;
|
||||
const targetDbParams = [targetSubscriptionId, targetResourceGroup, targetAccountName, "SQL"] as DatabaseParams;
|
||||
const targetContainerParams = [
|
||||
targetSubscriptionId,
|
||||
targetResourceGroup,
|
||||
targetAccountName,
|
||||
target?.databaseId,
|
||||
"SQL",
|
||||
] as DataContainerParams;
|
||||
|
||||
return { source, target, sourceDbParams, sourceContainerParams, targetDbParams, targetContainerParams };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useMemo, useReducer } from "react";
|
||||
import { useCallback, useMemo, useReducer, useState } 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";
|
||||
|
||||
@@ -31,20 +33,31 @@ function navigationReducer(state: NavigationState, action: Action): NavigationSt
|
||||
}
|
||||
|
||||
export function useCopyJobNavigation() {
|
||||
const { copyJobState, resetCopyJobState } = useCopyJobContext();
|
||||
const screens = useCreateCopyJobScreensList();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { copyJobState, resetCopyJobState, setContextError } = useCopyJobContext();
|
||||
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]);
|
||||
}, [currentScreen.key, copyJobState, cache, isLoading]);
|
||||
|
||||
const primaryBtnText = useMemo(() => {
|
||||
if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) {
|
||||
if (currentScreenKey === SCREEN_KEYS.CreateCollection) {
|
||||
return "Create";
|
||||
} else if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) {
|
||||
return "Copy";
|
||||
}
|
||||
return "Next";
|
||||
@@ -58,9 +71,74 @@ 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]: SCREEN_KEYS.AssignPermissions,
|
||||
[SCREEN_KEYS.SelectAccount]: shouldNotShowPermissionScreen()
|
||||
? SCREEN_KEYS.SelectSourceAndTargetContainers
|
||||
: SCREEN_KEYS.AssignPermissions,
|
||||
[SCREEN_KEYS.AssignPermissions]: SCREEN_KEYS.SelectSourceAndTargetContainers,
|
||||
[SCREEN_KEYS.SelectSourceAndTargetContainers]: SCREEN_KEYS.PreviewCopyJob,
|
||||
};
|
||||
@@ -69,13 +147,9 @@ export function useCopyJobNavigation() {
|
||||
if (nextScreen) {
|
||||
dispatch({ type: "NEXT", nextScreen });
|
||||
} else if (currentScreenKey === SCREEN_KEYS.PreviewCopyJob) {
|
||||
submitCreateCopyJob(copyJobState, handleCancel);
|
||||
handleCopyJobSubmission();
|
||||
}
|
||||
}, [currentScreenKey, copyJobState]);
|
||||
|
||||
const handlePrevious = useCallback(() => {
|
||||
dispatch({ type: "PREVIOUS" });
|
||||
}, []);
|
||||
}, [currentScreenKey, copyJobState, areContainersIdentical, handleCopyJobSubmission]);
|
||||
|
||||
return {
|
||||
currentScreen,
|
||||
@@ -84,6 +158,7 @@ export function useCopyJobNavigation() {
|
||||
handlePrimary,
|
||||
handlePrevious,
|
||||
handleCancel,
|
||||
showAddCollectionPanel,
|
||||
primaryBtnText,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React from "react";
|
||||
import { CopyJobContextState } from "../../Types";
|
||||
import AssignPermissions from "../Screens/AssignPermissions";
|
||||
import PreviewCopyJob from "../Screens/PreviewCopyJob";
|
||||
import SelectAccount from "../Screens/SelectAccount";
|
||||
import SelectSourceAndTargetContainers from "../Screens/SelectSourceAndTargetContainers";
|
||||
import { useCopyJobContext } from "../../Context/CopyJobContext";
|
||||
import { CopyJobContextState } from "../../Types/CopyJobTypes";
|
||||
import AssignPermissions from "../Screens/AssignPermissions/AssignPermissions";
|
||||
import AddCollectionPanelWrapper from "../Screens/CreateContainer/AddCollectionPanelWrapper";
|
||||
import PreviewCopyJob from "../Screens/PreviewCopyJob/PreviewCopyJob";
|
||||
import SelectAccount from "../Screens/SelectAccount/SelectAccount";
|
||||
import SelectSourceAndTargetContainers from "../Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers";
|
||||
|
||||
const SCREEN_KEYS = {
|
||||
CreateCollection: "CreateCollection",
|
||||
SelectAccount: "SelectAccount",
|
||||
SelectSourceAndTargetContainers: "SelectSourceAndTargetContainers",
|
||||
PreviewCopyJob: "PreviewCopyJob",
|
||||
@@ -23,7 +26,9 @@ type Screen = {
|
||||
validations: Validation[];
|
||||
};
|
||||
|
||||
function useCreateCopyJobScreensList() {
|
||||
function useCreateCopyJobScreensList(goBack: () => void): Screen[] {
|
||||
const { explorer } = useCopyJobContext();
|
||||
|
||||
return React.useMemo<Screen[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -50,13 +55,18 @@ function useCreateCopyJobScreensList() {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: SCREEN_KEYS.CreateCollection,
|
||||
component: <AddCollectionPanelWrapper explorer={explorer} goBack={goBack} />,
|
||||
validations: [],
|
||||
},
|
||||
{
|
||||
key: SCREEN_KEYS.PreviewCopyJob,
|
||||
component: <PreviewCopyJob />,
|
||||
validations: [
|
||||
{
|
||||
validate: (state: CopyJobContextState) =>
|
||||
!!(typeof state?.jobName === "string" && state?.jobName && /^[a-zA-Z0-9-.]+$/.test(state?.jobName)),
|
||||
!!(typeof state?.jobName === "string" && state?.jobName && /^[a-zA-Z0-9-._]+$/.test(state?.jobName)),
|
||||
message: "Please enter a job name to proceed",
|
||||
},
|
||||
],
|
||||
@@ -80,7 +90,7 @@ function useCreateCopyJobScreensList() {
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
[explorer],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,14 @@ export enum CopyJobMigrationType {
|
||||
Online = "online",
|
||||
}
|
||||
|
||||
// all checks will happen
|
||||
export enum IdentityType {
|
||||
SystemAssigned = "systemassigned", // "SystemAssigned"
|
||||
UserAssigned = "userassigned", // "UserAssigned"
|
||||
None = "none", // "None"
|
||||
SystemAssigned = "systemassigned",
|
||||
UserAssigned = "userassigned",
|
||||
None = "none",
|
||||
}
|
||||
|
||||
export enum DefaultIdentityType {
|
||||
SystemAssignedIdentity = "systemassignedidentity", // "SystemAssignedIdentity"
|
||||
SystemAssignedIdentity = "systemassignedidentity",
|
||||
}
|
||||
|
||||
export enum BackupPolicyType {
|
||||
@@ -1,38 +1,52 @@
|
||||
import { IconButton, IContextualMenuProps } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums";
|
||||
import { CopyJobType } from "../../Types";
|
||||
import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums/CopyJobEnums";
|
||||
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
|
||||
|
||||
interface CopyJobActionMenuProps {
|
||||
job: CopyJobType;
|
||||
handleClick: (job: CopyJobType, action: string) => void;
|
||||
handleClick: HandleJobActionClickType;
|
||||
}
|
||||
|
||||
const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick }) => {
|
||||
if ([CopyJobStatusType.Completed, CopyJobStatusType.Cancelled].includes(job.Status)) {
|
||||
const [updatingJobAction, setUpdatingJobAction] = React.useState<{ jobName: string; action: string } | null>(null);
|
||||
if (
|
||||
[
|
||||
CopyJobStatusType.Completed,
|
||||
CopyJobStatusType.Cancelled,
|
||||
CopyJobStatusType.Failed,
|
||||
CopyJobStatusType.Faulted,
|
||||
].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),
|
||||
onClick: () => handleClick(job, CopyJobActions.pause, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.pause,
|
||||
},
|
||||
{
|
||||
key: CopyJobActions.cancel,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.cancel,
|
||||
iconProps: { iconName: "Cancel" },
|
||||
onClick: () => handleClick(job, CopyJobActions.cancel),
|
||||
onClick: () => handleClick(job, CopyJobActions.cancel, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.cancel,
|
||||
},
|
||||
{
|
||||
key: CopyJobActions.resume,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.resume,
|
||||
iconProps: { iconName: "Play" },
|
||||
onClick: () => handleClick(job, CopyJobActions.resume),
|
||||
onClick: () => handleClick(job, CopyJobActions.resume, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.resume,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -48,18 +62,19 @@ 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 === CopyJobMigrationType.Online) {
|
||||
if ((job.Mode ?? "").toLowerCase() === CopyJobMigrationType.Online) {
|
||||
filteredItems.push({
|
||||
key: CopyJobActions.complete,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.complete,
|
||||
iconProps: { iconName: "CheckMark" },
|
||||
onClick: () => handleClick(job, CopyJobActions.complete),
|
||||
onClick: () => handleClick(job, CopyJobActions.complete, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.complete,
|
||||
});
|
||||
}
|
||||
return filteredItems;
|
||||
}
|
||||
|
||||
if ([CopyJobStatusType.Failed, CopyJobStatusType.Faulted, CopyJobStatusType.Skipped].includes(job.Status)) {
|
||||
if ([CopyJobStatusType.Skipped].includes(job.Status)) {
|
||||
return baseItems.filter((item) => item.key === CopyJobActions.resume);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,79 +1,80 @@
|
||||
import { IColumn } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
import { CopyJobType } from "../../Types";
|
||||
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
|
||||
import CopyJobActionMenu from "./CopyJobActionMenu";
|
||||
import CopyJobStatusWithIcon from "./CopyJobStatusWithIcon";
|
||||
|
||||
export const getColumns = (
|
||||
handleSort: (columnKey: string) => void,
|
||||
handleActionClick: (job: CopyJobType, action: string) => void,
|
||||
handleActionClick: HandleJobActionClickType,
|
||||
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"),
|
||||
},
|
||||
{
|
||||
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"),
|
||||
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} />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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;
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { FontIcon, getTheme, mergeStyles, mergeStyleSets, Stack, Text } from "@fluentui/react";
|
||||
import { FontIcon, getTheme, mergeStyles, mergeStyleSets, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
|
||||
import PropTypes from "prop-types";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
import { CopyJobStatusType } from "../../Enums";
|
||||
import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
|
||||
|
||||
const theme = getTheme();
|
||||
|
||||
@@ -24,11 +25,8 @@ const classNames = mergeStyleSets({
|
||||
unknown: [{ color: theme.semanticColors.bodySubtext }, iconClass],
|
||||
});
|
||||
|
||||
const iconMap: Record<CopyJobStatusType, string> = {
|
||||
[CopyJobStatusType.Pending]: "StatusCircleRing",
|
||||
[CopyJobStatusType.InProgress]: "ProgressRingDots",
|
||||
[CopyJobStatusType.Running]: "ProgressRingDots",
|
||||
[CopyJobStatusType.Partitioning]: "ProgressRingDots",
|
||||
const iconMap: Partial<Record<CopyJobStatusType, string>> = {
|
||||
[CopyJobStatusType.Pending]: "Clock",
|
||||
[CopyJobStatusType.Paused]: "CirclePause",
|
||||
[CopyJobStatusType.Skipped]: "StatusCircleBlock2",
|
||||
[CopyJobStatusType.Cancelled]: "StatusErrorFull",
|
||||
@@ -37,18 +35,38 @@ const iconMap: Record<CopyJobStatusType, string> = {
|
||||
[CopyJobStatusType.Completed]: "CompletedSolid",
|
||||
};
|
||||
|
||||
const CopyJobStatusWithIcon: React.FC<{ status: CopyJobStatusType }> = ({ status }) => {
|
||||
export interface CopyJobStatusWithIconProps {
|
||||
status: CopyJobStatusType;
|
||||
}
|
||||
|
||||
const CopyJobStatusWithIcon: React.FC<CopyJobStatusWithIconProps> = React.memo(({ status }) => {
|
||||
const statusText = ContainerCopyMessages.MonitorJobs.Status[status] || "Unknown";
|
||||
|
||||
const isSpinnerStatus = [
|
||||
CopyJobStatusType.Running,
|
||||
CopyJobStatusType.InProgress,
|
||||
CopyJobStatusType.Partitioning,
|
||||
].includes(status);
|
||||
|
||||
return (
|
||||
<Stack horizontal verticalAlign="center">
|
||||
<FontIcon
|
||||
aria-label={status}
|
||||
iconName={iconMap[status] || "UnknownSolid"}
|
||||
className={classNames[status] || classNames.unknown}
|
||||
/>
|
||||
{isSpinnerStatus ? (
|
||||
<Spinner size={SpinnerSize.small} style={{ marginRight: "8px" }} />
|
||||
) : (
|
||||
<FontIcon
|
||||
aria-label={status}
|
||||
iconName={iconMap[status] || "UnknownSolid"}
|
||||
className={classNames[status] || classNames.unknown}
|
||||
/>
|
||||
)}
|
||||
<Text>{statusText}</Text>
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
CopyJobStatusWithIcon.displayName = "CopyJobStatusWithIcon";
|
||||
CopyJobStatusWithIcon.propTypes = {
|
||||
status: PropTypes.oneOf(Object.values(CopyJobStatusType)).isRequired,
|
||||
};
|
||||
|
||||
export default CopyJobStatusWithIcon;
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { ActionButton, Image } from "@fluentui/react";
|
||||
import React, { useCallback } from "react";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import React from "react";
|
||||
import CopyJobIcon from "../../../../../images/ContainerCopy/copy-jobs.svg";
|
||||
import * as Actions from "../../Actions/CopyJobActions";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
|
||||
interface CopyJobsNotFoundProps {}
|
||||
interface CopyJobsNotFoundProps {
|
||||
explorer: Explorer;
|
||||
}
|
||||
|
||||
const CopyJobsNotFound: React.FC<CopyJobsNotFoundProps> = () => {
|
||||
const handleCreateCopyJob = useCallback(Actions.openCreateCopyJobPanel, []);
|
||||
const CopyJobsNotFound: React.FC<CopyJobsNotFoundProps> = ({ explorer }) => {
|
||||
return (
|
||||
<div className="notFoundContainer flexContainer centerContent">
|
||||
<Image src={CopyJobIcon} alt={ContainerCopyMessages.noCopyJobsTitle} width={100} height={100} />
|
||||
<h4 className="noCopyJobsMessage">{ContainerCopyMessages.noCopyJobsTitle}</h4>
|
||||
<ActionButton allowDisabledFocus className="createCopyJobButton" onClick={handleCreateCopyJob}>
|
||||
<ActionButton
|
||||
allowDisabledFocus
|
||||
className="createCopyJobButton"
|
||||
onClick={Actions.openCreateCopyJobPanel.bind(null, explorer)}
|
||||
>
|
||||
{ContainerCopyMessages.createCopyJobButtonText}
|
||||
</ActionButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyJobsNotFound;
|
||||
export default React.memo(CopyJobsNotFound);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable react/prop-types */
|
||||
import {
|
||||
ConstrainMode,
|
||||
DetailsListLayoutMode,
|
||||
DetailsRow,
|
||||
IColumn,
|
||||
IDetailsRowProps,
|
||||
ScrollablePane,
|
||||
ScrollbarVisibility,
|
||||
ShimmeredDetailsList,
|
||||
@@ -12,30 +14,33 @@ import {
|
||||
StickyPositionType,
|
||||
} from "@fluentui/react";
|
||||
import React, { useEffect } from "react";
|
||||
import { CopyJobType } from "../../Types";
|
||||
import Pager from "../../../../Common/Pager";
|
||||
import { openCopyJobDetailsPanel } from "../../Actions/CopyJobActions";
|
||||
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
|
||||
import { getColumns } from "./CopyJobColumns";
|
||||
|
||||
interface CopyJobsListProps {
|
||||
jobs: CopyJobType[];
|
||||
handleActionClick: (job: CopyJobType, action: string) => void;
|
||||
handleActionClick: HandleJobActionClickType;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: { height: "calc(100vh - 15em)" } as React.CSSProperties,
|
||||
container: { height: "calc(100vh - 25em)" } as React.CSSProperties,
|
||||
stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties,
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 100; // Number of items per page
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => {
|
||||
const [startIndex] = React.useState(0);
|
||||
const [startIndex, setStartIndex] = 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) => {
|
||||
@@ -52,27 +57,22 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
|
||||
setSortedJobs(sorted);
|
||||
setSortedColumnKey(columnKey);
|
||||
setIsSortedDescending(isDescending);
|
||||
setStartIndex(0);
|
||||
};
|
||||
|
||||
const columns: IColumn[] = React.useMemo(
|
||||
() => getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending),
|
||||
[handleSort, handleActionClick, sortedColumnKey, isSortedDescending],
|
||||
);
|
||||
const columns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending);
|
||||
|
||||
const _handleRowClick = React.useCallback((job: CopyJobType) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Row clicked:", job);
|
||||
}, []);
|
||||
const _handleRowClick = (job: CopyJobType) => {
|
||||
openCopyJobDetailsPanel(job);
|
||||
};
|
||||
|
||||
const _onRenderRow = React.useCallback((props: any) => {
|
||||
const _onRenderRow = (props: IDetailsRowProps) => {
|
||||
return (
|
||||
<div onClick={_handleRowClick.bind(null, props.item)}>
|
||||
<DetailsRow {...props} styles={{ root: { cursor: "pointer" } }} />
|
||||
</div>
|
||||
);
|
||||
}, []);
|
||||
|
||||
// const totalCount = jobs.length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
@@ -95,6 +95,21 @@ 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>
|
||||
);
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import { MessageBar, MessageBarType, Stack } from "@fluentui/react";
|
||||
import ShimmerTree, { IndentLevel } from "Common/ShimmerTree";
|
||||
import ShimmerTree, { IndentLevel } from "Common/ShimmerTree/ShimmerTree";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import React, { forwardRef, useEffect, useImperativeHandle } from "react";
|
||||
import { getCopyJobs, updateCopyJobStatus } from "../Actions/CopyJobActions";
|
||||
import { convertToCamelCase } from "../CopyJobUtils";
|
||||
import { CopyJobStatusType } from "../Enums";
|
||||
import { convertToCamelCase, isEqual } from "../CopyJobUtils";
|
||||
import { CopyJobStatusType } from "../Enums/CopyJobEnums";
|
||||
import CopyJobsNotFound from "../MonitorCopyJobs/Components/CopyJobs.NotFound";
|
||||
import { CopyJobType } from "../Types";
|
||||
import { CopyJobType, JobActionUpdatorType } from "../Types/CopyJobTypes";
|
||||
import CopyJobsList from "./Components/CopyJobsList";
|
||||
|
||||
const FETCH_INTERVAL_MS = 30 * 1000; // Interval time in milliseconds (30 seconds)
|
||||
const FETCH_INTERVAL_MS = 30 * 1000;
|
||||
const SHIMMER_INDENT_LEVELS: IndentLevel[] = Array(7).fill({ level: 0, width: "100%" });
|
||||
|
||||
interface MonitorCopyJobsProps {}
|
||||
interface MonitorCopyJobsProps {
|
||||
explorer: Explorer;
|
||||
}
|
||||
|
||||
export interface MonitorCopyJobsRef {
|
||||
refreshJobList: () => void;
|
||||
}
|
||||
|
||||
const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>((_props, ref) => {
|
||||
const [loading, setLoading] = React.useState(true); // Start with loading as true
|
||||
const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>(({ explorer }, ref) => {
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [jobs, setJobs] = React.useState<CopyJobType[]>([]);
|
||||
const isUpdatingRef = React.useRef(false); // 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 isUpdatingRef = React.useRef(false);
|
||||
const isFirstFetchRef = React.useRef(true);
|
||||
|
||||
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;
|
||||
return isEqual(prevJobs, response) ? prevJobs : response;
|
||||
});
|
||||
} catch (error) {
|
||||
setError(error.message || "Failed to load copy jobs. Please try again later.");
|
||||
} finally {
|
||||
if (isFirstFetchRef.current) {
|
||||
setLoading(false); // Hide loading spinner after the first fetch
|
||||
isFirstFetchRef.current = false; // Mark the first fetch as complete
|
||||
setLoading(false);
|
||||
isFirstFetchRef.current = false;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
@@ -69,48 +69,55 @@ const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>((_p
|
||||
},
|
||||
}));
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error.message || "Failed to update copy job status. Please try again later.");
|
||||
} finally {
|
||||
isUpdatingRef.current = false; // Mark as not updating
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const memoizedJobsList = React.useMemo(() => {
|
||||
const renderJobsList = () => {
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
if (jobs.length > 0) {
|
||||
return <CopyJobsList jobs={jobs} handleActionClick={handleActionClick} />;
|
||||
}
|
||||
return <CopyJobsNotFound />;
|
||||
}, [jobs, loading, handleActionClick]);
|
||||
return <CopyJobsNotFound explorer={explorer} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="monitorCopyJobs flexContainer">
|
||||
{loading && <ShimmerTree indentLevels={indentLevels} style={{ width: "100%", padding: "1rem 2.5rem" }} />}
|
||||
{loading && (
|
||||
<ShimmerTree indentLevels={SHIMMER_INDENT_LEVELS} style={{ width: "100%", padding: "1rem 2.5rem" }} />
|
||||
)}
|
||||
{error && (
|
||||
<MessageBar messageBarType={MessageBarType.error} isMultiline={false} onDismiss={() => setError(null)}>
|
||||
{error}
|
||||
</MessageBar>
|
||||
)}
|
||||
{memoizedJobsList}
|
||||
{renderJobsList()}
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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";
|
||||
import { CopyJobMigrationType, CopyJobStatusType } from "../Enums/CopyJobEnums";
|
||||
|
||||
export interface ContainerCopyProps {
|
||||
container: Explorer;
|
||||
explorer: Explorer;
|
||||
}
|
||||
|
||||
export type CopyJobCommandBarBtnType = {
|
||||
@@ -47,20 +48,19 @@ 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,11 +74,14 @@ 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 = {
|
||||
@@ -91,6 +94,8 @@ export type CopyJobType = {
|
||||
LastUpdatedTime: string;
|
||||
timestamp: number;
|
||||
Error?: CopyJobErrorType;
|
||||
Source: CosmosSqlDataTransferDataSourceSink;
|
||||
Destination: CosmosSqlDataTransferDataSourceSink;
|
||||
};
|
||||
|
||||
export interface CopyJobErrorType {
|
||||
@@ -130,3 +135,13 @@ 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;
|
||||
@@ -19,9 +19,11 @@
|
||||
.createCopyJobScreensContainer {
|
||||
height: 100%;
|
||||
padding: 1em 1.5em;
|
||||
.bold {
|
||||
font-weight: 600;
|
||||
|
||||
.pointInTimeRestoreContainer, .onlineCopyContainer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
label {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -61,6 +63,7 @@
|
||||
}
|
||||
}
|
||||
.popover-container {
|
||||
border-radius: 6px;
|
||||
button[disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
@@ -68,12 +71,33 @@
|
||||
}
|
||||
.foreground {
|
||||
z-index: 10;
|
||||
background-color: white;
|
||||
background-color: #f9f9f9;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
transform: translate(0%, -9%);
|
||||
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 {
|
||||
@@ -114,6 +138,13 @@
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.jobNameLink {
|
||||
color: @LinkColor;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,3 +157,28 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ 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;
|
||||
@@ -22,6 +23,7 @@ export interface FullTextPoliciesComponentProps {
|
||||
) => void;
|
||||
discardChanges?: boolean;
|
||||
onChangesDiscarded?: () => void;
|
||||
englishOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface FullTextPolicyData {
|
||||
@@ -66,6 +68,7 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
|
||||
onFullTextPathChange,
|
||||
discardChanges,
|
||||
onChangesDiscarded,
|
||||
englishOnly,
|
||||
}): JSX.Element => {
|
||||
const getFullTextPathError = (path: string, index?: number): string => {
|
||||
let error = "";
|
||||
@@ -87,6 +90,7 @@ 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),
|
||||
@@ -166,7 +170,7 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
|
||||
<Dropdown
|
||||
required={true}
|
||||
styles={dropdownStyles}
|
||||
options={getFullTextLanguageOptions()}
|
||||
options={getFullTextLanguageOptions(englishOnly)}
|
||||
selectedKey={defaultLanguage}
|
||||
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
|
||||
setDefaultLanguage(option.key as never)
|
||||
@@ -211,7 +215,7 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
|
||||
<Dropdown
|
||||
required={true}
|
||||
styles={dropdownStyles}
|
||||
options={getFullTextLanguageOptions()}
|
||||
options={getFullTextLanguageOptions(englishOnly)}
|
||||
selectedKey={fullTextPolicy.language}
|
||||
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
|
||||
onFullTextPathPolicyChange(index, option)
|
||||
@@ -229,11 +233,30 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
|
||||
);
|
||||
};
|
||||
|
||||
export const getFullTextLanguageOptions = (): IDropdownOption[] => {
|
||||
return [
|
||||
export const getFullTextLanguageOptions = (englishOnly?: boolean): IDropdownOption[] => {
|
||||
const multiLanguageSupportEnabled: boolean = isFullTextSearchPreviewFeaturesEnabled() && !englishOnly;
|
||||
const fullTextLanguageOptions: IDropdownOption[] = [
|
||||
{
|
||||
key: "en-US",
|
||||
text: "English (US)",
|
||||
},
|
||||
...(multiLanguageSupportEnabled
|
||||
? [
|
||||
{
|
||||
key: "fr-FR",
|
||||
text: "French",
|
||||
},
|
||||
{
|
||||
key: "de-DE",
|
||||
text: "German",
|
||||
},
|
||||
{
|
||||
key: "es-ES",
|
||||
text: "Spanish",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return fullTextLanguageOptions;
|
||||
};
|
||||
|
||||
@@ -24,6 +24,11 @@ jest.mock("../../../Common/dataAccess/updateCollection", () => ({
|
||||
changeFeedPolicy: undefined,
|
||||
analyticalStorageTtl: undefined,
|
||||
geospatialConfig: undefined,
|
||||
dataMaskingPolicy: {
|
||||
includedPaths: [],
|
||||
excludedPaths: ["/excludedPath"],
|
||||
isPolicyEnabled: true,
|
||||
},
|
||||
indexes: [],
|
||||
}),
|
||||
}));
|
||||
@@ -92,7 +97,6 @@ describe("SettingsComponent", () => {
|
||||
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
|
||||
expect(settingsComponentInstance.hasProvisioningTypeChanged()).toEqual(false);
|
||||
wrapper.setState({
|
||||
userCanChangeProvisioningTypes: true,
|
||||
isAutoPilotSelected: true,
|
||||
wasAutopilotOriginallySet: false,
|
||||
autoPilotThroughput: 1000,
|
||||
@@ -286,4 +290,157 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
|
||||
import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
|
||||
import { isRunningOnPublicCloud } from "Utils/CloudUtils";
|
||||
import * as React from "react";
|
||||
import DiscardIcon from "../../../../images/discard.svg";
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
ConflictResolutionComponent,
|
||||
ConflictResolutionComponentProps,
|
||||
} from "./SettingsSubComponents/ConflictResolutionComponent";
|
||||
import { DataMaskingComponent, DataMaskingComponentProps } from "./SettingsSubComponents/DataMaskingComponent";
|
||||
import {
|
||||
GlobalSecondaryIndexComponent,
|
||||
GlobalSecondaryIndexComponentProps,
|
||||
@@ -151,6 +152,12 @@ export interface SettingsComponentState {
|
||||
conflictResolutionPolicyProcedureBaseline: string;
|
||||
isConflictResolutionDirty: boolean;
|
||||
|
||||
dataMaskingContent: DataModels.DataMaskingPolicy;
|
||||
dataMaskingContentBaseline: DataModels.DataMaskingPolicy;
|
||||
shouldDiscardDataMasking: boolean;
|
||||
isDataMaskingDirty: boolean;
|
||||
dataMaskingValidationErrors: string[];
|
||||
|
||||
selectedTab: SettingsV2TabTypes;
|
||||
}
|
||||
|
||||
@@ -258,6 +265,12 @@ 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,
|
||||
@@ -334,7 +347,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
};
|
||||
|
||||
public isSaveSettingsButtonEnabled = (): boolean => {
|
||||
if (this.isOfferReplacePending()) {
|
||||
if (this.isOfferReplacePending() || this.props.settingsTab.isExecuting()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -342,6 +355,10 @@ 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 ||
|
||||
@@ -349,12 +366,16 @@ 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 ||
|
||||
@@ -362,6 +383,7 @@ 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
|
||||
);
|
||||
@@ -417,7 +439,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
: this.saveDatabaseSettings(startKey));
|
||||
} catch (error) {
|
||||
this.props.settingsTab.isExecutionError(true);
|
||||
console.error(error);
|
||||
traceFailure(
|
||||
Action.SettingsV2Updated,
|
||||
{
|
||||
@@ -434,8 +455,6 @@ 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,
|
||||
@@ -446,6 +465,9 @@ 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",
|
||||
});
|
||||
@@ -486,6 +508,10 @@ 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: [],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -648,6 +674,36 @@ 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) => {
|
||||
@@ -772,6 +828,11 @@ 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);
|
||||
@@ -824,11 +885,14 @@ 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({
|
||||
@@ -838,7 +902,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.saveSettingsButton.isEnabled(),
|
||||
disabled: isExecuting || !this.saveSettingsButton.isEnabled(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -847,11 +911,16 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
buttons.push({
|
||||
iconSrc: DiscardIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: this.onRevertClick,
|
||||
onCommandClick: () => {
|
||||
if (isExecuting) {
|
||||
return;
|
||||
}
|
||||
this.onRevertClick();
|
||||
},
|
||||
commandButtonLabel: label,
|
||||
ariaLabel: label,
|
||||
hasPopup: false,
|
||||
disabled: !this.discardSettingsChangesButton.isEnabled(),
|
||||
disabled: isExecuting || !this.discardSettingsChangesButton.isEnabled(),
|
||||
});
|
||||
}
|
||||
return buttons;
|
||||
@@ -949,7 +1018,8 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
this.state.isContainerPolicyDirty ||
|
||||
this.state.isIndexingPolicyDirty ||
|
||||
this.state.isConflictResolutionDirty ||
|
||||
this.state.isComputedPropertiesDirty
|
||||
this.state.isComputedPropertiesDirty ||
|
||||
this.state.isDataMaskingDirty
|
||||
) {
|
||||
let defaultTtl: number;
|
||||
switch (this.state.timeToLive) {
|
||||
@@ -972,6 +1042,11 @@ 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 =
|
||||
@@ -1017,13 +1092,18 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1353,6 +1433,31 @@ 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,
|
||||
|
||||
@@ -69,13 +69,111 @@ describe("SettingsUtils functions", () => {
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
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("$");
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface PriceBreakdown {
|
||||
currencySign: string;
|
||||
}
|
||||
|
||||
export type editorType = "indexPolicy" | "computedProperties";
|
||||
export type editorType = "indexPolicy" | "computedProperties" | "dataMasking";
|
||||
|
||||
export const infoAndToolTipTextStyle: ITextStyles = { root: { fontSize: 14, color: "windowtext" } };
|
||||
|
||||
@@ -170,6 +170,14 @@ 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 {
|
||||
@@ -259,7 +267,12 @@ 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" : "computed properties"}. Please click save to confirm the changes.
|
||||
{editor === "indexPolicy"
|
||||
? "indexing policy"
|
||||
: editor === "dataMasking"
|
||||
? "data masking policy"
|
||||
: "computed properties"}
|
||||
. Please click save to confirm the changes.
|
||||
</Text>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,12 @@ import {
|
||||
getMongoIndexType,
|
||||
getMongoIndexTypeText,
|
||||
getMongoNotification,
|
||||
getPartitionKeyName,
|
||||
getPartitionKeyPlaceHolder,
|
||||
getPartitionKeySubtext,
|
||||
getPartitionKeyTooltipText,
|
||||
getSanitizedInputValue,
|
||||
getTabTitle,
|
||||
hasDatabaseSharedThroughput,
|
||||
isDirty,
|
||||
isIndexTransforming,
|
||||
@@ -14,6 +19,7 @@ import {
|
||||
MongoWildcardPlaceHolder,
|
||||
parseConflictResolutionMode,
|
||||
parseConflictResolutionProcedure,
|
||||
SettingsV2TabTypes,
|
||||
SingleFieldText,
|
||||
WildcardText,
|
||||
} from "./SettingsUtils";
|
||||
@@ -50,14 +56,46 @@ describe("SettingsUtils", () => {
|
||||
expect(hasDatabaseSharedThroughput(newCollection)).toEqual(true);
|
||||
});
|
||||
|
||||
it("parseConflictResolutionMode", () => {
|
||||
expect(parseConflictResolutionMode("custom")).toEqual(DataModels.ConflictResolutionMode.Custom);
|
||||
expect(parseConflictResolutionMode("lastwriterwins")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins);
|
||||
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("parseConflictResolutionProcedure", () => {
|
||||
expect(parseConflictResolutionProcedure("/dbs/db/colls/coll/sprocs/conflictResSproc")).toEqual("conflictResSproc");
|
||||
expect(parseConflictResolutionProcedure("conflictResSproc")).toEqual("conflictResSproc");
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDirty", () => {
|
||||
@@ -68,68 +106,235 @@ describe("SettingsUtils", () => {
|
||||
excludedPaths: [],
|
||||
} as DataModels.IndexingPolicy;
|
||||
|
||||
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);
|
||||
describe("primitive types", () => {
|
||||
it("handles strings", () => {
|
||||
expect(isDirty("baseline", "baseline")).toBeFalsy();
|
||||
expect(isDirty("baseline", "current")).toBeTruthy();
|
||||
expect(isDirty("", "")).toBeFalsy();
|
||||
expect(isDirty("test", "")).toBeTruthy();
|
||||
});
|
||||
|
||||
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);
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("getSanitizedInputValue", () => {
|
||||
describe("getSanitizedInputValue", () => {
|
||||
const max = 100;
|
||||
expect(getSanitizedInputValue("", max)).toEqual(0);
|
||||
expect(getSanitizedInputValue("999", max)).toEqual(100);
|
||||
expect(getSanitizedInputValue("10", max)).toEqual(10);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
it("getMongoIndexType", () => {
|
||||
expect(getMongoIndexType(["Single"])).toEqual(MongoIndexTypes.Single);
|
||||
expect(getMongoIndexType(["Wildcard.$**"])).toEqual(MongoIndexTypes.Wildcard);
|
||||
expect(getMongoIndexType(["Key1", "Key2"])).toEqual(undefined);
|
||||
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("getMongoIndexTypeText", () => {
|
||||
expect(getMongoIndexTypeText(MongoIndexTypes.Single)).toEqual(SingleFieldText);
|
||||
expect(getMongoIndexTypeText(MongoIndexTypes.Wildcard)).toEqual(WildcardText);
|
||||
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("getMongoNotification", () => {
|
||||
describe("getMongoNotification", () => {
|
||||
const singleIndexDescription = "sampleKey";
|
||||
const wildcardIndexDescription = "sampleKey.$**";
|
||||
|
||||
let notification = getMongoNotification(singleIndexDescription, undefined);
|
||||
expect(notification.message).toEqual("Please select a type for each index.");
|
||||
expect(notification.type).toEqual(MongoNotificationType.Warning);
|
||||
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);
|
||||
});
|
||||
|
||||
notification = getMongoNotification(singleIndexDescription, MongoIndexTypes.Single);
|
||||
expect(notification).toEqual(undefined);
|
||||
it("returns undefined for valid type and description combinations", () => {
|
||||
expect(getMongoNotification(singleIndexDescription, MongoIndexTypes.Single)).toBeUndefined();
|
||||
expect(getMongoNotification(wildcardIndexDescription, MongoIndexTypes.Wildcard)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
notification = getMongoNotification(wildcardIndexDescription, MongoIndexTypes.Wildcard);
|
||||
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("", MongoIndexTypes.Single);
|
||||
expect(notification.message).toEqual("Please enter a field name.");
|
||||
expect(notification.type).toEqual(MongoNotificationType.Error);
|
||||
const whitespaceNotification = getMongoNotification(" ", MongoIndexTypes.Single);
|
||||
expect(whitespaceNotification.message).toEqual("Please enter a field name.");
|
||||
expect(whitespaceNotification.type).toEqual(MongoNotificationType.Error);
|
||||
});
|
||||
|
||||
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("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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("isIndexingTransforming", () => {
|
||||
expect(isIndexTransforming(undefined)).toBeFalsy();
|
||||
expect(isIndexTransforming(0)).toBeTruthy();
|
||||
expect(isIndexTransforming(90)).toBeTruthy();
|
||||
expect(isIndexTransforming(100)).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -13,7 +13,8 @@ export type isDirtyTypes =
|
||||
| DataModels.ComputedProperties
|
||||
| DataModels.VectorEmbedding[]
|
||||
| DataModels.FullTextPolicy
|
||||
| DataModels.ThroughputBucket[];
|
||||
| DataModels.ThroughputBucket[]
|
||||
| DataModels.DataMaskingPolicy;
|
||||
export const TtlOff = "off";
|
||||
export const TtlOn = "on";
|
||||
export const TtlOnNoDefault = "on-nodefault";
|
||||
@@ -59,6 +60,7 @@ export enum SettingsV2TabTypes {
|
||||
ContainerVectorPolicyTab,
|
||||
ThroughputBucketsTab,
|
||||
GlobalSecondaryIndexTab,
|
||||
DataMaskingTab,
|
||||
}
|
||||
|
||||
export enum ContainerPolicyTabTypes {
|
||||
@@ -175,6 +177,8 @@ 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}`);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,11 @@ export const collection = {
|
||||
sourceCollectionId: "source1",
|
||||
sourceCollectionRid: "rid123",
|
||||
}),
|
||||
dataMaskingPolicy: ko.observable<DataModels.DataMaskingPolicy>({
|
||||
includedPaths: [],
|
||||
excludedPaths: ["/excludedPath"],
|
||||
isPolicyEnabled: true,
|
||||
}),
|
||||
readSettings: () => {
|
||||
return;
|
||||
},
|
||||
|
||||
@@ -53,6 +53,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"parameters": [Function],
|
||||
},
|
||||
},
|
||||
"dataMaskingPolicy": [Function],
|
||||
"databaseId": "test",
|
||||
"defaultTtl": [Function],
|
||||
"fullTextPolicy": [Function],
|
||||
@@ -145,6 +146,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"parameters": [Function],
|
||||
},
|
||||
},
|
||||
"dataMaskingPolicy": [Function],
|
||||
"databaseId": "test",
|
||||
"defaultTtl": [Function],
|
||||
"fullTextPolicy": [Function],
|
||||
@@ -302,6 +304,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"parameters": [Function],
|
||||
},
|
||||
},
|
||||
"dataMaskingPolicy": [Function],
|
||||
"databaseId": "test",
|
||||
"defaultTtl": [Function],
|
||||
"fullTextPolicy": [Function],
|
||||
@@ -442,6 +445,7 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"parameters": [Function],
|
||||
},
|
||||
},
|
||||
"dataMaskingPolicy": [Function],
|
||||
"databaseId": "test",
|
||||
"defaultTtl": [Function],
|
||||
"fullTextPolicy": [Function],
|
||||
|
||||
@@ -286,6 +286,7 @@ export const ThroughputInput: FunctionComponent<ThroughputInputProps> = ({
|
||||
</Stack>
|
||||
<TextField
|
||||
id="autoscaleRUValueField"
|
||||
data-test="autoscaleRUInput"
|
||||
type="number"
|
||||
styles={{
|
||||
fieldGroup: { width: 100, height: 27, flexShrink: 0 },
|
||||
|
||||
@@ -2144,6 +2144,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = `
|
||||
</Stack>
|
||||
<StyledTextFieldBase
|
||||
ariaLabel="Container max RU/s"
|
||||
data-test="autoscaleRUInput"
|
||||
errorMessage=""
|
||||
id="autoscaleRUValueField"
|
||||
key=".0:$.$.1"
|
||||
@@ -2170,6 +2171,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = `
|
||||
>
|
||||
<TextFieldBase
|
||||
ariaLabel="Container max RU/s"
|
||||
data-test="autoscaleRUInput"
|
||||
deferredValidationTime={200}
|
||||
errorMessage=""
|
||||
id="autoscaleRUValueField"
|
||||
@@ -2470,6 +2472,7 @@ exports[`ThroughputInput Pane should render Default properly 1`] = `
|
||||
aria-invalid={false}
|
||||
aria-label="Container max RU/s"
|
||||
className="ms-TextField-field field-124"
|
||||
data-test="autoscaleRUInput"
|
||||
id="autoscaleRUValueField"
|
||||
max="9007199254740991"
|
||||
min={1000}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { LocalStorageUtility, StorageKey } from "Shared/StorageUtility";
|
||||
import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils";
|
||||
import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointUtils";
|
||||
import { featureRegistered } from "Utils/FeatureRegistrationUtils";
|
||||
import { update } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
|
||||
import { useQueryCopilot } from "hooks/useQueryCopilot";
|
||||
import * as ko from "knockout";
|
||||
import React from "react";
|
||||
@@ -48,7 +47,7 @@ import { stringToBlob } from "../Utils/BlobUtils";
|
||||
import { isCapabilityEnabled } from "../Utils/CapabilityUtils";
|
||||
import { fromContentUri, toRawContentUri } from "../Utils/GitHubUtils";
|
||||
import * as NotificationConsoleUtils from "../Utils/NotificationConsoleUtils";
|
||||
import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../Utils/NotificationConsoleUtils";
|
||||
import { logConsoleError, logConsoleInfo } from "../Utils/NotificationConsoleUtils";
|
||||
import { useSidePanel } from "../hooks/useSidePanel";
|
||||
import { ReactTabKind, useTabs } from "../hooks/useTabs";
|
||||
import "./ComponentRegisterer";
|
||||
@@ -218,56 +217,6 @@ export default class Explorer {
|
||||
this.refreshNotebookList();
|
||||
}
|
||||
|
||||
public openEnableSynapseLinkDialog(): void {
|
||||
const addSynapseLinkDialogProps: DialogProps = {
|
||||
linkProps: {
|
||||
linkText: "Learn more",
|
||||
linkUrl: "https://aka.ms/cosmosdb-synapselink",
|
||||
},
|
||||
isModal: true,
|
||||
title: `Enable Azure Synapse Link on your Cosmos DB account`,
|
||||
subText: `Enable Azure Synapse Link to perform near real time analytical analytics on this account, without impacting the performance of your transactional workloads.
|
||||
Azure Synapse Link brings together Cosmos Db Analytical Store and Synapse Analytics`,
|
||||
primaryButtonText: "Enable Azure Synapse Link",
|
||||
secondaryButtonText: "Cancel",
|
||||
|
||||
onPrimaryButtonClick: async () => {
|
||||
const startTime = TelemetryProcessor.traceStart(Action.EnableAzureSynapseLink);
|
||||
const clearInProgressMessage = logConsoleProgress(
|
||||
"Enabling Azure Synapse Link for this account. This may take a few minutes before you can enable analytical store for this account.",
|
||||
);
|
||||
useNotebook.getState().setIsSynapseLinkUpdating(true);
|
||||
useDialog.getState().closeDialog();
|
||||
|
||||
try {
|
||||
await update(userContext.subscriptionId, userContext.resourceGroup, userContext.databaseAccount.name, {
|
||||
properties: {
|
||||
enableAnalyticalStorage: true,
|
||||
},
|
||||
});
|
||||
|
||||
clearInProgressMessage();
|
||||
logConsoleInfo("Enabled Azure Synapse Link for this account");
|
||||
TelemetryProcessor.traceSuccess(Action.EnableAzureSynapseLink, {}, startTime);
|
||||
userContext.databaseAccount.properties.enableAnalyticalStorage = true;
|
||||
} catch (error) {
|
||||
clearInProgressMessage();
|
||||
logConsoleError(`Enabling Azure Synapse Link for this account failed. ${getErrorMessage(error)}`);
|
||||
TelemetryProcessor.traceFailure(Action.EnableAzureSynapseLink, {}, startTime);
|
||||
} finally {
|
||||
useNotebook.getState().setIsSynapseLinkUpdating(false);
|
||||
}
|
||||
},
|
||||
|
||||
onSecondaryButtonClick: () => {
|
||||
useDialog.getState().closeDialog();
|
||||
TelemetryProcessor.traceCancel(Action.EnableAzureSynapseLink);
|
||||
},
|
||||
};
|
||||
useDialog.getState().openDialog(addSynapseLinkDialogProps);
|
||||
TelemetryProcessor.traceStart(Action.EnableAzureSynapseLink);
|
||||
}
|
||||
|
||||
public async openLoginForEntraIDPopUp(): Promise<void> {
|
||||
if (userContext.databaseAccount.properties?.documentEndpoint) {
|
||||
try {
|
||||
@@ -359,6 +308,14 @@ 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;
|
||||
@@ -1016,7 +973,7 @@ export default class Explorer {
|
||||
break;
|
||||
|
||||
case ViewModels.TerminalKind.VCoreMongo:
|
||||
title = "VCoreMongo Shell";
|
||||
title = "Mongo Shell";
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -14,66 +14,6 @@ describe("CommandBarComponentButtonFactory tests", () => {
|
||||
|
||||
afterEach(() => useSelectedNode.getState().setSelectedNode(undefined));
|
||||
|
||||
describe("Enable Azure Synapse Link Button", () => {
|
||||
const enableAzureSynapseLinkBtnLabel = "Enable Azure Synapse Link";
|
||||
const selectedNodeState = useSelectedNode.getState();
|
||||
|
||||
beforeAll(() => {
|
||||
mockExplorer = {} as Explorer;
|
||||
updateUserContext({
|
||||
databaseAccount: {
|
||||
properties: {
|
||||
capabilities: [{ name: "EnableMongo" }],
|
||||
},
|
||||
} as DatabaseAccount,
|
||||
});
|
||||
});
|
||||
|
||||
it("Button should be visible", () => {
|
||||
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
|
||||
const enableAzureSynapseLinkBtn = buttons.find(
|
||||
(button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel,
|
||||
);
|
||||
expect(enableAzureSynapseLinkBtn).toBeDefined();
|
||||
});
|
||||
|
||||
// TODO: Now that Tables API supports dataplane RBAC, calling createStaticCommandBarButtons will enable the
|
||||
// Entra ID Login button, which causes this test to fail due to "Invalid hook call.". This seems to be
|
||||
// unsupported in jest and needs to be tested with react-hooks-testing-library.
|
||||
//
|
||||
// it("Button should not be visible for Tables API", () => {
|
||||
// updateUserContext({
|
||||
// databaseAccount: {
|
||||
// properties: {
|
||||
// capabilities: [{ name: "EnableTable" }],
|
||||
// },
|
||||
// } as DatabaseAccount,
|
||||
// });
|
||||
//
|
||||
// const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
|
||||
// const enableAzureSynapseLinkBtn = buttons.find(
|
||||
// (button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel,
|
||||
// );
|
||||
// expect(enableAzureSynapseLinkBtn).toBeUndefined();
|
||||
//});
|
||||
|
||||
it("Button should not be visible for Cassandra API", () => {
|
||||
updateUserContext({
|
||||
databaseAccount: {
|
||||
properties: {
|
||||
capabilities: [{ name: "EnableCassandra" }],
|
||||
},
|
||||
} as DatabaseAccount,
|
||||
});
|
||||
|
||||
const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
|
||||
const enableAzureSynapseLinkBtn = buttons.find(
|
||||
(button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel,
|
||||
);
|
||||
expect(enableAzureSynapseLinkBtn).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Open Cassandra shell button", () => {
|
||||
const openCassandraShellBtnLabel = "Open Cassandra shell";
|
||||
const selectedNodeState = useSelectedNode.getState();
|
||||
@@ -136,7 +76,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
|
||||
|
||||
describe("Open Postgres and vCore Mongo buttons", () => {
|
||||
const openPostgresShellButtonLabel = "Open PSQL shell";
|
||||
const openVCoreMongoShellButtonLabel = "Open MongoDB (vCore) shell";
|
||||
const openVCoreMongoShellButtonLabel = "Open MongoDB (DocumentDB) shell";
|
||||
|
||||
beforeAll(() => {
|
||||
mockExplorer = {} as Explorer;
|
||||
|
||||
@@ -12,10 +12,8 @@ import HostedTerminalIcon from "../../../../images/Hosted-Terminal.svg";
|
||||
import OpenQueryFromDiskIcon from "../../../../images/OpenQueryFromDisk.svg";
|
||||
import OpenInTabIcon from "../../../../images/open-in-tab.svg";
|
||||
import SettingsIcon from "../../../../images/settings_15x15.svg";
|
||||
import SynapseIcon from "../../../../images/synapse-link.svg";
|
||||
import VSCodeIcon from "../../../../images/vscode.svg";
|
||||
import { AuthType } from "../../../AuthType";
|
||||
import * as Constants from "../../../Common/Constants";
|
||||
import { Platform, configContext } from "../../../ConfigContext";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { userContext } from "../../../UserContext";
|
||||
@@ -55,11 +53,6 @@ export function createStaticCommandBarButtons(
|
||||
userContext.apiType !== "Tables" &&
|
||||
userContext.apiType !== "Cassandra"
|
||||
) {
|
||||
const addSynapseLink = createOpenSynapseLinkDialogButton(container);
|
||||
if (addSynapseLink) {
|
||||
addDivider();
|
||||
buttons.push(addSynapseLink);
|
||||
}
|
||||
if (userContext.apiType !== "Gremlin") {
|
||||
const addVsCode = createOpenVsCodeDialogButton(container);
|
||||
buttons.push(addVsCode);
|
||||
@@ -237,33 +230,6 @@ function areScriptsSupported(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonComponentProps {
|
||||
if (configContext.platform === Platform.Emulator) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (userContext?.databaseAccount?.properties?.enableAnalyticalStorage) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const capabilities = userContext?.databaseAccount?.properties?.capabilities || [];
|
||||
if (capabilities.some((capability) => capability.name === Constants.CapabilityNames.EnableStorageAnalytics)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const label = "Enable Azure Synapse Link";
|
||||
return {
|
||||
iconSrc: SynapseIcon,
|
||||
iconAlt: label,
|
||||
onCommandClick: () => container.openEnableSynapseLinkDialog(),
|
||||
commandButtonLabel: label,
|
||||
hasPopup: false,
|
||||
disabled:
|
||||
useSelectedNode.getState().isQueryCopilotCollectionSelected() || useNotebook.getState().isSynapseLinkUpdating,
|
||||
ariaLabel: label,
|
||||
};
|
||||
}
|
||||
|
||||
function createOpenVsCodeDialogButton(container: Explorer): CommandButtonComponentProps {
|
||||
const label = "Visual Studio Code";
|
||||
return {
|
||||
@@ -450,7 +416,7 @@ function createOpenTerminalButtonByKind(
|
||||
case ViewModels.TerminalKind.Postgres:
|
||||
return "PSQL";
|
||||
case ViewModels.TerminalKind.VCoreMongo:
|
||||
return "MongoDB (vCore)";
|
||||
return "MongoDB (DocumentDB)";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ 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";
|
||||
|
||||
@@ -127,7 +126,6 @@ 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">
|
||||
@@ -293,21 +291,6 @@ 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);
|
||||
|
||||
@@ -25,7 +25,6 @@ interface NotebookState {
|
||||
isNotebooksEnabledForAccount: boolean;
|
||||
notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo;
|
||||
sparkClusterConnectionInfo: DataModels.SparkClusterConnectionInfo;
|
||||
isSynapseLinkUpdating: boolean;
|
||||
memoryUsageInfo: DataModels.MemoryUsageInfo;
|
||||
isShellEnabled: boolean;
|
||||
notebookBasePath: string;
|
||||
@@ -44,7 +43,6 @@ interface NotebookState {
|
||||
setIsNotebooksEnabledForAccount: (isNotebooksEnabledForAccount: boolean) => void;
|
||||
setNotebookServerInfo: (notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo) => void;
|
||||
setSparkClusterConnectionInfo: (sparkClusterConnectionInfo: DataModels.SparkClusterConnectionInfo) => void;
|
||||
setIsSynapseLinkUpdating: (isSynapseLinkUpdating: boolean) => void;
|
||||
setMemoryUsageInfo: (memoryUsageInfo: DataModels.MemoryUsageInfo) => void;
|
||||
setIsShellEnabled: (isShellEnabled: boolean) => void;
|
||||
setNotebookBasePath: (notebookBasePath: string) => void;
|
||||
@@ -79,7 +77,6 @@ export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
|
||||
password: undefined,
|
||||
endpoints: [],
|
||||
},
|
||||
isSynapseLinkUpdating: false,
|
||||
memoryUsageInfo: undefined,
|
||||
isShellEnabled: false,
|
||||
notebookBasePath: Constants.Notebook.defaultBasePath,
|
||||
@@ -106,7 +103,6 @@ export const useNotebook: UseStore<NotebookState> = create((set, get) => ({
|
||||
set({ notebookServerInfo }),
|
||||
setSparkClusterConnectionInfo: (sparkClusterConnectionInfo: DataModels.SparkClusterConnectionInfo) =>
|
||||
set({ sparkClusterConnectionInfo }),
|
||||
setIsSynapseLinkUpdating: (isSynapseLinkUpdating: boolean) => set({ isSynapseLinkUpdating }),
|
||||
setMemoryUsageInfo: (memoryUsageInfo: DataModels.MemoryUsageInfo) => set({ memoryUsageInfo }),
|
||||
setIsShellEnabled: (isShellEnabled: boolean) => set({ isShellEnabled }),
|
||||
setNotebookBasePath: (notebookBasePath: string) => set({ notebookBasePath }),
|
||||
|
||||
@@ -25,7 +25,6 @@ import { FullTextPoliciesComponent } from "Explorer/Controls/FullTextSeach/FullT
|
||||
import { VectorEmbeddingPoliciesComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent";
|
||||
import {
|
||||
AllPropertiesIndexed,
|
||||
AnalyticalStoreHeader,
|
||||
ContainerVectorPolicyTooltipContent,
|
||||
FullTextPolicyDefault,
|
||||
getPartitionKey,
|
||||
@@ -33,11 +32,9 @@ import {
|
||||
getPartitionKeyPlaceHolder,
|
||||
getPartitionKeyTooltipText,
|
||||
isFreeTierAccount,
|
||||
isSynapseLinkEnabled,
|
||||
parseUniqueKeys,
|
||||
scrollToSection,
|
||||
SharedDatabaseDefault,
|
||||
shouldShowAnalyticalStoreOptions,
|
||||
UniqueKeysHeader,
|
||||
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
|
||||
import { useSidePanel } from "hooks/useSidePanel";
|
||||
@@ -65,6 +62,8 @@ export interface AddCollectionPanelProps {
|
||||
explorer: Explorer;
|
||||
databaseId?: string;
|
||||
isQuickstart?: boolean;
|
||||
isCopyJobFlow?: boolean;
|
||||
onSubmitSuccess?: (collectionData: { databaseId: string; collectionId: string }) => void;
|
||||
}
|
||||
|
||||
export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = {
|
||||
@@ -84,7 +83,6 @@ export interface AddCollectionPanelState {
|
||||
enableDedicatedThroughput: boolean;
|
||||
createMongoWildCardIndex: boolean;
|
||||
useHashV1: boolean;
|
||||
enableAnalyticalStore: boolean;
|
||||
uniqueKeys: string[];
|
||||
errorMessage: string;
|
||||
showErrorDetails: boolean;
|
||||
@@ -126,7 +124,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
createMongoWildCardIndex:
|
||||
isCapabilityEnabled("EnableMongo") && !isCapabilityEnabled("EnableMongo16MBDocumentSupport"),
|
||||
useHashV1: false,
|
||||
enableAnalyticalStore: false,
|
||||
uniqueKeys: [],
|
||||
errorMessage: "",
|
||||
showErrorDetails: false,
|
||||
@@ -779,70 +776,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
<Separator className="panelSeparator" style={{ marginTop: -15, marginBottom: -4 }} />
|
||||
)}
|
||||
|
||||
{shouldShowAnalyticalStoreOptions() && (
|
||||
<Stack className="panelGroupSpacing" style={{ marginTop: -4 }}>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
{AnalyticalStoreHeader()}
|
||||
</Text>
|
||||
|
||||
<Stack horizontal verticalAlign="center">
|
||||
<div role="radiogroup">
|
||||
<input
|
||||
className="panelRadioBtn"
|
||||
checked={this.state.enableAnalyticalStore}
|
||||
disabled={!isSynapseLinkEnabled()}
|
||||
aria-label="Enable analytical store"
|
||||
aria-checked={this.state.enableAnalyticalStore}
|
||||
name="analyticalStore"
|
||||
type="radio"
|
||||
role="radio"
|
||||
id="enableAnalyticalStoreBtn"
|
||||
tabIndex={0}
|
||||
onChange={this.onEnableAnalyticalStoreRadioBtnChange.bind(this)}
|
||||
/>
|
||||
<span className="panelRadioBtnLabel">On</span>
|
||||
|
||||
<input
|
||||
className="panelRadioBtn"
|
||||
checked={!this.state.enableAnalyticalStore}
|
||||
disabled={!isSynapseLinkEnabled()}
|
||||
aria-label="Disable analytical store"
|
||||
aria-checked={!this.state.enableAnalyticalStore}
|
||||
name="analyticalStore"
|
||||
type="radio"
|
||||
role="radio"
|
||||
id="disableAnalyticalStoreBtn"
|
||||
tabIndex={0}
|
||||
onChange={this.onDisableAnalyticalStoreRadioBtnChange.bind(this)}
|
||||
/>
|
||||
<span className="panelRadioBtnLabel">Off</span>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{!isSynapseLinkEnabled() && (
|
||||
<Stack className="panelGroupSpacing">
|
||||
<Text variant="small">
|
||||
Azure Synapse Link is required for creating an analytical store{" "}
|
||||
{getCollectionName().toLocaleLowerCase()}. Enable Synapse Link for this Cosmos DB account. <br />
|
||||
<Link
|
||||
href="https://aka.ms/cosmosdb-synapselink"
|
||||
target="_blank"
|
||||
aria-label={Constants.ariaLabelForLearnMoreLink.AzureSynapseLink}
|
||||
className="capacitycalculator-link"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Text>
|
||||
<DefaultButton
|
||||
text="Enable"
|
||||
onClick={() => this.props.explorer.openEnableSynapseLinkDialog()}
|
||||
style={{ height: 27, width: 80 }}
|
||||
styles={{ label: { fontSize: 12 } }}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{this.shouldShowVectorSearchParameters() && (
|
||||
<Stack>
|
||||
<CollapsibleSectionComponent
|
||||
@@ -893,6 +826,8 @@ 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>
|
||||
@@ -973,7 +908,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PanelFooterComponent buttonLabel="OK" isButtonDisabled={this.state.isThroughputCapExceeded} />
|
||||
{!this.props.isCopyJobFlow && (
|
||||
<PanelFooterComponent buttonLabel="OK" isButtonDisabled={this.state.isThroughputCapExceeded} />
|
||||
)}
|
||||
|
||||
{this.state.isExecuting && (
|
||||
<div>
|
||||
@@ -1047,22 +984,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
}
|
||||
}
|
||||
|
||||
private onEnableAnalyticalStoreRadioBtnChange(event: React.ChangeEvent<HTMLInputElement>): void {
|
||||
if (event.target.checked && !this.state.enableAnalyticalStore) {
|
||||
this.setState({
|
||||
enableAnalyticalStore: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private onDisableAnalyticalStoreRadioBtnChange(event: React.ChangeEvent<HTMLInputElement>): void {
|
||||
if (event.target.checked && this.state.enableAnalyticalStore) {
|
||||
this.setState({
|
||||
enableAnalyticalStore: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private onTurnOnIndexing(event: React.ChangeEvent<HTMLInputElement>): void {
|
||||
if (event.target.checked && !this.state.enableIndexing) {
|
||||
this.setState({
|
||||
@@ -1237,25 +1158,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
return true;
|
||||
}
|
||||
|
||||
private getAnalyticalStorageTtl(): number {
|
||||
if (!isSynapseLinkEnabled()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!shouldShowAnalyticalStoreOptions()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.state.enableAnalyticalStore) {
|
||||
// TODO: always default to 90 days once the backend hotfix is deployed
|
||||
return userContext.features.ttl90Days
|
||||
? Constants.AnalyticalStorageTtl.Days90
|
||||
: Constants.AnalyticalStorageTtl.Infinite;
|
||||
}
|
||||
|
||||
return Constants.AnalyticalStorageTtl.Disabled;
|
||||
}
|
||||
|
||||
private getSampleDBName(): string {
|
||||
const existingSampleDBs = useDatabases
|
||||
.getState()
|
||||
@@ -1379,7 +1281,6 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
databaseLevelThroughput,
|
||||
offerThroughput,
|
||||
autoPilotMaxThroughput,
|
||||
analyticalStorageTtl: this.getAnalyticalStorageTtl(),
|
||||
indexingPolicy,
|
||||
partitionKey,
|
||||
uniqueKeyPolicy,
|
||||
@@ -1413,8 +1314,13 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
}
|
||||
}
|
||||
this.setState({ isExecuting: false });
|
||||
TelemetryProcessor.traceSuccess(Action.CreateCollection, telemetryData, startKey);
|
||||
useSidePanel.getState().closeSidePanel();
|
||||
|
||||
if (this.props.isCopyJobFlow && this.props.onSubmitSuccess) {
|
||||
this.props.onSubmitSuccess({ databaseId, collectionId });
|
||||
} else {
|
||||
TelemetryProcessor.traceSuccess(Action.CreateCollection, telemetryData, startKey);
|
||||
useSidePanel.getState().closeSidePanel();
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage: string = getErrorMessage(error);
|
||||
this.setState({ isExecuting: false, errorMessage, showErrorDetails: true });
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { DirectionalHint, Icon, Link, Stack, Text, TooltipHost } from "@fluentui/react";
|
||||
import * as Constants from "Common/Constants";
|
||||
import { configContext, Platform } from "ConfigContext";
|
||||
import * as DataModels from "Contracts/DataModels";
|
||||
import { getFullTextLanguageOptions } from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent";
|
||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import React from "react";
|
||||
import { userContext } from "UserContext";
|
||||
|
||||
@@ -84,70 +81,6 @@ export function UniqueKeysHeader(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowAnalyticalStoreOptions(): boolean {
|
||||
if (isFabricNative() || configContext.platform === Platform.Emulator) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (userContext.apiType) {
|
||||
case "SQL":
|
||||
case "Mongo":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AnalyticalStoreHeader(): JSX.Element {
|
||||
const tooltipContent =
|
||||
"Enable analytical store capability to perform near real-time analytics on your operational data, without impacting the performance of transactional workloads.";
|
||||
return (
|
||||
<Stack horizontal style={{ marginBottom: -2 }}>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
Analytical Store
|
||||
</Text>
|
||||
<TooltipHost directionalHint={DirectionalHint.bottomLeftEdge} content={tooltipContent}>
|
||||
<Icon iconName="Info" className="panelInfoIcon" tabIndex={0} ariaLabel={tooltipContent} />
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnalyticalStorageContent(): JSX.Element {
|
||||
return (
|
||||
<Text variant="small">
|
||||
Enable analytical store capability to perform near real-time analytics on your operational data, without impacting
|
||||
the performance of transactional workloads.{" "}
|
||||
<Link
|
||||
aria-label={Constants.ariaLabelForLearnMoreLink.AnalyticalStore}
|
||||
target="_blank"
|
||||
href="https://aka.ms/analytical-store-overview"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function isSynapseLinkEnabled(): boolean {
|
||||
if (!userContext.databaseAccount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { properties } = userContext.databaseAccount;
|
||||
if (!properties) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (properties.enableAnalyticalStorage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return properties.capabilities?.some(
|
||||
(capability) => capability.name === Constants.CapabilityNames.EnableStorageAnalytics,
|
||||
);
|
||||
}
|
||||
|
||||
export function scrollToSection(id: string): void {
|
||||
document.getElementById(id)?.scrollIntoView();
|
||||
}
|
||||
|
||||
@@ -367,129 +367,6 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Stack
|
||||
className="panelGroupSpacing"
|
||||
style={
|
||||
{
|
||||
"marginTop": -4,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
className="panelTextBold"
|
||||
variant="small"
|
||||
>
|
||||
<Stack
|
||||
horizontal={true}
|
||||
style={
|
||||
{
|
||||
"marginBottom": -2,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
className="panelTextBold"
|
||||
variant="small"
|
||||
>
|
||||
Analytical Store
|
||||
</Text>
|
||||
<StyledTooltipHostBase
|
||||
content="Enable analytical store capability to perform near real-time analytics on your operational data, without impacting the performance of transactional workloads."
|
||||
directionalHint={4}
|
||||
>
|
||||
<Icon
|
||||
ariaLabel="Enable analytical store capability to perform near real-time analytics on your operational data, without impacting the performance of transactional workloads."
|
||||
className="panelInfoIcon"
|
||||
iconName="Info"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</StyledTooltipHostBase>
|
||||
</Stack>
|
||||
</Text>
|
||||
<Stack
|
||||
horizontal={true}
|
||||
verticalAlign="center"
|
||||
>
|
||||
<div
|
||||
role="radiogroup"
|
||||
>
|
||||
<input
|
||||
aria-checked={false}
|
||||
aria-label="Enable analytical store"
|
||||
checked={false}
|
||||
className="panelRadioBtn"
|
||||
disabled={true}
|
||||
id="enableAnalyticalStoreBtn"
|
||||
name="analyticalStore"
|
||||
onChange={[Function]}
|
||||
role="radio"
|
||||
tabIndex={0}
|
||||
type="radio"
|
||||
/>
|
||||
<span
|
||||
className="panelRadioBtnLabel"
|
||||
>
|
||||
On
|
||||
</span>
|
||||
<input
|
||||
aria-checked={true}
|
||||
aria-label="Disable analytical store"
|
||||
checked={true}
|
||||
className="panelRadioBtn"
|
||||
disabled={true}
|
||||
id="disableAnalyticalStoreBtn"
|
||||
name="analyticalStore"
|
||||
onChange={[Function]}
|
||||
role="radio"
|
||||
tabIndex={0}
|
||||
type="radio"
|
||||
/>
|
||||
<span
|
||||
className="panelRadioBtnLabel"
|
||||
>
|
||||
Off
|
||||
</span>
|
||||
</div>
|
||||
</Stack>
|
||||
<Stack
|
||||
className="panelGroupSpacing"
|
||||
>
|
||||
<Text
|
||||
variant="small"
|
||||
>
|
||||
Azure Synapse Link is required for creating an analytical store
|
||||
|
||||
container
|
||||
. Enable Synapse Link for this Cosmos DB account.
|
||||
<br />
|
||||
<StyledLinkBase
|
||||
aria-label="Learn more about Azure Synapse Link."
|
||||
className="capacitycalculator-link"
|
||||
href="https://aka.ms/cosmosdb-synapselink"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more
|
||||
</StyledLinkBase>
|
||||
</Text>
|
||||
<CustomizedDefaultButton
|
||||
onClick={[Function]}
|
||||
style={
|
||||
{
|
||||
"height": 27,
|
||||
"width": 80,
|
||||
}
|
||||
}
|
||||
styles={
|
||||
{
|
||||
"label": {
|
||||
"fontSize": 12,
|
||||
},
|
||||
}
|
||||
}
|
||||
text="Enable"
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<CollapsibleSectionComponent
|
||||
isExpandedByDefault={false}
|
||||
@@ -516,6 +393,7 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
|
||||
}
|
||||
>
|
||||
<FullTextPoliciesComponent
|
||||
englishOnly={true}
|
||||
fullTextPolicy={
|
||||
{
|
||||
"defaultLanguage": "en-US",
|
||||
|
||||
@@ -21,16 +21,13 @@ import {
|
||||
AllPropertiesIndexed,
|
||||
FullTextPolicyDefault,
|
||||
getPartitionKey,
|
||||
isSynapseLinkEnabled,
|
||||
scrollToSection,
|
||||
shouldShowAnalyticalStoreOptions,
|
||||
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
|
||||
import {
|
||||
chooseSourceContainerStyle,
|
||||
chooseSourceContainerStyles,
|
||||
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanelStyles";
|
||||
import { AdvancedComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AdvancedComponent";
|
||||
import { AnalyticalStoreComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/AnalyticalStoreComponent";
|
||||
import { FullTextSearchComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/FullTextSearchComponent";
|
||||
import { PartitionKeyComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/PartitionKeyComponent";
|
||||
import { ThroughputComponent } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/Components/ThroughputComponent";
|
||||
@@ -64,7 +61,6 @@ export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanel
|
||||
const [useHashV1, setUseHashV1] = useState<boolean>();
|
||||
const [enableDedicatedThroughput, setEnabledDedicatedThroughput] = useState<boolean>();
|
||||
const [isThroughputCapExceeded, setIsThroughputCapExceeded] = useState<boolean>();
|
||||
const [enableAnalyticalStore, setEnableAnalyticalStore] = useState<boolean>();
|
||||
const [vectorEmbeddingPolicy, setVectorEmbeddingPolicy] = useState<VectorEmbedding[]>([]);
|
||||
const [vectorIndexingPolicy, setVectorIndexingPolicy] = useState<VectorIndex[]>([]);
|
||||
const [vectorPolicyValidated, setVectorPolicyValidated] = useState<boolean>(true);
|
||||
@@ -142,25 +138,6 @@ export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanel
|
||||
return isVectorSearchEnabled() && (isServerlessAccount() || showCollectionThroughputInput());
|
||||
};
|
||||
|
||||
const getAnalyticalStorageTtl = (): number => {
|
||||
if (!isSynapseLinkEnabled()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!shouldShowAnalyticalStoreOptions()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (enableAnalyticalStore) {
|
||||
// TODO: always default to 90 days once the backend hotfix is deployed
|
||||
return userContext.features.ttl90Days
|
||||
? Constants.AnalyticalStorageTtl.Days90
|
||||
: Constants.AnalyticalStorageTtl.Infinite;
|
||||
}
|
||||
|
||||
return Constants.AnalyticalStorageTtl.Disabled;
|
||||
};
|
||||
|
||||
const validateInputs = (): boolean => {
|
||||
if (!selectedSourceContainer) {
|
||||
setErrorMessage("Please select a source container");
|
||||
@@ -257,7 +234,6 @@ export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanel
|
||||
...(!databaseLevelThroughput && {
|
||||
autoPilotMaxThroughput: globalSecondaryIndexThroughput,
|
||||
}),
|
||||
analyticalStorageTtl: getAnalyticalStorageTtl(),
|
||||
indexingPolicy: indexingPolicy,
|
||||
partitionKey: partitionKeyPaths,
|
||||
vectorEmbeddingPolicy: vectorEmbeddingPolicyFinal,
|
||||
@@ -369,9 +345,7 @@ export const AddGlobalSecondaryIndexPanel = (props: AddGlobalSecondaryIndexPanel
|
||||
isCostAknowledgedOnChange,
|
||||
}}
|
||||
/>
|
||||
{shouldShowAnalyticalStoreOptions() && (
|
||||
<AnalyticalStoreComponent {...{ explorer, enableAnalyticalStore, setEnableAnalyticalStore }} />
|
||||
)}
|
||||
|
||||
{showVectorSearchParameters() && (
|
||||
<VectorSearchComponent
|
||||
{...{
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { DefaultButton, Link, Stack, Text } from "@fluentui/react";
|
||||
import * as Constants from "Common/Constants";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import {
|
||||
AnalyticalStorageContent,
|
||||
isSynapseLinkEnabled,
|
||||
} from "Explorer/Panes/AddCollectionPanel/AddCollectionPanelUtility";
|
||||
import React from "react";
|
||||
import { getCollectionName } from "Utils/APITypeUtils";
|
||||
|
||||
export interface AnalyticalStoreComponentProps {
|
||||
explorer: Explorer;
|
||||
enableAnalyticalStore: boolean;
|
||||
setEnableAnalyticalStore: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
export const AnalyticalStoreComponent = (props: AnalyticalStoreComponentProps): JSX.Element => {
|
||||
const { explorer, enableAnalyticalStore, setEnableAnalyticalStore } = props;
|
||||
|
||||
const onEnableAnalyticalStoreRadioButtonChange = (checked: boolean): void => {
|
||||
if (checked && !enableAnalyticalStore) {
|
||||
setEnableAnalyticalStore(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onDisableAnalyticalStoreRadioButtonnChange = (checked: boolean): void => {
|
||||
if (checked && enableAnalyticalStore) {
|
||||
setEnableAnalyticalStore(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="panelGroupSpacing">
|
||||
<Text className="panelTextBold" variant="small">
|
||||
{AnalyticalStorageContent()}
|
||||
</Text>
|
||||
|
||||
<Stack horizontal verticalAlign="center">
|
||||
<div role="radiogroup">
|
||||
<input
|
||||
className="panelRadioBtn"
|
||||
checked={enableAnalyticalStore}
|
||||
disabled={!isSynapseLinkEnabled()}
|
||||
aria-label="Enable analytical store"
|
||||
aria-checked={enableAnalyticalStore}
|
||||
name="analyticalStore"
|
||||
type="radio"
|
||||
role="radio"
|
||||
id="enableAnalyticalStoreBtn"
|
||||
tabIndex={0}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onEnableAnalyticalStoreRadioButtonChange(event.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className="panelRadioBtnLabel">On</span>
|
||||
|
||||
<input
|
||||
className="panelRadioBtn"
|
||||
checked={!enableAnalyticalStore}
|
||||
disabled={!isSynapseLinkEnabled()}
|
||||
aria-label="Disable analytical store"
|
||||
aria-checked={!enableAnalyticalStore}
|
||||
name="analyticalStore"
|
||||
type="radio"
|
||||
role="radio"
|
||||
id="disableAnalyticalStoreBtn"
|
||||
tabIndex={0}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onDisableAnalyticalStoreRadioButtonnChange(event.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span className="panelRadioBtnLabel">Off</span>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{!isSynapseLinkEnabled() && (
|
||||
<Stack className="panelGroupSpacing">
|
||||
<Text variant="small">
|
||||
Azure Synapse Link is required for creating an analytical store {getCollectionName().toLocaleLowerCase()}.
|
||||
Enable Synapse Link for this Cosmos DB account.{" "}
|
||||
<Link
|
||||
href="https://aka.ms/cosmosdb-synapselink"
|
||||
target="_blank"
|
||||
aria-label={Constants.ariaLabelForLearnMoreLink.AzureSynapseLink}
|
||||
className="capacitycalculator-link"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Text>
|
||||
<DefaultButton
|
||||
text="Enable"
|
||||
onClick={() => explorer.openEnableSynapseLinkDialog()}
|
||||
style={{ height: 27, width: 80 }}
|
||||
styles={{ label: { fontSize: 12 } }}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -142,36 +142,6 @@ exports[`AddGlobalSecondaryIndexPanel render default panel 1`] = `
|
||||
setIsThroughputCapExceeded={[Function]}
|
||||
showCollectionThroughputInput={[Function]}
|
||||
/>
|
||||
<AnalyticalStoreComponent
|
||||
explorer={
|
||||
Explorer {
|
||||
"_isInitializingNotebooks": false,
|
||||
"isFixedCollectionWithSharedThroughputSupported": [Function],
|
||||
"isTabsContentExpanded": [Function],
|
||||
"onRefreshDatabasesKeyPress": [Function],
|
||||
"onRefreshResourcesClick": [Function],
|
||||
"phoenixClient": PhoenixClient {
|
||||
"armResourceId": undefined,
|
||||
"retryOptions": {
|
||||
"maxTimeout": 5000,
|
||||
"minTimeout": 5000,
|
||||
"retries": 3,
|
||||
},
|
||||
},
|
||||
"provideFeedbackEmail": [Function],
|
||||
"queriesClient": QueriesClient {
|
||||
"container": [Circular],
|
||||
},
|
||||
"refreshNotebookList": [Function],
|
||||
"resourceTree": ResourceTreeAdapter {
|
||||
"container": [Circular],
|
||||
"copyNotebook": [Function],
|
||||
"parameters": [Function],
|
||||
},
|
||||
}
|
||||
}
|
||||
setEnableAnalyticalStore={[Function]}
|
||||
/>
|
||||
<FullTextSearchComponent
|
||||
fullTextPolicy={
|
||||
{
|
||||
|
||||
@@ -3,11 +3,24 @@ 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} />);
|
||||
@@ -19,6 +32,7 @@ describe("PaneContainerComponent test", () => {
|
||||
headerText: "test",
|
||||
panelContent: undefined,
|
||||
isOpen: true,
|
||||
hasConsole: true,
|
||||
isConsoleExpanded: false,
|
||||
};
|
||||
const wrapper = shallow(<PanelContainerComponent {...panelContainerProps} />);
|
||||
@@ -30,6 +44,7 @@ describe("PaneContainerComponent test", () => {
|
||||
headerText: "test",
|
||||
panelContent: <div></div>,
|
||||
isOpen: true,
|
||||
hasConsole: true,
|
||||
isConsoleExpanded: true,
|
||||
};
|
||||
const wrapper = shallow(<PanelContainerComponent {...panelContainerProps} />);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -5,6 +5,12 @@ import { updateUserContext } from "../../../UserContext";
|
||||
import { SettingsPane } from "./SettingsPane";
|
||||
|
||||
describe("Settings Pane", () => {
|
||||
beforeEach(() => {
|
||||
updateUserContext({
|
||||
sessionId: "1234-5678",
|
||||
});
|
||||
});
|
||||
|
||||
it("should render Default properly", () => {
|
||||
const wrapper = shallow(<SettingsPane explorer={null} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IToggleStyles,
|
||||
Position,
|
||||
SpinButton,
|
||||
Stack,
|
||||
Toggle,
|
||||
} from "@fluentui/react";
|
||||
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, makeStyles } from "@fluentui/react-components";
|
||||
@@ -204,10 +205,14 @@ 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 =
|
||||
@@ -424,6 +429,12 @@ 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)}`,
|
||||
@@ -453,6 +464,10 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
||||
);
|
||||
}
|
||||
|
||||
logConsoleInfo(
|
||||
`${ignorePartitionKeyOnDocumentUpdate ? "Enabled" : "Disabled"} ignoring partition key on document update`,
|
||||
);
|
||||
|
||||
refreshExplorer && (await explorer.refreshExplorer());
|
||||
closeSidePanel();
|
||||
};
|
||||
@@ -593,6 +608,13 @@ 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",
|
||||
@@ -1137,6 +1159,29 @@ 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>
|
||||
)}
|
||||
|
||||
@@ -1183,6 +1228,12 @@ 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>
|
||||
);
|
||||
|
||||
@@ -575,6 +575,52 @@ 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"
|
||||
@@ -603,6 +649,22 @@ 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>
|
||||
`;
|
||||
@@ -838,6 +900,52 @@ 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"
|
||||
@@ -866,6 +974,22 @@ 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>
|
||||
`;
|
||||
|
||||
@@ -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, useState } from "react";
|
||||
import React, { ChangeEvent, FunctionComponent, useReducer, useState } from "react";
|
||||
import { getErrorMessage } from "../../Tables/Utilities";
|
||||
import { useSelectedNode } from "../../useSelectedNode";
|
||||
import { RightPaneForm, RightPaneFormProps } from "../RightPaneForm/RightPaneForm";
|
||||
@@ -57,6 +57,7 @@ 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("");
|
||||
@@ -75,6 +76,7 @@ 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);
|
||||
},
|
||||
@@ -95,6 +97,7 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
|
||||
const props: RightPaneFormProps = {
|
||||
formError,
|
||||
isExecuting: isExecuting,
|
||||
isSubmitButtonDisabled: !files || files.length === 0,
|
||||
submitButtonText: "Upload",
|
||||
onSubmit,
|
||||
};
|
||||
@@ -192,6 +195,7 @@ 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"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
exports[`Upload Items Pane should render Default properly 1`] = `
|
||||
<RightPaneForm
|
||||
formError=""
|
||||
isSubmitButtonDisabled={true}
|
||||
onSubmit={[Function]}
|
||||
submitButtonText="Upload"
|
||||
>
|
||||
@@ -11,6 +12,7 @@ exports[`Upload Items Pane should render Default properly 1`] = `
|
||||
>
|
||||
<Upload
|
||||
accept="application/json"
|
||||
key="1"
|
||||
label="Select JSON Files"
|
||||
multiple={true}
|
||||
onUpload={[Function]}
|
||||
|
||||
@@ -39,6 +39,45 @@ 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`] = `
|
||||
|
||||
@@ -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 Cosmos DB Mongo DB (vCore)</Text>
|
||||
<Text variant="small">Getting started in Azure DocumentDB (with MongoDB compatibility)</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 MongoDB vCore
|
||||
To start, input the admin password you used during the cluster creation process into the Document DB
|
||||
terminal.
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
Note: If you navigate out of the Quick start blade (MongoDB vCore Shell), the session will be
|
||||
closed and all ongoing commands might be interrupted.
|
||||
Note: If you navigate out of the Quick start blade (MongoDB Shell), 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's on-premises or
|
||||
hosted in the cloud, to Azure Cosmos DB for MongoDB vCore.
|
||||
hosted in the cloud, to Azure DocumentDB.
|
||||
<Link
|
||||
target="_blank"
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/mongodb/vcore/migration-options"
|
||||
|
||||
@@ -142,7 +142,7 @@ const FabricHomeScreenButton: React.FC<FabricHomeScreenButtonProps & { className
|
||||
return (
|
||||
<div role="button" className={`${styles.buttonContainer} ${className}`} onClick={onClick} tabIndex={0}>
|
||||
<div className={styles.buttonUpperPart}>{icon}</div>
|
||||
<div aria-label={title} className={styles.buttonLowerPart}>
|
||||
<div aria-label={`${title} ${description}`} className={styles.buttonLowerPart}>
|
||||
<div>{title}</div>
|
||||
<div>{description}</div>
|
||||
</div>
|
||||
@@ -182,7 +182,7 @@ export const FabricHomeScreen: React.FC<SplashScreenProps> = (props: SplashScree
|
||||
},
|
||||
{
|
||||
title: "Sample Vector Data",
|
||||
description: "Load sample vector data in your database",
|
||||
description: "Load sample vector data with text-embedding-ada-002",
|
||||
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://azurecosmosdb.github.io/gallery/?tags=example&tags=analytics", "_blank"),
|
||||
onClick: () => window.open("https://aka.ms/CosmosFabricSamplesGallery", "_blank"),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -36,6 +36,56 @@ 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,
|
||||
@@ -49,48 +99,12 @@ export const createContainer = async (
|
||||
databaseId: databaseName,
|
||||
databaseLevelThroughput: false,
|
||||
partitionKey: {
|
||||
paths: [`/${SAMPLE_DATA_PARTITION_KEY}`],
|
||||
paths: [`/${containerSettings[sampleDataFile].partitionKeyString}`],
|
||||
kind: "Hash",
|
||||
version: BackendDefaults.partitionKeyVersion,
|
||||
},
|
||||
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,
|
||||
vectorEmbeddingPolicy: containerSettings[sampleDataFile].vectorEmbeddingPolicy,
|
||||
indexingPolicy: containerSettings[sampleDataFile].indexingPolicy,
|
||||
};
|
||||
await createCollection(createRequest);
|
||||
await explorer.refreshAllDatabases();
|
||||
@@ -103,8 +117,6 @@ 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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user