mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-01-10 13:08:20 +00:00
Compare commits
7 Commits
user/bchou
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed43314903 | ||
|
|
92c8afd166 | ||
|
|
234e4181fc | ||
|
|
38823ac86f | ||
|
|
b71ea50972 | ||
|
|
e27cff0553 | ||
|
|
4ac8cd8fe4 |
32
.github/workflows/ci.yml
vendored
32
.github/workflows/ci.yml
vendored
@@ -164,8 +164,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
|
||||
shardTotal: [16]
|
||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
|
||||
shardTotal: [20]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 18.x
|
||||
@@ -201,18 +201,20 @@ jobs:
|
||||
GREMLIN_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-gremlin.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$GREMLIN_TESTACCOUNT_TOKEN"
|
||||
echo GREMLIN_TESTACCOUNT_TOKEN=$GREMLIN_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN"
|
||||
echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN"
|
||||
echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN"
|
||||
echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN"
|
||||
echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
# CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
# echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN"
|
||||
# echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
# MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
# echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN"
|
||||
# echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
# MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
# echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN"
|
||||
# echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
# MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
# echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN"
|
||||
# echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
- name: List test files for shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
|
||||
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --list
|
||||
- name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
|
||||
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --workers=3
|
||||
- name: Upload blob report to GitHub Actions Artifacts
|
||||
@@ -253,4 +255,4 @@ jobs:
|
||||
with:
|
||||
name: html-report--attempt-${{ github.run_attempt }}
|
||||
path: playwright-report
|
||||
retention-days: 14
|
||||
retention-days: 14
|
||||
6
.github/workflows/cleanup.yml
vendored
6
.github/workflows/cleanup.yml
vendored
@@ -6,8 +6,8 @@ on:
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Once every hour
|
||||
- cron: "0 15 * * *"
|
||||
# Once every two hours
|
||||
- cron: "0 */2 * * *"
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -36,4 +36,4 @@ jobs:
|
||||
with:
|
||||
node-version: 18.x
|
||||
- run: npm ci
|
||||
- run: node utils/cleanupDBs.js
|
||||
- run: node utils/cleanupDBs.js
|
||||
@@ -406,7 +406,11 @@ body {
|
||||
width: 440px;
|
||||
min-height: 565px;
|
||||
}
|
||||
|
||||
.dataExplorerLoaderforcopyJobs{
|
||||
width: 100%;
|
||||
min-height: 565px;
|
||||
right: 0;
|
||||
}
|
||||
.dataExplorerTabLoaderContainer {
|
||||
left: initial;
|
||||
top: initial;
|
||||
|
||||
36
package-lock.json
generated
36
package-lock.json
generated
@@ -116,8 +116,8 @@
|
||||
"tinykeys": "2.1.0",
|
||||
"underscore": "1.12.1",
|
||||
"utility-types": "3.10.0",
|
||||
"web-vitals": "4.2.4",
|
||||
"uuid": "9.0.0",
|
||||
"web-vitals": "4.2.4",
|
||||
"zustand": "3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -15191,6 +15191,8 @@
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
@@ -17899,6 +17901,8 @@
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
@@ -27702,21 +27706,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/jwa": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
|
||||
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "1.0.1",
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/jws": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
|
||||
"integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^1.4.1",
|
||||
"jwa": "^1.4.2",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
@@ -27763,19 +27769,23 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "1.0.1",
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.0",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.0",
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -11,8 +11,8 @@ export default defineConfig({
|
||||
reporter: process.env.CI ? "blob" : "html",
|
||||
timeout: 10 * 60 * 1000,
|
||||
use: {
|
||||
trace: "off",
|
||||
video: "off",
|
||||
trace: "retain-on-failure",
|
||||
video: "retain-on-failure",
|
||||
screenshot: "on",
|
||||
testIdAttribute: "data-test",
|
||||
contextOptions: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Overlay, Spinner, SpinnerSize } from "@fluentui/react";
|
||||
import { useThemeStore } from "hooks/useTheme";
|
||||
import React from "react";
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
@@ -7,6 +8,7 @@ interface LoadingOverlayProps {
|
||||
}
|
||||
|
||||
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading, label }) => {
|
||||
const isDarkMode = useThemeStore((state) => state.isDarkMode);
|
||||
if (!isLoading) {
|
||||
return null;
|
||||
}
|
||||
@@ -16,7 +18,7 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading, label }) =>
|
||||
data-test="loading-overlay"
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: "rgba(255,255,255,0.9)",
|
||||
backgroundColor: isDarkMode ? "rgba(32, 31, 30, 0.9)" : "rgba(255,255,255,0.9)",
|
||||
zIndex: 9999,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -24,7 +26,11 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading, label }) =>
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Spinner size={SpinnerSize.large} label={label} styles={{ label: { fontWeight: 600 } }} />
|
||||
<Spinner
|
||||
size={SpinnerSize.large}
|
||||
label={label}
|
||||
styles={{ label: { fontWeight: 600, color: isDarkMode ? "#ffffff" : "#323130" } }}
|
||||
/>
|
||||
</Overlay>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,3 +11,14 @@
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Override dark mode inherit for pagination icons */
|
||||
body.isDarkMode .pager-container .ms-Button .ms-Button-icon,
|
||||
body.isDarkMode .pager-container .ms-Button i {
|
||||
color: var(--colorBrandForeground1);
|
||||
}
|
||||
|
||||
body.isDarkMode .pager-container .ms-Button:disabled .ms-Button-icon,
|
||||
body.isDarkMode .pager-container .ms-Button:disabled i {
|
||||
color: var(--colorNeutralForegroundDisabled);
|
||||
}
|
||||
@@ -59,7 +59,7 @@ const Pager: React.FC<PagerProps> = ({
|
||||
return (
|
||||
<div className={className || "pager-container"}>
|
||||
{showItemCount && (
|
||||
<Text>
|
||||
<Text className="themeText">
|
||||
Showing {startIndex + 1} - {endIndex} of {totalCount} items
|
||||
</Text>
|
||||
)}
|
||||
@@ -82,7 +82,7 @@ const Pager: React.FC<PagerProps> = ({
|
||||
disabled={disabled || currentPage === 1}
|
||||
styles={iconButtonStyles}
|
||||
/>
|
||||
<Text>
|
||||
<Text className="themeText">
|
||||
Page {currentPage} of {totalPages}
|
||||
</Text>
|
||||
<IconButton
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SqlStoredProcedureCreateUpdateParameters,
|
||||
SqlStoredProcedureResource,
|
||||
} from "../../Utils/arm/generatedClients/cosmos/types";
|
||||
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { client } from "../CosmosClient";
|
||||
import { handleError } from "../ErrorHandlingUtils";
|
||||
|
||||
@@ -20,6 +20,7 @@ export async function createStoredProcedure(
|
||||
): Promise<StoredProcedureDefinition & Resource> {
|
||||
const clearMessage = logConsoleProgress(`Creating stored procedure ${storedProcedure.id}`);
|
||||
try {
|
||||
let resource: StoredProcedureDefinition & Resource;
|
||||
if (
|
||||
userContext.authType === AuthType.AAD &&
|
||||
!userContext.features.enableSDKoperations &&
|
||||
@@ -60,14 +61,16 @@ export async function createStoredProcedure(
|
||||
storedProcedure.id,
|
||||
createSprocParams,
|
||||
);
|
||||
return rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource);
|
||||
resource = rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource);
|
||||
} else {
|
||||
const response = await client()
|
||||
.database(databaseId)
|
||||
.container(collectionId)
|
||||
.scripts.storedProcedures.create(storedProcedure);
|
||||
resource = response.resource;
|
||||
}
|
||||
|
||||
const response = await client()
|
||||
.database(databaseId)
|
||||
.container(collectionId)
|
||||
.scripts.storedProcedures.create(storedProcedure);
|
||||
return response?.resource;
|
||||
logConsoleInfo(`Successfully created stored procedure ${storedProcedure.id}`);
|
||||
return resource;
|
||||
} catch (error) {
|
||||
handleError(error, "CreateStoredProcedure", `Error while creating stored procedure ${storedProcedure.id}`);
|
||||
throw error;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AuthType } from "../../AuthType";
|
||||
import { userContext } from "../../UserContext";
|
||||
import { createUpdateSqlTrigger, getSqlTrigger } from "../../Utils/arm/generatedClients/cosmos/sqlResources";
|
||||
import { SqlTriggerCreateUpdateParameters, SqlTriggerResource } from "../../Utils/arm/generatedClients/cosmos/types";
|
||||
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { client } from "../CosmosClient";
|
||||
import { handleError } from "../ErrorHandlingUtils";
|
||||
|
||||
@@ -14,6 +14,7 @@ export async function createTrigger(
|
||||
): Promise<TriggerDefinition | SqlTriggerResource> {
|
||||
const clearMessage = logConsoleProgress(`Creating trigger ${trigger.id}`);
|
||||
try {
|
||||
let resource: SqlTriggerResource | TriggerDefinition;
|
||||
if (
|
||||
userContext.authType === AuthType.AAD &&
|
||||
!userContext.features.enableSDKoperations &&
|
||||
@@ -52,14 +53,16 @@ export async function createTrigger(
|
||||
trigger.id,
|
||||
createTriggerParams,
|
||||
);
|
||||
return rpResponse && rpResponse.properties?.resource;
|
||||
resource = rpResponse && rpResponse.properties?.resource;
|
||||
} else {
|
||||
const sdkResponse = await client()
|
||||
.database(databaseId)
|
||||
.container(collectionId)
|
||||
.scripts.triggers.create(trigger as unknown as TriggerDefinition); // TODO: TypeScript does not like the SQL SDK trigger type
|
||||
resource = sdkResponse.resource;
|
||||
}
|
||||
|
||||
const response = await client()
|
||||
.database(databaseId)
|
||||
.container(collectionId)
|
||||
.scripts.triggers.create(trigger as unknown as TriggerDefinition); // TODO: TypeScript does not like the SQL SDK trigger type
|
||||
return response.resource;
|
||||
logConsoleInfo(`Successfully created trigger ${trigger.id}`);
|
||||
return resource;
|
||||
} catch (error) {
|
||||
handleError(error, "CreateTrigger", `Error while creating trigger ${trigger.id}`);
|
||||
throw error;
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SqlUserDefinedFunctionCreateUpdateParameters,
|
||||
SqlUserDefinedFunctionResource,
|
||||
} from "../../Utils/arm/generatedClients/cosmos/types";
|
||||
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
|
||||
import { client } from "../CosmosClient";
|
||||
import { handleError } from "../ErrorHandlingUtils";
|
||||
|
||||
@@ -20,6 +20,7 @@ export async function createUserDefinedFunction(
|
||||
): Promise<UserDefinedFunctionDefinition & Resource> {
|
||||
const clearMessage = logConsoleProgress(`Creating user defined function ${userDefinedFunction.id}`);
|
||||
try {
|
||||
let resource: UserDefinedFunctionDefinition & Resource;
|
||||
if (
|
||||
userContext.authType === AuthType.AAD &&
|
||||
!userContext.features.enableSDKoperations &&
|
||||
@@ -60,14 +61,17 @@ export async function createUserDefinedFunction(
|
||||
userDefinedFunction.id,
|
||||
createUDFParams,
|
||||
);
|
||||
return rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource);
|
||||
}
|
||||
|
||||
const response = await client()
|
||||
.database(databaseId)
|
||||
.container(collectionId)
|
||||
.scripts.userDefinedFunctions.create(userDefinedFunction);
|
||||
return response?.resource;
|
||||
resource = rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource);
|
||||
} else {
|
||||
const response = await client()
|
||||
.database(databaseId)
|
||||
.container(collectionId)
|
||||
.scripts.userDefinedFunctions.create(userDefinedFunction);
|
||||
resource = response.resource;
|
||||
}
|
||||
logConsoleInfo(`Successfully created user defined function ${userDefinedFunction.id}`);
|
||||
return resource;
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
|
||||
@@ -28,6 +28,7 @@ export async function deleteStoredProcedure(
|
||||
} else {
|
||||
await client().database(databaseId).container(collectionId).scripts.storedProcedure(storedProcedureId).delete();
|
||||
}
|
||||
logConsoleProgress(`Successfully deleted stored procedure ${storedProcedureId}`);
|
||||
} catch (error) {
|
||||
handleError(error, "DeleteStoredProcedure", `Error while deleting stored procedure ${storedProcedureId}`);
|
||||
throw error;
|
||||
|
||||
@@ -24,6 +24,7 @@ export async function deleteTrigger(databaseId: string, collectionId: string, tr
|
||||
} else {
|
||||
await client().database(databaseId).container(collectionId).scripts.trigger(triggerId).delete();
|
||||
}
|
||||
logConsoleProgress(`Successfully deleted trigger ${triggerId}`);
|
||||
} catch (error) {
|
||||
handleError(error, "DeleteTrigger", `Error while deleting trigger ${triggerId}`);
|
||||
throw error;
|
||||
|
||||
@@ -24,6 +24,7 @@ export async function deleteUserDefinedFunction(databaseId: string, collectionId
|
||||
} else {
|
||||
await client().database(databaseId).container(collectionId).scripts.userDefinedFunction(id).delete();
|
||||
}
|
||||
logConsoleProgress(`Successfully deleted user defined function ${id}`);
|
||||
} catch (error) {
|
||||
handleError(error, "DeleteUserDefinedFunction", `Error while deleting user defined function ${id}`);
|
||||
throw error;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import { getDataTransferJobs } from "../../../Common/dataAccess/dataTransfers";
|
||||
import * as Logger from "../../../Common/Logger";
|
||||
import { useSidePanel } from "../../../hooks/useSidePanel";
|
||||
import * as dataTransferService from "../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs";
|
||||
@@ -30,6 +31,7 @@ jest.mock("../../../Common/Logger");
|
||||
jest.mock("../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs");
|
||||
jest.mock("../MonitorCopyJobs/MonitorCopyJobRefState");
|
||||
jest.mock("../CopyJobUtils");
|
||||
jest.mock("../../../Common/dataAccess/dataTransfers");
|
||||
|
||||
describe("CopyJobActions", () => {
|
||||
beforeEach(() => {
|
||||
@@ -41,10 +43,8 @@ describe("CopyJobActions", () => {
|
||||
const mockExplorer = {} as Explorer;
|
||||
const mockSetPanelHasConsole = jest.fn();
|
||||
const mockOpenSidePanel = jest.fn();
|
||||
const mockSetLightDismiss = jest.fn();
|
||||
|
||||
(useSidePanel.getState as jest.Mock).mockReturnValue({
|
||||
setLightDismiss: mockSetLightDismiss,
|
||||
setPanelHasConsole: mockSetPanelHasConsole,
|
||||
openSidePanel: mockOpenSidePanel,
|
||||
});
|
||||
@@ -52,7 +52,6 @@ describe("CopyJobActions", () => {
|
||||
openCreateCopyJobPanel(mockExplorer);
|
||||
|
||||
expect(mockSetPanelHasConsole).toHaveBeenCalledWith(false);
|
||||
expect(mockSetLightDismiss).toHaveBeenCalledWith(false);
|
||||
expect(mockOpenSidePanel).toHaveBeenCalledWith(expect.any(String), expect.any(Object), "650px");
|
||||
});
|
||||
|
||||
@@ -62,7 +61,6 @@ describe("CopyJobActions", () => {
|
||||
|
||||
(useSidePanel.getState as jest.Mock).mockReturnValue({
|
||||
setPanelHasConsole: jest.fn(),
|
||||
setLightDismiss: jest.fn(),
|
||||
openSidePanel: mockOpenSidePanel,
|
||||
});
|
||||
|
||||
@@ -98,11 +96,9 @@ describe("CopyJobActions", () => {
|
||||
};
|
||||
|
||||
const mockSetPanelHasConsole = jest.fn();
|
||||
const mockSetLightDismiss = jest.fn();
|
||||
const mockOpenSidePanel = jest.fn();
|
||||
|
||||
(useSidePanel.getState as jest.Mock).mockReturnValue({
|
||||
setLightDismiss: mockSetLightDismiss,
|
||||
setPanelHasConsole: mockSetPanelHasConsole,
|
||||
openSidePanel: mockOpenSidePanel,
|
||||
});
|
||||
@@ -110,7 +106,6 @@ describe("CopyJobActions", () => {
|
||||
openCopyJobDetailsPanel(mockJob);
|
||||
|
||||
expect(mockSetPanelHasConsole).toHaveBeenCalledWith(false);
|
||||
expect(mockSetLightDismiss).toHaveBeenCalledWith(true);
|
||||
expect(mockOpenSidePanel).toHaveBeenCalledWith(expect.stringContaining("test-job"), expect.any(Object), "650px");
|
||||
});
|
||||
|
||||
@@ -140,7 +135,6 @@ describe("CopyJobActions", () => {
|
||||
|
||||
(useSidePanel.getState as jest.Mock).mockReturnValue({
|
||||
setPanelHasConsole: jest.fn(),
|
||||
setLightDismiss: jest.fn(),
|
||||
openSidePanel: mockOpenSidePanel,
|
||||
});
|
||||
|
||||
@@ -162,33 +156,31 @@ describe("CopyJobActions", () => {
|
||||
});
|
||||
|
||||
it("should fetch and format copy jobs successfully", async () => {
|
||||
const mockResponse = {
|
||||
value: [
|
||||
{
|
||||
properties: {
|
||||
jobName: "job-1",
|
||||
status: "InProgress",
|
||||
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
|
||||
processedCount: 50,
|
||||
totalCount: 100,
|
||||
mode: "online",
|
||||
duration: "01:30:45",
|
||||
source: {
|
||||
component: "CosmosDBSql",
|
||||
databaseName: "source-db",
|
||||
containerName: "source-container",
|
||||
},
|
||||
destination: {
|
||||
component: "CosmosDBSql",
|
||||
databaseName: "target-db",
|
||||
containerName: "target-container",
|
||||
},
|
||||
const mockResponse = [
|
||||
{
|
||||
properties: {
|
||||
jobName: "job-1",
|
||||
status: "InProgress",
|
||||
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
|
||||
processedCount: 50,
|
||||
totalCount: 100,
|
||||
mode: "online",
|
||||
duration: "01:30:45",
|
||||
source: {
|
||||
component: "CosmosDBSql",
|
||||
databaseName: "source-db",
|
||||
containerName: "source-container",
|
||||
},
|
||||
destination: {
|
||||
component: "CosmosDBSql",
|
||||
databaseName: "target-db",
|
||||
containerName: "target-container",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
|
||||
formattedDateTime: "1/1/2025, 10:00:00 AM",
|
||||
timestamp: 1704106800000,
|
||||
@@ -209,38 +201,36 @@ describe("CopyJobActions", () => {
|
||||
});
|
||||
|
||||
it("should filter jobs by CosmosDBSql component", async () => {
|
||||
const mockResponse = {
|
||||
value: [
|
||||
{
|
||||
properties: {
|
||||
jobName: "sql-job",
|
||||
status: "Completed",
|
||||
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
|
||||
processedCount: 100,
|
||||
totalCount: 100,
|
||||
mode: "offline",
|
||||
duration: "02:00:00",
|
||||
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
|
||||
},
|
||||
const mockResponse = [
|
||||
{
|
||||
properties: {
|
||||
jobName: "sql-job",
|
||||
status: "Completed",
|
||||
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
|
||||
processedCount: 100,
|
||||
totalCount: 100,
|
||||
mode: "offline",
|
||||
duration: "02:00:00",
|
||||
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
jobName: "other-job",
|
||||
status: "Completed",
|
||||
lastUpdatedUtcTime: "2025-01-01T11:00:00Z",
|
||||
processedCount: 100,
|
||||
totalCount: 100,
|
||||
mode: "offline",
|
||||
duration: "01:00:00",
|
||||
source: { component: "OtherComponent", databaseName: "db1", containerName: "c1" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
|
||||
},
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
jobName: "other-job",
|
||||
status: "Completed",
|
||||
lastUpdatedUtcTime: "2025-01-01T11:00:00Z",
|
||||
processedCount: 100,
|
||||
totalCount: 100,
|
||||
mode: "offline",
|
||||
duration: "01:00:00",
|
||||
source: { component: "OtherComponent", databaseName: "db1", containerName: "c1" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
|
||||
formattedDateTime: "1/1/2025, 10:00:00 AM",
|
||||
timestamp: 1704106800000,
|
||||
@@ -255,38 +245,36 @@ describe("CopyJobActions", () => {
|
||||
});
|
||||
|
||||
it("should sort jobs by last updated time (newest first)", async () => {
|
||||
const mockResponse = {
|
||||
value: [
|
||||
{
|
||||
properties: {
|
||||
jobName: "older-job",
|
||||
status: "Completed",
|
||||
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
|
||||
processedCount: 100,
|
||||
totalCount: 100,
|
||||
mode: "offline",
|
||||
duration: "01:00:00",
|
||||
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
|
||||
},
|
||||
const mockResponse = [
|
||||
{
|
||||
properties: {
|
||||
jobName: "older-job",
|
||||
status: "Completed",
|
||||
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
|
||||
processedCount: 100,
|
||||
totalCount: 100,
|
||||
mode: "offline",
|
||||
duration: "01:00:00",
|
||||
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
jobName: "newer-job",
|
||||
status: "InProgress",
|
||||
lastUpdatedUtcTime: "2025-01-02T10:00:00Z",
|
||||
processedCount: 50,
|
||||
totalCount: 100,
|
||||
mode: "online",
|
||||
duration: "00:30:00",
|
||||
source: { component: "CosmosDBSql", databaseName: "db3", containerName: "c3" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db4", containerName: "c4" },
|
||||
},
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
jobName: "newer-job",
|
||||
status: "InProgress",
|
||||
lastUpdatedUtcTime: "2025-01-02T10:00:00Z",
|
||||
processedCount: 50,
|
||||
totalCount: 100,
|
||||
mode: "online",
|
||||
duration: "00:30:00",
|
||||
source: { component: "CosmosDBSql", databaseName: "db3", containerName: "c3" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db4", containerName: "c4" },
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
|
||||
formattedDateTime: "1/1/2025, 10:00:00 AM",
|
||||
timestamp: 1704106800000,
|
||||
@@ -301,25 +289,23 @@ describe("CopyJobActions", () => {
|
||||
});
|
||||
|
||||
it("should calculate completion percentage correctly", async () => {
|
||||
const mockResponse = {
|
||||
value: [
|
||||
{
|
||||
properties: {
|
||||
jobName: "job-1",
|
||||
status: "InProgress",
|
||||
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
|
||||
processedCount: 75,
|
||||
totalCount: 100,
|
||||
mode: "online",
|
||||
duration: "01:00:00",
|
||||
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
|
||||
},
|
||||
const mockResponse = [
|
||||
{
|
||||
properties: {
|
||||
jobName: "job-1",
|
||||
status: "InProgress",
|
||||
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
|
||||
processedCount: 75,
|
||||
totalCount: 100,
|
||||
mode: "online",
|
||||
duration: "01:00:00",
|
||||
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
|
||||
formattedDateTime: "1/1/2025, 10:00:00 AM",
|
||||
timestamp: 1704106800000,
|
||||
@@ -333,25 +319,23 @@ describe("CopyJobActions", () => {
|
||||
});
|
||||
|
||||
it("should handle zero total count gracefully", async () => {
|
||||
const mockResponse = {
|
||||
value: [
|
||||
{
|
||||
properties: {
|
||||
jobName: "job-1",
|
||||
status: "Pending",
|
||||
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
|
||||
processedCount: 0,
|
||||
totalCount: 0,
|
||||
mode: "online",
|
||||
duration: "00:00:00",
|
||||
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
|
||||
},
|
||||
const mockResponse = [
|
||||
{
|
||||
properties: {
|
||||
jobName: "job-1",
|
||||
status: "Pending",
|
||||
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
|
||||
processedCount: 0,
|
||||
totalCount: 0,
|
||||
mode: "online",
|
||||
duration: "00:00:00",
|
||||
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
|
||||
formattedDateTime: "1/1/2025, 10:00:00 AM",
|
||||
timestamp: 1704106800000,
|
||||
@@ -369,26 +353,24 @@ describe("CopyJobActions", () => {
|
||||
message: "Error message line 1\r\n\r\nError message line 2",
|
||||
code: "ErrorCode123",
|
||||
};
|
||||
const mockResponse = {
|
||||
value: [
|
||||
{
|
||||
properties: {
|
||||
jobName: "failed-job",
|
||||
status: "Failed",
|
||||
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
|
||||
processedCount: 50,
|
||||
totalCount: 100,
|
||||
mode: "offline",
|
||||
duration: "00:30:00",
|
||||
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
|
||||
error: mockError,
|
||||
},
|
||||
const mockResponse = [
|
||||
{
|
||||
properties: {
|
||||
jobName: "failed-job",
|
||||
status: "Failed",
|
||||
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
|
||||
processedCount: 50,
|
||||
totalCount: 100,
|
||||
mode: "offline",
|
||||
duration: "00:30:00",
|
||||
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
|
||||
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
|
||||
error: mockError,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
|
||||
formattedDateTime: "1/1/2025, 10:00:00 AM",
|
||||
timestamp: 1704106800000,
|
||||
@@ -416,7 +398,7 @@ describe("CopyJobActions", () => {
|
||||
};
|
||||
(global as any).AbortController = jest.fn(() => mockAbortController);
|
||||
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue({ value: [] });
|
||||
(getDataTransferJobs as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
getCopyJobs();
|
||||
expect(mockAbortController.abort).not.toHaveBeenCalled();
|
||||
@@ -426,9 +408,7 @@ describe("CopyJobActions", () => {
|
||||
});
|
||||
|
||||
it("should throw error for invalid response format", async () => {
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue({
|
||||
value: "not-an-array",
|
||||
});
|
||||
(getDataTransferJobs as jest.Mock).mockResolvedValue("not-an-array");
|
||||
|
||||
await expect(getCopyJobs()).rejects.toThrow("Invalid migration job status response: Expected an array of jobs.");
|
||||
});
|
||||
@@ -438,7 +418,7 @@ describe("CopyJobActions", () => {
|
||||
message: "Aborted",
|
||||
content: JSON.stringify({ message: "signal is aborted without reason" }),
|
||||
};
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockRejectedValue(abortError);
|
||||
(getDataTransferJobs as jest.Mock).mockRejectedValue(abortError);
|
||||
|
||||
await expect(getCopyJobs()).rejects.toMatchObject({
|
||||
message: expect.stringContaining("Previous copy job request was cancelled."),
|
||||
@@ -447,7 +427,7 @@ describe("CopyJobActions", () => {
|
||||
|
||||
it("should handle generic errors", async () => {
|
||||
const genericError = new Error("Network error");
|
||||
(dataTransferService.listByDatabaseAccount as jest.Mock).mockRejectedValue(genericError);
|
||||
(getDataTransferJobs as jest.Mock).mockRejectedValue(genericError);
|
||||
|
||||
await expect(getCopyJobs()).rejects.toThrow("Network error");
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import Explorer from "Explorer/Explorer";
|
||||
import React from "react";
|
||||
import { userContext } from "UserContext";
|
||||
import { getDataTransferJobs } from "../../../Common/dataAccess/dataTransfers";
|
||||
import { logError } from "../../../Common/Logger";
|
||||
import { useSidePanel } from "../../../hooks/useSidePanel";
|
||||
import {
|
||||
cancel,
|
||||
complete,
|
||||
create,
|
||||
listByDatabaseAccount,
|
||||
pause,
|
||||
resume,
|
||||
} from "../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs";
|
||||
@@ -34,7 +34,6 @@ import { CopyJobContextState, CopyJobError, CopyJobErrorType, CopyJobType } from
|
||||
export const openCreateCopyJobPanel = (explorer: Explorer) => {
|
||||
const sidePanelState = useSidePanel.getState();
|
||||
sidePanelState.setPanelHasConsole(false);
|
||||
sidePanelState.setLightDismiss(false);
|
||||
sidePanelState.openSidePanel(
|
||||
ContainerCopyMessages.createCopyJobPanelTitle,
|
||||
<CreateCopyJobScreensProvider explorer={explorer} />,
|
||||
@@ -45,7 +44,6 @@ export const openCreateCopyJobPanel = (explorer: Explorer) => {
|
||||
export const openCopyJobDetailsPanel = (job: CopyJobType) => {
|
||||
const sidePanelState = useSidePanel.getState();
|
||||
sidePanelState.setPanelHasConsole(false);
|
||||
sidePanelState.setLightDismiss(true);
|
||||
sidePanelState.openSidePanel(
|
||||
ContainerCopyMessages.copyJobDetailsPanelTitle(job.Name),
|
||||
<CopyJobDetails job={job} />,
|
||||
@@ -65,14 +63,8 @@ export const getCopyJobs = async (): Promise<CopyJobType[]> => {
|
||||
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
|
||||
userContext.databaseAccount?.id || "",
|
||||
);
|
||||
const response = await listByDatabaseAccount(
|
||||
subscriptionId,
|
||||
resourceGroup,
|
||||
accountName,
|
||||
copyJobsAbortController.signal,
|
||||
);
|
||||
const jobs = await getDataTransferJobs(subscriptionId, resourceGroup, accountName, copyJobsAbortController.signal);
|
||||
|
||||
const jobs = response.value || [];
|
||||
if (!Array.isArray(jobs)) {
|
||||
throw new Error("Invalid migration job status response: Expected an array of jobs.");
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("CopyJobCommandBar", () => {
|
||||
|
||||
render(<CopyJobCommandBar explorer={mockExplorer} />);
|
||||
|
||||
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer);
|
||||
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer, false);
|
||||
expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -163,7 +163,7 @@ describe("CopyJobCommandBar", () => {
|
||||
|
||||
render(<CopyJobCommandBar explorer={mockExplorer} />);
|
||||
|
||||
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer);
|
||||
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer, false);
|
||||
expect(mockConvertButton.mock.calls[0][0]).toEqual(mockCommandButtonProps);
|
||||
});
|
||||
|
||||
@@ -175,11 +175,11 @@ describe("CopyJobCommandBar", () => {
|
||||
mockConvertButton.mockReturnValue([]);
|
||||
|
||||
const { rerender } = render(<CopyJobCommandBar explorer={mockExplorer1} />);
|
||||
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer1);
|
||||
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer1, false);
|
||||
|
||||
rerender(<CopyJobCommandBar explorer={mockExplorer2} />);
|
||||
|
||||
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer2);
|
||||
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer2, false);
|
||||
expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import { StyleConstants } from "../../../Common/StyleConstants";
|
||||
import { useThemeStore } from "../../../hooks/useTheme";
|
||||
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
|
||||
import * as CommandBarUtil from "../../Menus/CommandBar/CommandBarUtil";
|
||||
import { getThemeTokens } from "../../Theme/ThemeUtil";
|
||||
import { ContainerCopyProps } from "../Types/CopyJobTypes";
|
||||
import { getCommandBarButtons } from "./Utils";
|
||||
|
||||
const backgroundColor = StyleConstants.BaseLight;
|
||||
const rootStyle = {
|
||||
root: {
|
||||
backgroundColor: backgroundColor,
|
||||
},
|
||||
};
|
||||
|
||||
const CopyJobCommandBar: React.FC<ContainerCopyProps> = ({ explorer }) => {
|
||||
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer);
|
||||
const isDarkMode = useThemeStore((state) => state.isDarkMode);
|
||||
const themeTokens = getThemeTokens(isDarkMode);
|
||||
const backgroundColor = themeTokens.colorNeutralBackground1;
|
||||
|
||||
const rootStyle = {
|
||||
root: {
|
||||
backgroundColor: backgroundColor,
|
||||
},
|
||||
};
|
||||
|
||||
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer, isDarkMode);
|
||||
const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor);
|
||||
|
||||
return (
|
||||
<div className="commandBarContainer">
|
||||
<div className="commandBarContainer" style={{ backgroundColor }}>
|
||||
<FluentCommandBar
|
||||
ariaLabel="Use left and right arrow keys to navigate between commands"
|
||||
styles={rootStyle}
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("CommandBar Utils", () => {
|
||||
|
||||
describe("getCommandBarButtons", () => {
|
||||
it("should return an array of command button props", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
|
||||
expect(buttons).toBeDefined();
|
||||
expect(Array.isArray(buttons)).toBe(true);
|
||||
@@ -58,7 +58,7 @@ describe("CommandBar Utils", () => {
|
||||
});
|
||||
|
||||
it("should include create copy job button", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
const createButton = buttons[0];
|
||||
|
||||
expect(createButton).toBeDefined();
|
||||
@@ -70,7 +70,7 @@ describe("CommandBar Utils", () => {
|
||||
});
|
||||
|
||||
it("should include refresh button", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
const refreshButton = buttons[1];
|
||||
|
||||
expect(refreshButton).toBeDefined();
|
||||
@@ -80,11 +80,11 @@ describe("CommandBar Utils", () => {
|
||||
});
|
||||
|
||||
it("should include feedback button when platform is Portal", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
|
||||
expect(buttons.length).toBe(3);
|
||||
expect(buttons.length).toBe(4);
|
||||
|
||||
const feedbackButton = buttons[2];
|
||||
const feedbackButton = buttons[3];
|
||||
expect(feedbackButton).toBeDefined();
|
||||
expect(feedbackButton.ariaLabel).toBe("Provide feedback on copy jobs");
|
||||
expect(feedbackButton.tooltipText).toBe("Feedback");
|
||||
@@ -105,13 +105,13 @@ describe("CommandBar Utils", () => {
|
||||
}));
|
||||
|
||||
const { getCommandBarButtons: getCommandBarButtonsEmulator } = await import("./Utils");
|
||||
const buttons = getCommandBarButtonsEmulator(mockExplorer);
|
||||
const buttons = getCommandBarButtonsEmulator(mockExplorer, false);
|
||||
|
||||
expect(buttons.length).toBe(2);
|
||||
expect(buttons.length).toBe(3);
|
||||
});
|
||||
|
||||
it("should call openCreateCopyJobPanel when create button is clicked", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
const createButton = buttons[0];
|
||||
|
||||
createButton.onCommandClick({} as React.SyntheticEvent);
|
||||
@@ -121,7 +121,7 @@ describe("CommandBar Utils", () => {
|
||||
});
|
||||
|
||||
it("should call refreshJobList when refresh button is clicked", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
const refreshButton = buttons[1];
|
||||
|
||||
refreshButton.onCommandClick({} as React.SyntheticEvent);
|
||||
@@ -130,8 +130,8 @@ describe("CommandBar Utils", () => {
|
||||
});
|
||||
|
||||
it("should call openContainerCopyFeedbackBlade when feedback button is clicked", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const feedbackButton = buttons[2];
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
const feedbackButton = buttons[3];
|
||||
|
||||
feedbackButton.onCommandClick({} as React.SyntheticEvent);
|
||||
|
||||
@@ -139,7 +139,7 @@ describe("CommandBar Utils", () => {
|
||||
});
|
||||
|
||||
it("should return buttons with correct icon sources", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
|
||||
expect(buttons[0].iconSrc).toBeDefined();
|
||||
expect(buttons[0].iconAlt).toBe("Create Copy Job");
|
||||
@@ -148,7 +148,10 @@ describe("CommandBar Utils", () => {
|
||||
expect(buttons[1].iconAlt).toBe("Refresh");
|
||||
|
||||
expect(buttons[2].iconSrc).toBeDefined();
|
||||
expect(buttons[2].iconAlt).toBe("Feedback");
|
||||
expect(buttons[2].iconAlt).toBe("Dark Theme");
|
||||
|
||||
expect(buttons[3].iconSrc).toBeDefined();
|
||||
expect(buttons[3].iconAlt).toBe("Feedback");
|
||||
});
|
||||
|
||||
it("should handle null MonitorCopyJobsRefState ref gracefully", () => {
|
||||
@@ -157,14 +160,14 @@ describe("CommandBar Utils", () => {
|
||||
return selector(state);
|
||||
});
|
||||
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
const refreshButton = buttons[1];
|
||||
|
||||
expect(() => refreshButton.onCommandClick({} as React.SyntheticEvent)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should set hasPopup to false for all buttons", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
|
||||
buttons.forEach((button) => {
|
||||
expect(button.hasPopup).toBe(false);
|
||||
@@ -172,7 +175,7 @@ describe("CommandBar Utils", () => {
|
||||
});
|
||||
|
||||
it("should set commandButtonLabel to undefined for all buttons", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
|
||||
buttons.forEach((button) => {
|
||||
expect(button.commandButtonLabel).toBeUndefined();
|
||||
@@ -180,7 +183,7 @@ describe("CommandBar Utils", () => {
|
||||
});
|
||||
|
||||
it("should respect disabled state when provided", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
|
||||
buttons.forEach((button) => {
|
||||
expect(button.disabled).toBe(false);
|
||||
@@ -188,7 +191,7 @@ describe("CommandBar Utils", () => {
|
||||
});
|
||||
|
||||
it("should return CommandButtonComponentProps with all required properties", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
|
||||
buttons.forEach((button: CommandButtonComponentProps) => {
|
||||
expect(button).toHaveProperty("iconSrc");
|
||||
@@ -202,18 +205,19 @@ describe("CommandBar Utils", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should maintain button order: create, refresh, feedback", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
it("should maintain button order: create, refresh, themeToggle, feedback", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
|
||||
expect(buttons[0].tooltipText).toBe("Create Copy Job");
|
||||
expect(buttons[1].tooltipText).toBe("Refresh");
|
||||
expect(buttons[2].tooltipText).toBe("Feedback");
|
||||
expect(buttons[2].tooltipText).toBe("Dark Theme");
|
||||
expect(buttons[3].tooltipText).toBe("Feedback");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Button click handlers", () => {
|
||||
it("should execute click handlers without errors", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
|
||||
buttons.forEach((button) => {
|
||||
expect(() => button.onCommandClick({} as React.SyntheticEvent)).not.toThrow();
|
||||
@@ -221,7 +225,7 @@ describe("CommandBar Utils", () => {
|
||||
});
|
||||
|
||||
it("should call correct action for each button", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
|
||||
buttons[0].onCommandClick({} as React.SyntheticEvent);
|
||||
expect(Actions.openCreateCopyJobPanel).toHaveBeenCalledWith(mockExplorer);
|
||||
@@ -229,14 +233,14 @@ describe("CommandBar Utils", () => {
|
||||
buttons[1].onCommandClick({} as React.SyntheticEvent);
|
||||
expect(mockRefreshJobList).toHaveBeenCalled();
|
||||
|
||||
buttons[2].onCommandClick({} as React.SyntheticEvent);
|
||||
buttons[3].onCommandClick({} as React.SyntheticEvent);
|
||||
expect(mockOpenContainerCopyFeedbackBlade).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accessibility", () => {
|
||||
it("should have aria labels for all buttons", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
|
||||
buttons.forEach((button) => {
|
||||
expect(button.ariaLabel).toBeDefined();
|
||||
@@ -246,7 +250,7 @@ describe("CommandBar Utils", () => {
|
||||
});
|
||||
|
||||
it("should have tooltip text for all buttons", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
|
||||
buttons.forEach((button) => {
|
||||
expect(button.tooltipText).toBeDefined();
|
||||
@@ -256,7 +260,7 @@ describe("CommandBar Utils", () => {
|
||||
});
|
||||
|
||||
it("should have icon alt text for all buttons", () => {
|
||||
const buttons = getCommandBarButtons(mockExplorer);
|
||||
const buttons = getCommandBarButtons(mockExplorer, false);
|
||||
|
||||
buttons.forEach((button) => {
|
||||
expect(button.iconAlt).toBeDefined();
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import AddIcon from "../../../../images/Add.svg";
|
||||
import FeedbackIcon from "../../../../images/Feedback-Command.svg";
|
||||
import MoonIcon from "../../../../images/MoonIcon.svg";
|
||||
import RefreshIcon from "../../../../images/refresh-cosmos.svg";
|
||||
import SunIcon from "../../../../images/SunIcon.svg";
|
||||
import { configContext, Platform } from "../../../ConfigContext";
|
||||
import { useThemeStore } from "../../../hooks/useTheme";
|
||||
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
|
||||
import Explorer from "../../Explorer";
|
||||
import * as Actions from "../Actions/CopyJobActions";
|
||||
@@ -9,7 +12,7 @@ import ContainerCopyMessages from "../ContainerCopyMessages";
|
||||
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
|
||||
import { CopyJobCommandBarBtnType } from "../Types/CopyJobTypes";
|
||||
|
||||
function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] {
|
||||
function getCopyJobBtns(explorer: Explorer, isDarkMode: boolean): CopyJobCommandBarBtnType[] {
|
||||
const monitorCopyJobsRef = MonitorCopyJobsRefState((state) => state.ref);
|
||||
const buttons: CopyJobCommandBarBtnType[] = [
|
||||
{
|
||||
@@ -26,7 +29,15 @@ function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] {
|
||||
ariaLabel: ContainerCopyMessages.refreshButtonAriaLabel,
|
||||
onClick: () => monitorCopyJobsRef?.refreshJobList(),
|
||||
},
|
||||
{
|
||||
key: "themeToggle",
|
||||
iconSrc: isDarkMode ? SunIcon : MoonIcon,
|
||||
label: isDarkMode ? "Light Theme" : "Dark Theme",
|
||||
ariaLabel: isDarkMode ? "Switch to Light Theme" : "Switch to Dark Theme",
|
||||
onClick: () => useThemeStore.getState().toggleTheme(),
|
||||
},
|
||||
];
|
||||
|
||||
if (configContext.platform === Platform.Portal) {
|
||||
buttons.push({
|
||||
key: "feedback",
|
||||
@@ -54,6 +65,6 @@ function btnMapper(config: CopyJobCommandBarBtnType): CommandButtonComponentProp
|
||||
};
|
||||
}
|
||||
|
||||
export function getCommandBarButtons(explorer: Explorer): CommandButtonComponentProps[] {
|
||||
return getCopyJobBtns(explorer).map(btnMapper);
|
||||
export function getCommandBarButtons(explorer: Explorer, isDarkMode: boolean): CommandButtonComponentProps[] {
|
||||
return getCopyJobBtns(explorer, isDarkMode).map(btnMapper);
|
||||
}
|
||||
|
||||
@@ -25,18 +25,7 @@ export default {
|
||||
subscriptionDropdownPlaceholder: "Select a subscription",
|
||||
sourceAccountDropdownLabel: "Account",
|
||||
sourceAccountDropdownPlaceholder: "Select an account",
|
||||
migrationTypeOptions: {
|
||||
offline: {
|
||||
title: "Offline mode",
|
||||
description:
|
||||
"Offline container copy jobs let you copy data from a source container to a destination Cosmos DB container for supported APIs. To ensure data integrity between the source and destination, we recommend stopping updates on the source container before creating the copy job. Learn more about [offline copy jobs](https://learn.microsoft.com/azure/cosmos-db/how-to-container-copy?tabs=offline-copy&pivots=api-nosql).",
|
||||
},
|
||||
online: {
|
||||
title: "Online mode",
|
||||
description:
|
||||
"Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the [All Versions and Delete](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about [online copy jobs](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started).",
|
||||
},
|
||||
},
|
||||
migrationTypeCheckboxLabel: "Copy container in offline mode",
|
||||
|
||||
// Select Source and Target Containers Screen
|
||||
selectSourceAndTargetContainersDescription:
|
||||
@@ -184,10 +173,5 @@ export default {
|
||||
Skipped: "Cancelled",
|
||||
Cancelled: "Cancelled",
|
||||
},
|
||||
dialog: {
|
||||
heading: "",
|
||||
confirmButtonText: "Confirm",
|
||||
cancelButtonText: "Cancel",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,7 +12,12 @@ import useToggle from "./hooks/useToggle";
|
||||
const managedIdentityTooltip = (
|
||||
<Text>
|
||||
{ContainerCopyMessages.addManagedIdentity.tooltip.content}
|
||||
<Link href={ContainerCopyMessages.addManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
|
||||
<Link
|
||||
style={{ color: "var(--colorBrandForeground1)" }}
|
||||
href={ContainerCopyMessages.addManagedIdentity.tooltip.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{ContainerCopyMessages.addManagedIdentity.tooltip.hrefText}
|
||||
</Link>
|
||||
</Text>
|
||||
@@ -26,7 +31,7 @@ const AddManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
|
||||
|
||||
return (
|
||||
<Stack className="addManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
|
||||
<Text>
|
||||
<Text className="themeText">
|
||||
{ContainerCopyMessages.addManagedIdentity.description} 
|
||||
<Link href={ContainerCopyMessages.addManagedIdentity.descriptionHref} target="_blank" rel="noopener noreferrer">
|
||||
{ContainerCopyMessages.addManagedIdentity.descriptionHrefText}
|
||||
|
||||
@@ -13,7 +13,12 @@ import useToggle from "./hooks/useToggle";
|
||||
const TooltipContent = (
|
||||
<Text>
|
||||
{ContainerCopyMessages.readPermissionAssigned.tooltip.content}
|
||||
<Link href={ContainerCopyMessages.readPermissionAssigned.tooltip.href} target="_blank" rel="noopener noreferrer">
|
||||
<Link
|
||||
style={{ color: "var(--colorBrandForeground1)" }}
|
||||
href={ContainerCopyMessages.readPermissionAssigned.tooltip.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{ContainerCopyMessages.readPermissionAssigned.tooltip.hrefText}
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
@@ -48,8 +48,8 @@ const PermissionGroup: React.FC<PermissionGroupConfig> = ({ id, title, descripti
|
||||
tokens={{ childrenGap: 15 }}
|
||||
styles={{
|
||||
root: {
|
||||
background: "#fafafa",
|
||||
border: "1px solid #e1e1e1",
|
||||
background: "var(--colorNeutralBackground2)",
|
||||
border: "1px solid var(--colorNeutralStroke1)",
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
|
||||
@@ -57,11 +57,11 @@ const PermissionGroup: React.FC<PermissionGroupConfig> = ({ id, title, descripti
|
||||
}}
|
||||
>
|
||||
<Stack tokens={{ childrenGap: 5 }}>
|
||||
<Text variant="medium" style={{ fontWeight: 600 }}>
|
||||
<Text variant="medium" style={{ fontWeight: 600, color: "var(--colorNeutralForeground1)" }}>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text variant="small" styles={{ root: { color: "#605E5C" } }}>
|
||||
<Text variant="small" styles={{ root: { color: "var(--colorNeutralForeground2)" } }}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
@@ -105,7 +105,7 @@ const AssignPermissions = () => {
|
||||
className="assignPermissionsContainer"
|
||||
tokens={{ childrenGap: 20 }}
|
||||
>
|
||||
<Text variant="medium">
|
||||
<Text variant="medium" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
{isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online
|
||||
? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription(
|
||||
copyJobState?.source?.account?.name || "",
|
||||
|
||||
@@ -12,7 +12,12 @@ import useToggle from "./hooks/useToggle";
|
||||
const managedIdentityTooltip = (
|
||||
<Text>
|
||||
{ContainerCopyMessages.defaultManagedIdentity.tooltip.content}
|
||||
<Link href={ContainerCopyMessages.defaultManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
|
||||
<Link
|
||||
style={{ color: "var(--colorBrandForeground1)" }}
|
||||
href={ContainerCopyMessages.defaultManagedIdentity.tooltip.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{ContainerCopyMessages.defaultManagedIdentity.tooltip.hrefText}
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
@@ -13,7 +13,12 @@ import InfoTooltip from "../Components/InfoTooltip";
|
||||
const tooltipContent = (
|
||||
<Text>
|
||||
{ContainerCopyMessages.pointInTimeRestore.tooltip.content}
|
||||
<Link href={ContainerCopyMessages.pointInTimeRestore.tooltip.href} target="_blank" rel="noopener noreferrer">
|
||||
<Link
|
||||
style={{ color: "var(--colorBrandForeground1)" }}
|
||||
href={ContainerCopyMessages.pointInTimeRestore.tooltip.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{ContainerCopyMessages.pointInTimeRestore.tooltip.hrefText}
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
@@ -5,7 +5,7 @@ exports[`AddManagedIdentity Snapshot Tests renders initial state correctly 1`] =
|
||||
class="ms-Stack addManagedIdentityContainer css-109"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
class="themeText css-110"
|
||||
>
|
||||
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.
|
||||
|
||||
@@ -93,7 +93,7 @@ exports[`AddManagedIdentity Snapshot Tests renders loading state 1`] = `
|
||||
class="ms-Stack addManagedIdentityContainer css-109"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
class="themeText css-110"
|
||||
>
|
||||
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.
|
||||
|
||||
@@ -196,13 +196,13 @@ exports[`AddManagedIdentity Snapshot Tests renders loading state 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="css-124"
|
||||
class="themeText css-124"
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Enable system assigned managed identity
|
||||
</span>
|
||||
<span
|
||||
class="css-110"
|
||||
class="themeText css-110"
|
||||
>
|
||||
Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button.
|
||||
</span>
|
||||
@@ -265,7 +265,7 @@ exports[`AddManagedIdentity Snapshot Tests renders with toggle on and popover vi
|
||||
class="ms-Stack addManagedIdentityContainer css-109"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
class="themeText css-110"
|
||||
>
|
||||
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.
|
||||
|
||||
@@ -351,13 +351,13 @@ exports[`AddManagedIdentity Snapshot Tests renders with toggle on and popover vi
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<span
|
||||
class="css-124"
|
||||
class="themeText css-124"
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Enable system assigned managed identity
|
||||
</span>
|
||||
<span
|
||||
class="css-110"
|
||||
class="themeText css-110"
|
||||
>
|
||||
Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button.
|
||||
</span>
|
||||
|
||||
@@ -23,10 +23,10 @@ const PopoverContainer: React.FC<PopoverContainerProps> = React.memo(
|
||||
style={{ maxWidth: 450 }}
|
||||
>
|
||||
<LoadingOverlay isLoading={isLoading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
|
||||
<Text variant="mediumPlus" style={{ fontWeight: 600 }}>
|
||||
<Text variant="mediumPlus" className="themeText" style={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text>{children}</Text>
|
||||
<Text className="themeText">{children}</Text>
|
||||
<Stack horizontal tokens={{ childrenGap: 20 }}>
|
||||
<PrimaryButton text={"Yes"} onClick={onPrimary} disabled={isLoading} />
|
||||
<DefaultButton text="No" onClick={onCancel} disabled={isLoading} />
|
||||
|
||||
@@ -8,11 +8,11 @@ exports[`PopoverMessage Component Edge Cases should handle empty string title 1`
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
class="themeText css-110"
|
||||
style="font-weight: 600;"
|
||||
/>
|
||||
<span
|
||||
class="css-111"
|
||||
class="themeText css-111"
|
||||
>
|
||||
<div>
|
||||
Test content
|
||||
@@ -76,7 +76,7 @@ exports[`PopoverMessage Component Edge Cases should handle null children 1`] = `
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
class="themeText css-110"
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Test Title
|
||||
@@ -139,7 +139,7 @@ exports[`PopoverMessage Component Edge Cases should handle undefined children 1`
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
class="themeText css-110"
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Test Title
|
||||
@@ -202,13 +202,13 @@ exports[`PopoverMessage Component Edge Cases should handle very long title 1`] =
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
class="themeText css-110"
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
This is a very long title that might cause layout issues or text wrapping in the popover component
|
||||
</span>
|
||||
<span
|
||||
class="css-111"
|
||||
class="themeText css-111"
|
||||
>
|
||||
<div>
|
||||
Test content
|
||||
@@ -274,13 +274,13 @@ exports[`PopoverMessage Component Rendering should render correctly when visible
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
class="themeText css-110"
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Test Title
|
||||
</span>
|
||||
<span
|
||||
class="css-111"
|
||||
class="themeText css-111"
|
||||
>
|
||||
<div>
|
||||
Test content
|
||||
@@ -344,13 +344,13 @@ exports[`PopoverMessage Component Rendering should render correctly with differe
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
class="themeText css-110"
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Test Title
|
||||
</span>
|
||||
<span
|
||||
class="css-111"
|
||||
class="themeText css-111"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
@@ -419,13 +419,13 @@ exports[`PopoverMessage Component Rendering should render correctly with differe
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
class="themeText css-110"
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Custom Title
|
||||
</span>
|
||||
<span
|
||||
class="css-111"
|
||||
class="themeText css-111"
|
||||
>
|
||||
<div>
|
||||
Test content
|
||||
@@ -493,13 +493,13 @@ exports[`PopoverMessage Component Rendering should render correctly with loading
|
||||
data-testid="loading-overlay"
|
||||
/>
|
||||
<span
|
||||
class="css-110"
|
||||
class="themeText css-110"
|
||||
style="font-weight: 600;"
|
||||
>
|
||||
Test Title
|
||||
</span>
|
||||
<span
|
||||
class="css-111"
|
||||
class="themeText css-111"
|
||||
>
|
||||
<div>
|
||||
Test content
|
||||
|
||||
@@ -41,7 +41,7 @@ const AddCollectionPanelWrapper: React.FunctionComponent<AddCollectionPanelWrapp
|
||||
return (
|
||||
<Stack className="addCollectionPanelWrapper">
|
||||
<Stack.Item className="addCollectionPanelHeader">
|
||||
<Text>{ContainerCopyMessages.createNewContainerSubHeading}</Text>
|
||||
<Text className="themeText">{ContainerCopyMessages.createNewContainerSubHeading}</Text>
|
||||
</Stack.Item>
|
||||
<Stack.Item className="addCollectionPanelBody">
|
||||
<AddCollectionPanel explorer={explorer} isCopyJobFlow={true} onSubmitSuccess={handleAddCollectionSuccess} />
|
||||
|
||||
@@ -9,7 +9,7 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot 1`]
|
||||
class="ms-StackItem addCollectionPanelHeader css-110"
|
||||
>
|
||||
<span
|
||||
class="css-111"
|
||||
class="themeText css-111"
|
||||
>
|
||||
Select the properties for your container.
|
||||
</span>
|
||||
@@ -50,7 +50,7 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot wit
|
||||
class="ms-StackItem addCollectionPanelHeader css-110"
|
||||
>
|
||||
<span
|
||||
class="css-111"
|
||||
class="themeText css-111"
|
||||
>
|
||||
Select the properties for your container.
|
||||
</span>
|
||||
@@ -91,7 +91,7 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot wit
|
||||
class="ms-StackItem addCollectionPanelHeader css-110"
|
||||
>
|
||||
<span
|
||||
class="css-111"
|
||||
class="themeText css-111"
|
||||
>
|
||||
Select the properties for your container.
|
||||
</span>
|
||||
@@ -132,7 +132,7 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot wit
|
||||
class="ms-StackItem addCollectionPanelHeader css-110"
|
||||
>
|
||||
<span
|
||||
class="css-111"
|
||||
class="themeText css-111"
|
||||
>
|
||||
Select the properties for your container.
|
||||
</span>
|
||||
|
||||
@@ -36,12 +36,16 @@ const PreviewCopyJob: React.FC = () => {
|
||||
<TextField data-test="job-name-textfield" value={jobName} onChange={onJobNameChange} />
|
||||
</FieldRow>
|
||||
<Stack>
|
||||
<Text className="bold">{ContainerCopyMessages.sourceSubscriptionLabel}</Text>
|
||||
<Text data-test="source-subscription-name">{copyJobState.source?.subscription?.displayName}</Text>
|
||||
<Text className="bold themeText">{ContainerCopyMessages.sourceSubscriptionLabel}</Text>
|
||||
<Text data-test="source-subscription-name" className="themeText">
|
||||
{copyJobState.source?.subscription?.displayName}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Text className="bold">{ContainerCopyMessages.sourceAccountLabel}</Text>
|
||||
<Text data-test="source-account-name">{copyJobState.source?.account?.name}</Text>
|
||||
<Text className="bold themeText">{ContainerCopyMessages.sourceAccountLabel}</Text>
|
||||
<Text data-test="source-account-name" className="themeText">
|
||||
{copyJobState.source?.account?.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<DetailsList
|
||||
|
||||
@@ -47,12 +47,12 @@ exports[`PreviewCopyJob should handle special characters in database and contain
|
||||
class="ms-Stack css-124"
|
||||
>
|
||||
<span
|
||||
class="bold css-125"
|
||||
class="bold themeText css-125"
|
||||
>
|
||||
Source subscription
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
class="themeText css-125"
|
||||
data-test="source-subscription-name"
|
||||
>
|
||||
Test Subscription
|
||||
@@ -62,12 +62,12 @@ exports[`PreviewCopyJob should handle special characters in database and contain
|
||||
class="ms-Stack css-124"
|
||||
>
|
||||
<span
|
||||
class="bold css-125"
|
||||
class="bold themeText css-125"
|
||||
>
|
||||
Source account
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
class="themeText css-125"
|
||||
data-test="source-account-name"
|
||||
>
|
||||
test-account
|
||||
@@ -369,12 +369,12 @@ exports[`PreviewCopyJob should render component with cross-subscription setup 1`
|
||||
class="ms-Stack css-124"
|
||||
>
|
||||
<span
|
||||
class="bold css-125"
|
||||
class="bold themeText css-125"
|
||||
>
|
||||
Source subscription
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
class="themeText css-125"
|
||||
data-test="source-subscription-name"
|
||||
>
|
||||
Test Subscription
|
||||
@@ -384,12 +384,12 @@ exports[`PreviewCopyJob should render component with cross-subscription setup 1`
|
||||
class="ms-Stack css-124"
|
||||
>
|
||||
<span
|
||||
class="bold css-125"
|
||||
class="bold themeText css-125"
|
||||
>
|
||||
Source account
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
class="themeText css-125"
|
||||
data-test="source-account-name"
|
||||
>
|
||||
test-account
|
||||
@@ -691,12 +691,12 @@ exports[`PreviewCopyJob should render with default state and empty job name 1`]
|
||||
class="ms-Stack css-124"
|
||||
>
|
||||
<span
|
||||
class="bold css-125"
|
||||
class="bold themeText css-125"
|
||||
>
|
||||
Source subscription
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
class="themeText css-125"
|
||||
data-test="source-subscription-name"
|
||||
>
|
||||
Test Subscription
|
||||
@@ -706,12 +706,12 @@ exports[`PreviewCopyJob should render with default state and empty job name 1`]
|
||||
class="ms-Stack css-124"
|
||||
>
|
||||
<span
|
||||
class="bold css-125"
|
||||
class="bold themeText css-125"
|
||||
>
|
||||
Source account
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
class="themeText css-125"
|
||||
data-test="source-account-name"
|
||||
>
|
||||
test-account
|
||||
@@ -1013,12 +1013,12 @@ exports[`PreviewCopyJob should render with long subscription and account names 1
|
||||
class="ms-Stack css-124"
|
||||
>
|
||||
<span
|
||||
class="bold css-125"
|
||||
class="bold themeText css-125"
|
||||
>
|
||||
Source subscription
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
class="themeText css-125"
|
||||
data-test="source-subscription-name"
|
||||
>
|
||||
This is a very long subscription name that might cause display issues if not handled properly
|
||||
@@ -1028,12 +1028,12 @@ exports[`PreviewCopyJob should render with long subscription and account names 1
|
||||
class="ms-Stack css-124"
|
||||
>
|
||||
<span
|
||||
class="bold css-125"
|
||||
class="bold themeText css-125"
|
||||
>
|
||||
Source account
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
class="themeText css-125"
|
||||
data-test="source-account-name"
|
||||
>
|
||||
this-is-a-very-long-database-account-name-that-might-cause-display-issues
|
||||
@@ -1335,12 +1335,12 @@ exports[`PreviewCopyJob should render with missing source account information 1`
|
||||
class="ms-Stack css-124"
|
||||
>
|
||||
<span
|
||||
class="bold css-125"
|
||||
class="bold themeText css-125"
|
||||
>
|
||||
Source subscription
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
class="themeText css-125"
|
||||
data-test="source-subscription-name"
|
||||
>
|
||||
Test Subscription
|
||||
@@ -1350,7 +1350,7 @@ exports[`PreviewCopyJob should render with missing source account information 1`
|
||||
class="ms-Stack css-124"
|
||||
>
|
||||
<span
|
||||
class="bold css-125"
|
||||
class="bold themeText css-125"
|
||||
>
|
||||
Source account
|
||||
</span>
|
||||
@@ -1651,7 +1651,7 @@ exports[`PreviewCopyJob should render with missing source subscription informati
|
||||
class="ms-Stack css-124"
|
||||
>
|
||||
<span
|
||||
class="bold css-125"
|
||||
class="bold themeText css-125"
|
||||
>
|
||||
Source subscription
|
||||
</span>
|
||||
@@ -1660,12 +1660,12 @@ exports[`PreviewCopyJob should render with missing source subscription informati
|
||||
class="ms-Stack css-124"
|
||||
>
|
||||
<span
|
||||
class="bold css-125"
|
||||
class="bold themeText css-125"
|
||||
>
|
||||
Source account
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
class="themeText css-125"
|
||||
data-test="source-account-name"
|
||||
>
|
||||
test-account
|
||||
@@ -1967,12 +1967,12 @@ exports[`PreviewCopyJob should render with online migration type 1`] = `
|
||||
class="ms-Stack css-124"
|
||||
>
|
||||
<span
|
||||
class="bold css-125"
|
||||
class="bold themeText css-125"
|
||||
>
|
||||
Source subscription
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
class="themeText css-125"
|
||||
data-test="source-subscription-name"
|
||||
>
|
||||
Test Subscription
|
||||
@@ -1982,12 +1982,12 @@ exports[`PreviewCopyJob should render with online migration type 1`] = `
|
||||
class="ms-Stack css-124"
|
||||
>
|
||||
<span
|
||||
class="bold css-125"
|
||||
class="bold themeText css-125"
|
||||
>
|
||||
Source account
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
class="themeText css-125"
|
||||
data-test="source-account-name"
|
||||
>
|
||||
test-account
|
||||
@@ -2289,12 +2289,12 @@ exports[`PreviewCopyJob should render with pre-filled job name 1`] = `
|
||||
class="ms-Stack css-124"
|
||||
>
|
||||
<span
|
||||
class="bold css-125"
|
||||
class="bold themeText css-125"
|
||||
>
|
||||
Source subscription
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
class="themeText css-125"
|
||||
data-test="source-subscription-name"
|
||||
>
|
||||
Test Subscription
|
||||
@@ -2304,12 +2304,12 @@ exports[`PreviewCopyJob should render with pre-filled job name 1`] = `
|
||||
class="ms-Stack css-124"
|
||||
>
|
||||
<span
|
||||
class="bold css-125"
|
||||
class="bold themeText css-125"
|
||||
>
|
||||
Source account
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
class="themeText css-125"
|
||||
data-test="source-account-name"
|
||||
>
|
||||
test-account
|
||||
@@ -2611,12 +2611,12 @@ exports[`PreviewCopyJob should render with undefined database and container name
|
||||
class="ms-Stack css-124"
|
||||
>
|
||||
<span
|
||||
class="bold css-125"
|
||||
class="bold themeText css-125"
|
||||
>
|
||||
Source subscription
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
class="themeText css-125"
|
||||
data-test="source-subscription-name"
|
||||
>
|
||||
Test Subscription
|
||||
@@ -2626,12 +2626,12 @@ exports[`PreviewCopyJob should render with undefined database and container name
|
||||
class="ms-Stack css-124"
|
||||
>
|
||||
<span
|
||||
class="bold css-125"
|
||||
class="bold themeText css-125"
|
||||
>
|
||||
Source account
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
class="themeText css-125"
|
||||
data-test="source-account-name"
|
||||
>
|
||||
test-account
|
||||
|
||||
@@ -54,6 +54,7 @@ export const AccountDropdown: React.FC<AccountDropdownProps> = () => {
|
||||
const currentAccountId = copyJobState?.source?.account?.id;
|
||||
const predefinedAccountId = normalizeAccountId(userContext.databaseAccount?.id);
|
||||
const selectedAccountId = currentAccountId || predefinedAccountId;
|
||||
|
||||
const targetAccount: DatabaseAccount | null =
|
||||
sqlApiOnlyAccounts.find((account) => account.id === selectedAccountId) || null;
|
||||
updateCopyJobState(targetAccount || sqlApiOnlyAccounts[0]);
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
|
||||
import { MigrationType } from "./MigrationType";
|
||||
|
||||
jest.mock("../../../../Context/CopyJobContext", () => ({
|
||||
useCopyJobContext: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("MigrationType", () => {
|
||||
const mockSetCopyJobState = jest.fn();
|
||||
|
||||
const defaultContextValue = {
|
||||
copyJobState: {
|
||||
jobName: "",
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
source: {
|
||||
subscription: null as any,
|
||||
account: null as any,
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
},
|
||||
target: {
|
||||
subscriptionId: "",
|
||||
account: null as any,
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
},
|
||||
sourceReadAccessFromTarget: false,
|
||||
},
|
||||
setCopyJobState: mockSetCopyJobState,
|
||||
flow: { currentScreen: "selectAccount" },
|
||||
setFlow: jest.fn(),
|
||||
contextError: "",
|
||||
setContextError: jest.fn(),
|
||||
explorer: {} as any,
|
||||
resetCopyJobState: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue(defaultContextValue);
|
||||
});
|
||||
|
||||
describe("Component Rendering", () => {
|
||||
it("should render migration type component with radio buttons", () => {
|
||||
const { container } = render(<MigrationType />);
|
||||
|
||||
expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
|
||||
expect(screen.getByRole("radiogroup")).toBeInTheDocument();
|
||||
|
||||
const offlineRadio = screen.getByRole("radio", {
|
||||
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
});
|
||||
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
|
||||
|
||||
expect(offlineRadio).toBeInTheDocument();
|
||||
expect(onlineRadio).toBeInTheDocument();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render with online mode selected by default", () => {
|
||||
render(<MigrationType />);
|
||||
|
||||
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
|
||||
const offlineRadio = screen.getByRole("radio", {
|
||||
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
});
|
||||
|
||||
expect(onlineRadio).toBeChecked();
|
||||
expect(offlineRadio).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should render with offline mode selected when state is offline", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
render(<MigrationType />);
|
||||
|
||||
const offlineRadio = screen.getByRole("radio", {
|
||||
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
});
|
||||
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
|
||||
|
||||
expect(offlineRadio).toBeChecked();
|
||||
expect(onlineRadio).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Descriptions and Learn More Links", () => {
|
||||
it("should render online description and learn more link when online is selected", () => {
|
||||
render(<MigrationType />);
|
||||
|
||||
expect(screen.getByTestId("migration-type-description-online")).toBeInTheDocument();
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "online copy jobs",
|
||||
});
|
||||
expect(learnMoreLink).toBeInTheDocument();
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started",
|
||||
);
|
||||
expect(learnMoreLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
it("should render offline description and learn more link when offline is selected", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
render(<MigrationType />);
|
||||
|
||||
expect(screen.getByTestId("migration-type-description-offline")).toBeInTheDocument();
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "offline copy jobs",
|
||||
});
|
||||
expect(learnMoreLink).toBeInTheDocument();
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://learn.microsoft.com/azure/cosmos-db/how-to-container-copy?tabs=offline-copy&pivots=api-nosql",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Interactions", () => {
|
||||
it("should call setCopyJobState when offline radio button is clicked", () => {
|
||||
render(<MigrationType />);
|
||||
|
||||
const offlineRadio = screen.getByRole("radio", {
|
||||
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
});
|
||||
fireEvent.click(offlineRadio);
|
||||
|
||||
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
const updateFunction = mockSetCopyJobState.mock.calls[0][0];
|
||||
const result = updateFunction(defaultContextValue.copyJobState);
|
||||
|
||||
expect(result).toEqual({
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
});
|
||||
});
|
||||
|
||||
it("should call setCopyJobState when online radio button is clicked", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
render(<MigrationType />);
|
||||
|
||||
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
|
||||
fireEvent.click(onlineRadio);
|
||||
|
||||
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
const updateFunction = mockSetCopyJobState.mock.calls[0][0];
|
||||
const result = updateFunction({
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accessibility", () => {
|
||||
it("should have proper ARIA attributes", () => {
|
||||
render(<MigrationType />);
|
||||
|
||||
const choiceGroup = screen.getByRole("radiogroup");
|
||||
expect(choiceGroup).toBeInTheDocument();
|
||||
expect(choiceGroup).toHaveAttribute("aria-labelledby", "migrationTypeChoiceGroup");
|
||||
});
|
||||
|
||||
it("should have proper radio button labels", () => {
|
||||
render(<MigrationType />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.offline.title }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle undefined migration type gracefully", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(<MigrationType />);
|
||||
|
||||
expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.offline.title }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle null copyJobState gracefully", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: null,
|
||||
});
|
||||
|
||||
const { container } = render(<MigrationType />);
|
||||
|
||||
expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable react/display-name */
|
||||
import { ChoiceGroup, IChoiceGroupOption, Stack, Text } from "@fluentui/react";
|
||||
import MarkdownRender from "@nteract/markdown";
|
||||
import { useCopyJobContext } from "Explorer/ContainerCopy/Context/CopyJobContext";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
|
||||
|
||||
interface MigrationTypeProps {}
|
||||
const options: IChoiceGroupOption[] = [
|
||||
{
|
||||
key: CopyJobMigrationType.Offline,
|
||||
text: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
styles: { root: { width: "33%" } },
|
||||
},
|
||||
{
|
||||
key: CopyJobMigrationType.Online,
|
||||
text: ContainerCopyMessages.migrationTypeOptions.online.title,
|
||||
styles: { root: { width: "33%" } },
|
||||
},
|
||||
];
|
||||
|
||||
export const MigrationType: React.FC<MigrationTypeProps> = React.memo(() => {
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
const handleChange = (_ev?: React.FormEvent, option?: IChoiceGroupOption) => {
|
||||
if (option) {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
migrationType: option.key as CopyJobMigrationType,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const selectedKey = copyJobState?.migrationType ?? "";
|
||||
const selectedKeyLowercase = selectedKey.toLowerCase() as keyof typeof ContainerCopyMessages.migrationTypeOptions;
|
||||
const selectedKeyContent = ContainerCopyMessages.migrationTypeOptions[selectedKeyLowercase];
|
||||
|
||||
return (
|
||||
<Stack data-test="migration-type" className="migrationTypeContainer">
|
||||
<Stack.Item>
|
||||
<ChoiceGroup
|
||||
selectedKey={selectedKey}
|
||||
options={options}
|
||||
onChange={handleChange}
|
||||
ariaLabelledBy="migrationTypeChoiceGroup"
|
||||
styles={{ flexContainer: { display: "flex" } }}
|
||||
/>
|
||||
</Stack.Item>
|
||||
{selectedKeyContent && (
|
||||
<Stack.Item styles={{ root: { marginTop: 10 } }}>
|
||||
<Text
|
||||
variant="small"
|
||||
className="migrationTypeDescription"
|
||||
data-testid={`migration-type-description-${selectedKeyLowercase}`}
|
||||
>
|
||||
<MarkdownRender source={selectedKeyContent.description} linkTarget="_blank" />
|
||||
</Text>
|
||||
</Stack.Item>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { MigrationTypeCheckbox } from "./MigrationTypeCheckbox";
|
||||
|
||||
describe("MigrationTypeCheckbox", () => {
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Component Rendering", () => {
|
||||
it("should render with default props (unchecked state)", () => {
|
||||
const { container } = render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render in checked state", () => {
|
||||
const { container } = render(<MigrationTypeCheckbox checked={true} onChange={mockOnChange} />);
|
||||
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should display the correct label text", () => {
|
||||
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
|
||||
const label = screen.getByText("Copy container in offline mode");
|
||||
expect(label).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have correct accessibility attributes when checked", () => {
|
||||
render(<MigrationTypeCheckbox checked={true} onChange={mockOnChange} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).toBeChecked();
|
||||
expect(checkbox).toHaveAttribute("checked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FluentUI Integration", () => {
|
||||
it("should render FluentUI Checkbox component correctly", () => {
|
||||
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
expect(checkbox).toHaveAttribute("type", "checkbox");
|
||||
});
|
||||
|
||||
it("should render FluentUI Stack component correctly", () => {
|
||||
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
const stackContainer = document.querySelector(".migrationTypeRow");
|
||||
expect(stackContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply FluentUI Stack horizontal alignment correctly", () => {
|
||||
const { container } = render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
const stackContainer = container.querySelector(".migrationTypeRow");
|
||||
expect(stackContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable react/display-name */
|
||||
import { Checkbox, ICheckboxStyles, Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
|
||||
interface MigrationTypeCheckboxProps {
|
||||
checked: boolean;
|
||||
onChange: (_ev?: React.FormEvent, checked?: boolean) => void;
|
||||
}
|
||||
|
||||
const checkboxStyles: ICheckboxStyles = {
|
||||
text: { color: "var(--colorNeutralForeground1)" },
|
||||
checkbox: { borderColor: "var(--colorNeutralStroke1)" },
|
||||
root: {
|
||||
selectors: {
|
||||
":hover .ms-Checkbox-text": { color: "var(--colorNeutralForeground1)" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MigrationTypeCheckbox: React.FC<MigrationTypeCheckboxProps> = React.memo(({ checked, onChange }) => {
|
||||
return (
|
||||
<Stack horizontal horizontalAlign="space-between" className="migrationTypeRow" data-test="migration-type-checkbox">
|
||||
<Checkbox
|
||||
label={ContainerCopyMessages.migrationTypeCheckboxLabel}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
styles={checkboxStyles}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`MigrationType Component Rendering should render migration type component with radio buttons 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack migrationTypeContainer css-109"
|
||||
data-test="migration-type"
|
||||
>
|
||||
<div
|
||||
class="ms-StackItem css-110"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceFieldGroup root-111"
|
||||
>
|
||||
<div
|
||||
aria-labelledby="migrationTypeChoiceGroup"
|
||||
role="radiogroup"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceFieldGroup-flexContainer flexContainer-112"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceField root-113"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceField-wrapper"
|
||||
>
|
||||
<input
|
||||
class="ms-ChoiceField-input input-114"
|
||||
id="ChoiceGroup0-offline"
|
||||
name="ChoiceGroup0"
|
||||
type="radio"
|
||||
/>
|
||||
<label
|
||||
class="ms-ChoiceField-field field-115"
|
||||
for="ChoiceGroup0-offline"
|
||||
>
|
||||
<span
|
||||
class="ms-ChoiceFieldLabel"
|
||||
id="ChoiceGroupLabel1-offline"
|
||||
>
|
||||
Offline mode
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ms-ChoiceField root-113"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceField-wrapper"
|
||||
>
|
||||
<input
|
||||
checked=""
|
||||
class="ms-ChoiceField-input input-114"
|
||||
id="ChoiceGroup0-online"
|
||||
name="ChoiceGroup0"
|
||||
type="radio"
|
||||
/>
|
||||
<label
|
||||
class="ms-ChoiceField-field is-checked field-120"
|
||||
for="ChoiceGroup0-online"
|
||||
>
|
||||
<span
|
||||
class="ms-ChoiceFieldLabel"
|
||||
id="ChoiceGroupLabel1-online"
|
||||
>
|
||||
Online mode
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ms-StackItem css-123"
|
||||
>
|
||||
<span
|
||||
class="migrationTypeDescription css-124"
|
||||
data-testid="migration-type-description-online"
|
||||
>
|
||||
<div
|
||||
class="markdown-body "
|
||||
>
|
||||
<p>
|
||||
Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the
|
||||
<a
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview"
|
||||
target="_blank"
|
||||
>
|
||||
All Versions and Delete
|
||||
</a>
|
||||
change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about
|
||||
<a
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started"
|
||||
target="_blank"
|
||||
>
|
||||
online copy jobs
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,82 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`MigrationTypeCheckbox Component Rendering should render in checked state 1`] = `
|
||||
<div
|
||||
class="ms-Stack migrationTypeRow css-109"
|
||||
data-test="migration-type-checkbox"
|
||||
>
|
||||
<div
|
||||
class="ms-Checkbox is-checked is-enabled root-119"
|
||||
>
|
||||
<input
|
||||
checked=""
|
||||
class="input-111"
|
||||
data-ktp-execute-target="true"
|
||||
id="checkbox-1"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
class="ms-Checkbox-label label-112"
|
||||
for="checkbox-1"
|
||||
>
|
||||
<div
|
||||
class="ms-Checkbox-checkbox checkbox-120"
|
||||
data-ktp-target="true"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="ms-Checkbox-checkmark checkmark-122"
|
||||
data-icon-name="CheckMark"
|
||||
>
|
||||
|
||||
</i>
|
||||
</div>
|
||||
<span
|
||||
class="ms-Checkbox-text text-115"
|
||||
>
|
||||
Copy container in offline mode
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`MigrationTypeCheckbox Component Rendering should render with default props (unchecked state) 1`] = `
|
||||
<div
|
||||
class="ms-Stack migrationTypeRow css-109"
|
||||
data-test="migration-type-checkbox"
|
||||
>
|
||||
<div
|
||||
class="ms-Checkbox is-enabled root-110"
|
||||
>
|
||||
<input
|
||||
class="input-111"
|
||||
data-ktp-execute-target="true"
|
||||
id="checkbox-0"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
class="ms-Checkbox-label label-112"
|
||||
for="checkbox-0"
|
||||
>
|
||||
<div
|
||||
class="ms-Checkbox-checkbox checkbox-113"
|
||||
data-ktp-target="true"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="ms-Checkbox-checkmark checkmark-118"
|
||||
data-icon-name="CheckMark"
|
||||
>
|
||||
|
||||
</i>
|
||||
</div>
|
||||
<span
|
||||
class="ms-Checkbox-text text-115"
|
||||
>
|
||||
Copy container in offline mode
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,5 +1,5 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
|
||||
@@ -18,8 +18,19 @@ jest.mock("./Components/AccountDropdown", () => ({
|
||||
AccountDropdown: jest.fn(() => <div data-testid="account-dropdown">Account Dropdown</div>),
|
||||
}));
|
||||
|
||||
jest.mock("./Components/MigrationType", () => ({
|
||||
MigrationType: jest.fn(() => <div data-testid="migration-type">Migration Type</div>),
|
||||
jest.mock("./Components/MigrationTypeCheckbox", () => ({
|
||||
MigrationTypeCheckbox: jest.fn(({ checked, onChange }: { checked: boolean; onChange: () => void }) => (
|
||||
<div data-testid="migration-type-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
data-testid="migration-checkbox-input"
|
||||
aria-label="Migration Type Checkbox"
|
||||
/>
|
||||
Copy container in offline mode
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
describe("SelectAccount", () => {
|
||||
@@ -72,7 +83,7 @@ describe("SelectAccount", () => {
|
||||
|
||||
expect(screen.getByTestId("subscription-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("account-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("migration-type")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("migration-type-checkbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render correctly with snapshot", () => {
|
||||
@@ -82,20 +93,78 @@ describe("SelectAccount", () => {
|
||||
});
|
||||
|
||||
describe("Migration Type Functionality", () => {
|
||||
it("should render migration type component", () => {
|
||||
it("should display migration type checkbox as unchecked when migrationType is Online", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
},
|
||||
});
|
||||
|
||||
render(<SelectAccount />);
|
||||
|
||||
const migrationTypeComponent = screen.getByTestId("migration-type");
|
||||
expect(migrationTypeComponent).toBeInTheDocument();
|
||||
const checkbox = screen.getByTestId("migration-checkbox-input");
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should display migration type checkbox as checked when migrationType is Offline", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
render(<SelectAccount />);
|
||||
|
||||
const checkbox = screen.getByTestId("migration-checkbox-input");
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it("should call setCopyJobState with Online migration type when checkbox is unchecked", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
render(<SelectAccount />);
|
||||
|
||||
const checkbox = screen.getByTestId("migration-checkbox-input");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
const updateFunction = mockSetCopyJobState.mock.calls[0][0];
|
||||
const previousState = {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
};
|
||||
const result = updateFunction(previousState);
|
||||
|
||||
expect(result).toEqual({
|
||||
...previousState,
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance and Optimization", () => {
|
||||
it("should render without performance issues", () => {
|
||||
it("should maintain referential equality of handler functions between renders", async () => {
|
||||
const { rerender } = render(<SelectAccount />);
|
||||
|
||||
const migrationCheckbox = (await import("./Components/MigrationTypeCheckbox")).MigrationTypeCheckbox as jest.Mock;
|
||||
const firstRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange;
|
||||
|
||||
rerender(<SelectAccount />);
|
||||
|
||||
expect(screen.getByTestId("migration-type")).toBeInTheDocument();
|
||||
const secondRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange;
|
||||
|
||||
expect(firstRenderHandler).toBe(secondRenderHandler);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
import { Stack, Text } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
|
||||
import { AccountDropdown } from "./Components/AccountDropdown";
|
||||
import { MigrationType } from "./Components/MigrationType";
|
||||
import { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox";
|
||||
import { SubscriptionDropdown } from "./Components/SubscriptionDropdown";
|
||||
|
||||
const SelectAccount = React.memo(() => {
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
|
||||
const handleMigrationTypeChange = (_ev?: React.FormEvent<HTMLElement>, checked?: boolean) => {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
|
||||
}));
|
||||
};
|
||||
|
||||
const migrationTypeChecked = copyJobState?.migrationType === CopyJobMigrationType.Offline;
|
||||
|
||||
return (
|
||||
<Stack data-test="Panel:SelectAccountContainer" className="selectAccountContainer" tokens={{ childrenGap: 15 }}>
|
||||
<Text>{ContainerCopyMessages.selectAccountDescription}</Text>
|
||||
<Text className="themeText">{ContainerCopyMessages.selectAccountDescription}</Text>
|
||||
|
||||
<SubscriptionDropdown />
|
||||
|
||||
<AccountDropdown />
|
||||
|
||||
<MigrationType />
|
||||
<MigrationTypeCheckbox checked={migrationTypeChecked} onChange={handleMigrationTypeChange} />
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ exports[`SelectAccount Component Rendering should render correctly with snapshot
|
||||
data-test="Panel:SelectAccountContainer"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
class="themeText css-110"
|
||||
>
|
||||
Please select a source account from which to copy.
|
||||
</span>
|
||||
@@ -21,9 +21,14 @@ exports[`SelectAccount Component Rendering should render correctly with snapshot
|
||||
Account Dropdown
|
||||
</div>
|
||||
<div
|
||||
data-testid="migration-type"
|
||||
data-testid="migration-type-checkbox"
|
||||
>
|
||||
Migration Type
|
||||
<input
|
||||
aria-label="Migration Type Checkbox"
|
||||
data-testid="migration-checkbox-input"
|
||||
type="checkbox"
|
||||
/>
|
||||
Copy container in offline mode
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -52,7 +52,7 @@ const SelectSourceAndTargetContainers = ({ showAddCollectionPanel }: SelectSourc
|
||||
className="selectSourceAndTargetContainers"
|
||||
tokens={{ childrenGap: 25 }}
|
||||
>
|
||||
<span>{ContainerCopyMessages.selectSourceAndTargetContainersDescription}</span>
|
||||
<span className="themeText">{ContainerCopyMessages.selectSourceAndTargetContainersDescription}</span>
|
||||
<DatabaseContainerSection
|
||||
heading={ContainerCopyMessages.sourceContainerSubHeading}
|
||||
databaseOptions={sourceDatabaseOptions}
|
||||
|
||||
@@ -44,7 +44,11 @@ export const DatabaseContainerSection = ({
|
||||
data-test={`${sectionType}-containerDropdown`}
|
||||
/>
|
||||
{handleOnDemandCreateContainer && (
|
||||
<ActionButton className="create-container-link-btn" onClick={() => handleOnDemandCreateContainer()}>
|
||||
<ActionButton
|
||||
className="create-container-link-btn"
|
||||
style={{ color: "var(--colorBrandForeground1)" }}
|
||||
onClick={() => handleOnDemandCreateContainer()}
|
||||
>
|
||||
{ContainerCopyMessages.createContainerButtonLabel}
|
||||
</ActionButton>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable jest/no-conditional-expect */
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
@@ -6,20 +5,6 @@ import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../E
|
||||
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
|
||||
import CopyJobActionMenu from "./CopyJobActionMenu";
|
||||
|
||||
const mockShowOkCancelModalDialog = jest.fn();
|
||||
const mockCloseDialog = jest.fn();
|
||||
const mockOpenDialog = jest.fn();
|
||||
|
||||
jest.mock("../../../Controls/Dialog", () => ({
|
||||
useDialog: {
|
||||
getState: () => ({
|
||||
showOkCancelModalDialog: mockShowOkCancelModalDialog,
|
||||
closeDialog: mockCloseDialog,
|
||||
openDialog: mockOpenDialog,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../ContainerCopyMessages", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
@@ -33,11 +18,6 @@ jest.mock("../../ContainerCopyMessages", () => ({
|
||||
cancel: "Cancel",
|
||||
complete: "Complete",
|
||||
},
|
||||
dialog: {
|
||||
heading: "Confirm Action",
|
||||
confirmButtonText: "Confirm",
|
||||
cancelButtonText: "Cancel",
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -70,9 +50,6 @@ describe("CopyJobActionMenu", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockShowOkCancelModalDialog.mockClear();
|
||||
mockCloseDialog.mockClear();
|
||||
mockOpenDialog.mockClear();
|
||||
});
|
||||
|
||||
describe("Component Rendering", () => {
|
||||
@@ -289,29 +266,7 @@ describe("CopyJobActionMenu", () => {
|
||||
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.pause, expect.any(Function));
|
||||
});
|
||||
|
||||
it("should show confirmation dialog when cancel action is clicked", () => {
|
||||
const job = createMockJob({ Name: "Test Job", Status: CopyJobStatusType.InProgress });
|
||||
|
||||
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
|
||||
"Confirm Action",
|
||||
null,
|
||||
"Confirm",
|
||||
expect.any(Function),
|
||||
"Cancel",
|
||||
null,
|
||||
expect.any(Object), // dialogBody content
|
||||
);
|
||||
});
|
||||
|
||||
it("should call handleClick when dialog is confirmed for cancel action", () => {
|
||||
it("should call handleClick when cancel action is clicked", () => {
|
||||
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
|
||||
|
||||
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
@@ -322,9 +277,6 @@ describe("CopyJobActionMenu", () => {
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
const [, , , onOkCallback] = mockShowOkCancelModalDialog.mock.calls[0];
|
||||
onOkCallback();
|
||||
|
||||
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.cancel, expect.any(Function));
|
||||
});
|
||||
|
||||
@@ -342,33 +294,7 @@ describe("CopyJobActionMenu", () => {
|
||||
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.resume, expect.any(Function));
|
||||
});
|
||||
|
||||
it("should show confirmation dialog when complete action is clicked", () => {
|
||||
const job = createMockJob({
|
||||
Name: "Test Online Job",
|
||||
Status: CopyJobStatusType.InProgress,
|
||||
Mode: CopyJobMigrationType.Online,
|
||||
});
|
||||
|
||||
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const completeButton = screen.getByText("Complete");
|
||||
fireEvent.click(completeButton);
|
||||
|
||||
expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
|
||||
"Confirm Action",
|
||||
null,
|
||||
"Confirm",
|
||||
expect.any(Function),
|
||||
"Cancel",
|
||||
null,
|
||||
expect.any(Object), // dialogBody content
|
||||
);
|
||||
});
|
||||
|
||||
it("should call handleClick when dialog is confirmed for complete action", () => {
|
||||
it("should call handleClick when complete action is clicked", () => {
|
||||
const job = createMockJob({
|
||||
Status: CopyJobStatusType.InProgress,
|
||||
Mode: CopyJobMigrationType.Online,
|
||||
@@ -382,87 +308,10 @@ describe("CopyJobActionMenu", () => {
|
||||
const completeButton = screen.getByText("Complete");
|
||||
fireEvent.click(completeButton);
|
||||
|
||||
const [, , , onOkCallback] = mockShowOkCancelModalDialog.mock.calls[0];
|
||||
onOkCallback();
|
||||
|
||||
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.complete, expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dialog Body Content", () => {
|
||||
it("should pass correct dialog body content for cancel action", () => {
|
||||
const job = createMockJob({ Name: "MyTestJob", Status: CopyJobStatusType.InProgress });
|
||||
|
||||
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
|
||||
"Confirm Action",
|
||||
null,
|
||||
"Confirm",
|
||||
expect.any(Function),
|
||||
"Cancel",
|
||||
null,
|
||||
expect.objectContaining({
|
||||
props: expect.objectContaining({
|
||||
tokens: expect.any(Object),
|
||||
children: expect.any(Array),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass correct dialog body content for complete action", () => {
|
||||
const job = createMockJob({
|
||||
Name: "OnlineTestJob",
|
||||
Status: CopyJobStatusType.InProgress,
|
||||
Mode: CopyJobMigrationType.Online,
|
||||
});
|
||||
|
||||
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const completeButton = screen.getByText("Complete");
|
||||
fireEvent.click(completeButton);
|
||||
|
||||
expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
|
||||
"Confirm Action",
|
||||
null,
|
||||
"Confirm",
|
||||
expect.any(Function),
|
||||
"Cancel",
|
||||
null,
|
||||
expect.objectContaining({
|
||||
props: expect.objectContaining({
|
||||
tokens: expect.any(Object),
|
||||
children: expect.any(Array),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not show dialog body for actions without confirmation", () => {
|
||||
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
|
||||
|
||||
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const pauseButton = screen.getByText("Pause");
|
||||
fireEvent.click(pauseButton);
|
||||
|
||||
expect(mockShowOkCancelModalDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Disabled States During Updates", () => {
|
||||
const TestComponentWrapper: React.FC<{
|
||||
job: CopyJobType;
|
||||
@@ -490,13 +339,8 @@ describe("CopyJobActionMenu", () => {
|
||||
const pauseButton = screen.getByText("Pause");
|
||||
fireEvent.click(pauseButton);
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const pauseButtonAfterClick = screen.getByText("Pause").closest("button");
|
||||
const pauseButtonAfterClick = screen.getByText("Pause");
|
||||
expect(pauseButtonAfterClick).toBeInTheDocument();
|
||||
expect(pauseButtonAfterClick).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
const cancelButtonAfterClick = screen.getByText("Cancel").closest("button");
|
||||
expect(cancelButtonAfterClick).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("should not disable actions for different jobs when one is updating", () => {
|
||||
@@ -516,6 +360,22 @@ describe("CopyJobActionMenu", () => {
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should properly handle multiple action types being disabled for the same job", () => {
|
||||
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
|
||||
render(<TestComponentWrapper job={job} />);
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
|
||||
fireEvent.click(actionButton);
|
||||
fireEvent.click(screen.getByText("Pause"));
|
||||
|
||||
fireEvent.click(actionButton);
|
||||
fireEvent.click(screen.getByText("Cancel"));
|
||||
|
||||
fireEvent.click(actionButton);
|
||||
expect(screen.getByText("Pause")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle complete action disabled state for online jobs", () => {
|
||||
const job = createMockJob({
|
||||
Status: CopyJobStatusType.InProgress,
|
||||
@@ -602,7 +462,6 @@ describe("CopyJobActionMenu", () => {
|
||||
|
||||
expect(actionButton).toHaveAttribute("aria-label", "Actions");
|
||||
expect(actionButton).toHaveAttribute("title", "Actions");
|
||||
expect(actionButton).toHaveAttribute("role", "button");
|
||||
|
||||
const moreIcon = actionButton.querySelector('[data-icon-name="More"]');
|
||||
expect(moreIcon || actionButton).toBeInTheDocument();
|
||||
@@ -749,129 +608,4 @@ describe("CopyJobActionMenu", () => {
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Complete Coverage Tests", () => {
|
||||
it("should handle all possible dialog scenarios", () => {
|
||||
const dialogTests = [
|
||||
{ action: CopyJobActions.cancel, status: CopyJobStatusType.InProgress, shouldShowDialog: true },
|
||||
{
|
||||
action: CopyJobActions.complete,
|
||||
status: CopyJobStatusType.InProgress,
|
||||
mode: CopyJobMigrationType.Online,
|
||||
shouldShowDialog: true,
|
||||
},
|
||||
{ action: CopyJobActions.pause, status: CopyJobStatusType.InProgress, shouldShowDialog: false },
|
||||
{ action: CopyJobActions.resume, status: CopyJobStatusType.Paused, shouldShowDialog: false },
|
||||
];
|
||||
|
||||
dialogTests.forEach(({ action, status, mode = CopyJobMigrationType.Offline, shouldShowDialog }, index) => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const job = createMockJob({ Status: status, Mode: mode, Name: `DialogTestJob${index}` });
|
||||
const { unmount } = render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const actionText = action.charAt(0).toUpperCase() + action.slice(1);
|
||||
if (screen.queryByText(actionText)) {
|
||||
fireEvent.click(screen.getByText(actionText));
|
||||
|
||||
if (shouldShowDialog) {
|
||||
expect(mockShowOkCancelModalDialog).toHaveBeenCalled();
|
||||
} else {
|
||||
expect(mockShowOkCancelModalDialog).not.toHaveBeenCalled();
|
||||
expect(mockHandleClick).toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("should verify component handles state updates correctly", () => {
|
||||
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
|
||||
const stateUpdater = jest.fn();
|
||||
|
||||
const testHandleClick: HandleJobActionClickType = (job, action, setUpdatingJobAction) => {
|
||||
setUpdatingJobAction({ jobName: job.Name, action });
|
||||
stateUpdater(job.Name, action);
|
||||
};
|
||||
|
||||
render(<CopyJobActionMenu job={job} handleClick={testHandleClick} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const pauseButton = screen.getByText("Pause");
|
||||
fireEvent.click(pauseButton);
|
||||
|
||||
expect(stateUpdater).toHaveBeenCalledWith(job.Name, CopyJobActions.pause);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Full Integration Coverage", () => {
|
||||
it("should test complete workflow for cancel action with dialog", () => {
|
||||
const job = createMockJob({ Name: "Integration Test Job", Status: CopyJobStatusType.InProgress });
|
||||
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
expect(actionButton).toHaveAttribute("data-test", "CopyJobActionMenu/Button:Integration Test Job");
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
|
||||
"Confirm Action", // title
|
||||
null, // subText
|
||||
"Confirm", // confirmLabel
|
||||
expect.any(Function), // onOk
|
||||
"Cancel", // cancelLabel
|
||||
null, // onCancel
|
||||
expect.any(Object), // contentHtml (dialogBody)
|
||||
);
|
||||
|
||||
const onOkCallback = mockShowOkCancelModalDialog.mock.calls[0][3];
|
||||
onOkCallback();
|
||||
|
||||
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.cancel, expect.any(Function));
|
||||
});
|
||||
|
||||
it("should test complete workflow for complete action with dialog", () => {
|
||||
const job = createMockJob({
|
||||
Name: "Online Integration Job",
|
||||
Status: CopyJobStatusType.Running,
|
||||
Mode: CopyJobMigrationType.Online,
|
||||
});
|
||||
|
||||
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const completeButton = screen.getByText("Complete");
|
||||
fireEvent.click(completeButton);
|
||||
|
||||
expect(mockShowOkCancelModalDialog).toHaveBeenCalled();
|
||||
|
||||
const dialogContent = mockShowOkCancelModalDialog.mock.calls[0][6];
|
||||
expect(dialogContent).toBeTruthy();
|
||||
|
||||
const onOkCallback = mockShowOkCancelModalDialog.mock.calls[0][3];
|
||||
onOkCallback();
|
||||
|
||||
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.complete, expect.any(Function));
|
||||
});
|
||||
|
||||
it("should maintain proper component lifecycle", () => {
|
||||
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
|
||||
const { rerender, unmount } = render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
|
||||
rerender(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
expect(screen.getByRole("button", { name: "Actions" })).toBeInTheDocument();
|
||||
|
||||
expect(() => unmount()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { DirectionalHint, IconButton, IContextualMenuProps, Stack } from "@fluentui/react";
|
||||
import { IconButton, IContextualMenuProps } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import { useDialog } from "../../../Controls/Dialog";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums/CopyJobEnums";
|
||||
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
|
||||
@@ -10,28 +9,6 @@ interface CopyJobActionMenuProps {
|
||||
handleClick: HandleJobActionClickType;
|
||||
}
|
||||
|
||||
const dialogBody = {
|
||||
[CopyJobActions.cancel]: (jobName: string) => (
|
||||
<Stack tokens={{ childrenGap: 10 }}>
|
||||
<Stack.Item>
|
||||
You are about to cancel <b>{jobName}</b> copy job.
|
||||
</Stack.Item>
|
||||
<Stack.Item>Cancelling will stop the job immediately.</Stack.Item>
|
||||
</Stack>
|
||||
),
|
||||
[CopyJobActions.complete]: (jobName: string) => (
|
||||
<Stack tokens={{ childrenGap: 10 }}>
|
||||
<Stack.Item>
|
||||
You are about to complete <b>{jobName}</b> copy job.
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
Once completed, continuous data copy will stop after any pending documents are processed. To maintain data
|
||||
integrity, we recommend stopping updates to the source container before completing the job.
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
),
|
||||
};
|
||||
|
||||
const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick }) => {
|
||||
const [updatingJobAction, setUpdatingJobAction] = React.useState<{ jobName: string; action: string } | null>(null);
|
||||
if (
|
||||
@@ -45,22 +22,9 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
|
||||
return null;
|
||||
}
|
||||
|
||||
const showActionConfirmationDialog = (job: CopyJobType, action: CopyJobActions): void => {
|
||||
useDialog
|
||||
.getState()
|
||||
.showOkCancelModalDialog(
|
||||
ContainerCopyMessages.MonitorJobs.dialog.heading,
|
||||
null,
|
||||
ContainerCopyMessages.MonitorJobs.dialog.confirmButtonText,
|
||||
() => handleClick(job, action, setUpdatingJobAction),
|
||||
ContainerCopyMessages.MonitorJobs.dialog.cancelButtonText,
|
||||
null,
|
||||
action in dialogBody ? dialogBody[action as keyof typeof dialogBody](job.Name) : null,
|
||||
);
|
||||
};
|
||||
|
||||
const getMenuItems = (): IContextualMenuProps["items"] => {
|
||||
const isThisJobUpdating = updatingJobAction?.jobName === job.Name;
|
||||
const updatingAction = updatingJobAction?.action;
|
||||
|
||||
const baseItems = [
|
||||
{
|
||||
@@ -68,21 +32,21 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.pause,
|
||||
iconProps: { iconName: "Pause" },
|
||||
onClick: () => handleClick(job, CopyJobActions.pause, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating,
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.pause,
|
||||
},
|
||||
{
|
||||
key: CopyJobActions.cancel,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.cancel,
|
||||
iconProps: { iconName: "Cancel" },
|
||||
onClick: () => showActionConfirmationDialog(job, CopyJobActions.cancel),
|
||||
disabled: isThisJobUpdating,
|
||||
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, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating,
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.resume,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -103,8 +67,8 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
|
||||
key: CopyJobActions.complete,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.complete,
|
||||
iconProps: { iconName: "CheckMark" },
|
||||
onClick: () => showActionConfirmationDialog(job, CopyJobActions.complete),
|
||||
disabled: isThisJobUpdating,
|
||||
onClick: () => handleClick(job, CopyJobActions.complete, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.complete,
|
||||
});
|
||||
}
|
||||
return filteredItems;
|
||||
@@ -122,8 +86,8 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
|
||||
data-test={`CopyJobActionMenu/Button:${job.Name}`}
|
||||
role="button"
|
||||
iconProps={{ iconName: "More", styles: { root: { fontSize: "20px", fontWeight: "bold" } } }}
|
||||
menuProps={{ items: getMenuItems(), directionalHint: DirectionalHint.leftTopEdge, directionalHintFixed: false }}
|
||||
menuIconProps={{ iconName: "", className: "hidden" }}
|
||||
menuProps={{ items: getMenuItems() }}
|
||||
menuIconProps={{ iconName: "" }}
|
||||
ariaLabel={ContainerCopyMessages.MonitorJobs.Columns.actions}
|
||||
title={ContainerCopyMessages.MonitorJobs.Columns.actions}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DetailsList, DetailsListLayoutMode, IColumn, Stack, Text } from "@fluentui/react";
|
||||
import React, { memo } from "react";
|
||||
import { useThemeStore } from "../../../../hooks/useTheme";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
|
||||
import { CopyJobType } from "../../Types/CopyJobTypes";
|
||||
@@ -63,6 +64,19 @@ const getCopyJobDetailsListColumns = (): IColumn[] => {
|
||||
};
|
||||
|
||||
const CopyJobDetails: React.FC<CopyJobDetailsProps> = ({ job }) => {
|
||||
const isDarkMode = useThemeStore((state) => state.isDarkMode);
|
||||
|
||||
const errorMessageStyle: React.CSSProperties = {
|
||||
whiteSpace: "pre-wrap",
|
||||
...(isDarkMode && {
|
||||
whiteSpace: "pre-wrap",
|
||||
backgroundColor: "var(--colorNeutralBackground2)",
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
padding: "10px",
|
||||
borderRadius: "4px",
|
||||
}),
|
||||
};
|
||||
|
||||
const selectedContainers = [
|
||||
{
|
||||
sourceContainerName: job?.Source?.containerName || "N/A",
|
||||
@@ -77,10 +91,10 @@ const CopyJobDetails: React.FC<CopyJobDetailsProps> = ({ job }) => {
|
||||
<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}>
|
||||
<Text className="bold themeText" style={sectionCss.headingText}>
|
||||
{ContainerCopyMessages.errorTitle}
|
||||
</Text>
|
||||
<Text as="pre" style={{ whiteSpace: "pre-wrap" }}>
|
||||
<Text as="pre" style={errorMessageStyle}>
|
||||
{job.Error.message}
|
||||
</Text>
|
||||
</Stack.Item>
|
||||
@@ -88,16 +102,16 @@ const CopyJobDetails: React.FC<CopyJobDetailsProps> = ({ job }) => {
|
||||
<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>
|
||||
<Text className="bold themeText">{ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime}</Text>
|
||||
<Text className="themeText">{job.LastUpdatedTime}</Text>
|
||||
</Stack.Item>
|
||||
<Stack.Item style={sectionCss.verticalAlign}>
|
||||
<Text className="bold">{ContainerCopyMessages.sourceAccountLabel}</Text>
|
||||
<Text>{job.Source?.remoteAccountName}</Text>
|
||||
<Text className="bold themeText">{ContainerCopyMessages.sourceAccountLabel}</Text>
|
||||
<Text className="themeText">{job.Source?.remoteAccountName}</Text>
|
||||
</Stack.Item>
|
||||
<Stack.Item style={sectionCss.verticalAlign}>
|
||||
<Text className="bold">{ContainerCopyMessages.MonitorJobs.Columns.mode}</Text>
|
||||
<Text>{job.Mode}</Text>
|
||||
<Text className="bold themeText">{ContainerCopyMessages.MonitorJobs.Columns.mode}</Text>
|
||||
<Text className="themeText">{job.Mode}</Text>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Stack.Item>
|
||||
|
||||
@@ -1,30 +1,14 @@
|
||||
import { FontIcon, getTheme, mergeStyles, mergeStyleSets, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
|
||||
import { FontIcon, mergeStyles, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
|
||||
import PropTypes from "prop-types";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../ContainerCopyMessages";
|
||||
import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
|
||||
|
||||
const theme = getTheme();
|
||||
|
||||
const iconClass = mergeStyles({
|
||||
fontSize: "16px",
|
||||
marginRight: "8px",
|
||||
});
|
||||
|
||||
const classNames = mergeStyleSets({
|
||||
[CopyJobStatusType.Pending]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
|
||||
[CopyJobStatusType.InProgress]: [{ color: theme.palette.themePrimary }, iconClass],
|
||||
[CopyJobStatusType.Running]: [{ color: theme.palette.themePrimary }, iconClass],
|
||||
[CopyJobStatusType.Partitioning]: [{ color: theme.palette.themePrimary }, iconClass],
|
||||
[CopyJobStatusType.Paused]: [{ color: theme.palette.themePrimary }, iconClass],
|
||||
[CopyJobStatusType.Skipped]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
|
||||
[CopyJobStatusType.Cancelled]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
|
||||
[CopyJobStatusType.Failed]: [{ color: theme.semanticColors.errorIcon }, iconClass],
|
||||
[CopyJobStatusType.Faulted]: [{ color: theme.semanticColors.errorIcon }, iconClass],
|
||||
[CopyJobStatusType.Completed]: [{ color: theme.semanticColors.successIcon }, iconClass],
|
||||
unknown: [{ color: theme.semanticColors.bodySubtext }, iconClass],
|
||||
});
|
||||
|
||||
const iconMap: Partial<Record<CopyJobStatusType, string>> = {
|
||||
[CopyJobStatusType.Pending]: "Clock",
|
||||
[CopyJobStatusType.Paused]: "CirclePause",
|
||||
@@ -35,6 +19,17 @@ const iconMap: Partial<Record<CopyJobStatusType, string>> = {
|
||||
[CopyJobStatusType.Completed]: "CompletedSolid",
|
||||
};
|
||||
|
||||
// Icon colors for different statuses
|
||||
const statusIconColors: Partial<Record<CopyJobStatusType, string>> = {
|
||||
[CopyJobStatusType.Failed]: "var(--colorPaletteRedForeground1)",
|
||||
[CopyJobStatusType.Faulted]: "var(--colorPaletteRedForeground1)",
|
||||
[CopyJobStatusType.Completed]: "var(--colorSuccessGreen)",
|
||||
[CopyJobStatusType.InProgress]: "var(--colorBrandForeground1)",
|
||||
[CopyJobStatusType.Running]: "var(--colorBrandForeground1)",
|
||||
[CopyJobStatusType.Partitioning]: "var(--colorBrandForeground1)",
|
||||
[CopyJobStatusType.Paused]: "var(--colorBrandForeground1)",
|
||||
};
|
||||
|
||||
export interface CopyJobStatusWithIconProps {
|
||||
status: CopyJobStatusType;
|
||||
}
|
||||
@@ -47,19 +42,17 @@ const CopyJobStatusWithIcon: React.FC<CopyJobStatusWithIconProps> = React.memo((
|
||||
CopyJobStatusType.InProgress,
|
||||
CopyJobStatusType.Partitioning,
|
||||
].includes(status);
|
||||
const iconColor = statusIconColors[status] || "var(--colorNeutralForeground2)";
|
||||
const iconStyle = mergeStyles(iconClass, { color: iconColor });
|
||||
|
||||
return (
|
||||
<Stack horizontal verticalAlign="center">
|
||||
{isSpinnerStatus ? (
|
||||
<Spinner size={SpinnerSize.small} style={{ marginRight: "8px" }} />
|
||||
) : (
|
||||
<FontIcon
|
||||
aria-label={status}
|
||||
iconName={iconMap[status] || "UnknownSolid"}
|
||||
className={classNames[status] || classNames.unknown}
|
||||
/>
|
||||
<FontIcon aria-label={status} iconName={iconMap[status] || "UnknownSolid"} className={iconStyle} />
|
||||
)}
|
||||
<Text>{statusText}</Text>
|
||||
<Text className="themeText">{statusText}</Text>
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from "@fluentui/react";
|
||||
import React, { useEffect } from "react";
|
||||
import Pager from "../../../../Common/Pager";
|
||||
import { useThemeStore } from "../../../../hooks/useTheme";
|
||||
import { getThemeTokens } from "../../../Theme/ThemeUtil";
|
||||
import { openCopyJobDetailsPanel } from "../../Actions/CopyJobActions";
|
||||
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
|
||||
import { getColumns } from "./CopyJobColumns";
|
||||
@@ -26,13 +28,15 @@ interface CopyJobsListProps {
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: { height: "calc(100vh - 25em)" } as React.CSSProperties,
|
||||
container: { height: "100%" } as React.CSSProperties,
|
||||
stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties,
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => {
|
||||
const isDarkMode = useThemeStore((state) => state.isDarkMode);
|
||||
const themeTokens = getThemeTokens(isDarkMode);
|
||||
const [startIndex, setStartIndex] = React.useState(0);
|
||||
const [sortedJobs, setSortedJobs] = React.useState<CopyJobType[]>(jobs);
|
||||
const [sortedColumnKey, setSortedColumnKey] = React.useState<string | undefined>(undefined);
|
||||
@@ -88,11 +92,28 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
|
||||
enableShimmer={false}
|
||||
constrainMode={ConstrainMode.unconstrained}
|
||||
layoutMode={DetailsListLayoutMode.justified}
|
||||
onRenderDetailsHeader={(props, defaultRender) => (
|
||||
<Sticky stickyPosition={StickyPositionType.Header} isScrollSynced>
|
||||
{defaultRender({ ...props })}
|
||||
</Sticky>
|
||||
)}
|
||||
onRenderDetailsHeader={(props, defaultRender) => {
|
||||
const bgColor = themeTokens.colorNeutralBackground3;
|
||||
const textColor = themeTokens.colorNeutralForeground1;
|
||||
return (
|
||||
<Sticky stickyPosition={StickyPositionType.Header} isScrollSynced stickyBackgroundColor={bgColor}>
|
||||
<div style={{ backgroundColor: bgColor }}>
|
||||
{defaultRender({
|
||||
...props,
|
||||
styles: {
|
||||
root: {
|
||||
backgroundColor: bgColor,
|
||||
selectors: {
|
||||
".ms-DetailsHeader-cellTitle": { color: textColor },
|
||||
".ms-DetailsHeader-cellName": { color: textColor },
|
||||
},
|
||||
},
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
</Sticky>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</ScrollablePane>
|
||||
</Stack.Item>
|
||||
|
||||
@@ -13,7 +13,7 @@ exports[`CopyJobStatusWithIcon Spinner Status Types renders InProgress with spin
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="css-112"
|
||||
class="themeText css-112"
|
||||
>
|
||||
Running
|
||||
</span>
|
||||
@@ -33,7 +33,7 @@ exports[`CopyJobStatusWithIcon Spinner Status Types renders Partitioning with sp
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="css-112"
|
||||
class="themeText css-112"
|
||||
>
|
||||
Running
|
||||
</span>
|
||||
@@ -53,7 +53,7 @@ exports[`CopyJobStatusWithIcon Spinner Status Types renders Running with spinner
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="css-112"
|
||||
class="themeText css-112"
|
||||
>
|
||||
Running
|
||||
</span>
|
||||
@@ -66,7 +66,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
|
||||
>
|
||||
<i
|
||||
aria-label="Cancelled"
|
||||
class="ms-Icon root-105 css-118 mocked-style-Cancelled"
|
||||
class="ms-Icon root-105 css-118 mocked-styles"
|
||||
data-icon-name="StatusErrorFull"
|
||||
role="img"
|
||||
style="font-family: "FabricMDL2Icons-4";"
|
||||
@@ -74,7 +74,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
|
||||
|
||||
</i>
|
||||
<span
|
||||
class="css-112"
|
||||
class="themeText css-112"
|
||||
>
|
||||
Cancelled
|
||||
</span>
|
||||
@@ -87,7 +87,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
|
||||
>
|
||||
<i
|
||||
aria-label="Completed"
|
||||
class="ms-Icon root-105 css-120 mocked-style-Completed"
|
||||
class="ms-Icon root-105 css-120 mocked-styles"
|
||||
data-icon-name="CompletedSolid"
|
||||
role="img"
|
||||
style="font-family: "FabricMDL2Icons-5";"
|
||||
@@ -95,7 +95,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
|
||||
|
||||
</i>
|
||||
<span
|
||||
class="css-112"
|
||||
class="themeText css-112"
|
||||
>
|
||||
Completed
|
||||
</span>
|
||||
@@ -108,7 +108,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
|
||||
>
|
||||
<i
|
||||
aria-label="Failed"
|
||||
class="ms-Icon root-105 css-118 mocked-style-Failed"
|
||||
class="ms-Icon root-105 css-118 mocked-styles"
|
||||
data-icon-name="StatusErrorFull"
|
||||
role="img"
|
||||
style="font-family: "FabricMDL2Icons-4";"
|
||||
@@ -116,7 +116,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
|
||||
|
||||
</i>
|
||||
<span
|
||||
class="css-112"
|
||||
class="themeText css-112"
|
||||
>
|
||||
Failed
|
||||
</span>
|
||||
@@ -129,7 +129,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
|
||||
>
|
||||
<i
|
||||
aria-label="Faulted"
|
||||
class="ms-Icon root-105 css-118 mocked-style-Faulted"
|
||||
class="ms-Icon root-105 css-118 mocked-styles"
|
||||
data-icon-name="StatusErrorFull"
|
||||
role="img"
|
||||
style="font-family: "FabricMDL2Icons-4";"
|
||||
@@ -137,7 +137,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
|
||||
|
||||
</i>
|
||||
<span
|
||||
class="css-112"
|
||||
class="themeText css-112"
|
||||
>
|
||||
Failed
|
||||
</span>
|
||||
@@ -150,7 +150,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
|
||||
>
|
||||
<i
|
||||
aria-label="Paused"
|
||||
class="ms-Icon root-105 css-114 mocked-style-Paused"
|
||||
class="ms-Icon root-105 css-114 mocked-styles"
|
||||
data-icon-name="CirclePause"
|
||||
role="img"
|
||||
style="font-family: "FabricMDL2Icons-11";"
|
||||
@@ -158,7 +158,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
|
||||
|
||||
</i>
|
||||
<span
|
||||
class="css-112"
|
||||
class="themeText css-112"
|
||||
>
|
||||
Paused
|
||||
</span>
|
||||
@@ -171,7 +171,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
|
||||
>
|
||||
<i
|
||||
aria-label="Pending"
|
||||
class="ms-Icon root-105 css-111 mocked-style-Pending"
|
||||
class="ms-Icon root-105 css-111 mocked-styles"
|
||||
data-icon-name="Clock"
|
||||
role="img"
|
||||
style="font-family: "FabricMDL2Icons-2";"
|
||||
@@ -179,7 +179,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
|
||||
|
||||
</i>
|
||||
<span
|
||||
class="css-112"
|
||||
class="themeText css-112"
|
||||
>
|
||||
Queued
|
||||
</span>
|
||||
@@ -192,7 +192,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
|
||||
>
|
||||
<i
|
||||
aria-label="Skipped"
|
||||
class="ms-Icon root-105 css-116 mocked-style-Skipped"
|
||||
class="ms-Icon root-105 css-116 mocked-styles"
|
||||
data-icon-name="StatusCircleBlock2"
|
||||
role="img"
|
||||
style="font-family: "FabricMDL2Icons-9";"
|
||||
@@ -200,7 +200,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
|
||||
|
||||
</i>
|
||||
<span
|
||||
class="css-112"
|
||||
class="themeText css-112"
|
||||
>
|
||||
Cancelled
|
||||
</span>
|
||||
|
||||
@@ -10,7 +10,7 @@ import CopyJobsNotFound from "../MonitorCopyJobs/Components/CopyJobs.NotFound";
|
||||
import { CopyJobType, JobActionUpdatorType } from "../Types/CopyJobTypes";
|
||||
import CopyJobsList from "./Components/CopyJobsList";
|
||||
|
||||
const FETCH_INTERVAL_MS = 60 * 1000;
|
||||
const FETCH_INTERVAL = 2 * 60 * 1000;
|
||||
const SHIMMER_INDENT_LEVELS: IndentLevel[] = Array(7).fill({ level: 0, width: "100%" });
|
||||
|
||||
interface MonitorCopyJobsProps {
|
||||
@@ -57,7 +57,7 @@ const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>(({
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs();
|
||||
const intervalId = setInterval(fetchJobs, FETCH_INTERVAL_MS);
|
||||
const intervalId = setInterval(fetchJobs, FETCH_INTERVAL);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [fetchJobs]);
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
@import "../../../less/Common/Constants.less";
|
||||
|
||||
// Common theme-aware classes
|
||||
.themeText {
|
||||
color: var(--colorNeutralForeground1);
|
||||
}
|
||||
|
||||
.themeTextSecondary {
|
||||
color: var(--colorNeutralForeground2);
|
||||
}
|
||||
|
||||
.themeLinkText {
|
||||
color: var(--colorBrandForeground1);
|
||||
}
|
||||
|
||||
.themeBackground {
|
||||
background-color: var(--colorNeutralBackground1);
|
||||
}
|
||||
|
||||
.themeBackgroundSecondary {
|
||||
background-color: var(--colorNeutralBackground2);
|
||||
}
|
||||
|
||||
#containerCopyWrapper {
|
||||
background-color: var(--colorNeutralBackground1);
|
||||
color: var(--colorNeutralForeground1);
|
||||
|
||||
.centerContent {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -9,20 +33,30 @@
|
||||
.noCopyJobsMessage {
|
||||
font-weight: 600;
|
||||
margin: 0 auto;
|
||||
color: @FocusColor;
|
||||
color: var(--colorNeutralForeground2);
|
||||
}
|
||||
button.createCopyJobButton {
|
||||
color: @LinkColor;
|
||||
color: var(--colorBrandForeground1);
|
||||
}
|
||||
}
|
||||
}
|
||||
.createCopyJobScreensContainer {
|
||||
height: 100%;
|
||||
padding: 1em 1.5em;
|
||||
background-color: var(--colorNeutralBackground1);
|
||||
color: var(--colorNeutralForeground1);
|
||||
|
||||
.pointInTimeRestoreContainer, .onlineCopyContainer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
color: var(--colorNeutralForeground1);
|
||||
}
|
||||
|
||||
.accordionHeaderText {
|
||||
color: var(--colorNeutralForeground1);
|
||||
}
|
||||
|
||||
label {
|
||||
padding: 0;
|
||||
@@ -71,7 +105,7 @@
|
||||
}
|
||||
.foreground {
|
||||
z-index: 10;
|
||||
background-color: #f9f9f9;
|
||||
background-color: var(--colorNeutralBackground2);
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
transform: translate(0%, -9%);
|
||||
@@ -80,14 +114,40 @@
|
||||
.createCopyJobErrorMessageBar {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
body.isDarkMode & {
|
||||
.ms-TooltipHost .ms-Image {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.ms-TextField {
|
||||
.ms-TextField-fieldGroup {
|
||||
background-color: var(--colorNeutralBackground1);
|
||||
border-color: var(--colorNeutralStroke1);
|
||||
}
|
||||
|
||||
.ms-TextField-field {
|
||||
color: var(--colorNeutralForeground1);
|
||||
background-color: var(--colorNeutralBackground1);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--colorNeutralForeground4);
|
||||
}
|
||||
}
|
||||
|
||||
.ms-Label {
|
||||
color: var(--colorNeutralForeground1);
|
||||
}
|
||||
}
|
||||
}
|
||||
.create-container-link-btn {
|
||||
padding: 0;
|
||||
height: 25px;
|
||||
color: @LinkColor;
|
||||
color: var(--colorBrandForeground1);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* Create collection panel */
|
||||
@@ -105,7 +165,6 @@
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
|
||||
.ms-DetailsList {
|
||||
width: 100%;
|
||||
|
||||
@@ -114,36 +173,33 @@
|
||||
padding: @DefaultSpace 20px;
|
||||
font-weight: 600;
|
||||
font-size: @DefaultFontSize;
|
||||
color: @BaseHigh;
|
||||
background-color: @BaseLow;
|
||||
border-bottom: @ButtonBorderWidth solid @BaseMedium;
|
||||
color: var(--colorNeutralForeground1);
|
||||
background-color: var(--colorNeutralBackground2);
|
||||
border-bottom: @ButtonBorderWidth solid var(--colorNeutralStroke1);
|
||||
|
||||
&:hover {
|
||||
background-color: @BaseMediumLow;
|
||||
}
|
||||
.ms-DetailsHeader-cellTitle {
|
||||
padding-left: 20px;
|
||||
background-color: var(--colorNeutralBackground3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ms-DetailsRow {
|
||||
border-bottom: @ButtonBorderWidth solid @BaseMedium;
|
||||
border-bottom: @ButtonBorderWidth solid var(--colorNeutralStroke1);
|
||||
|
||||
&:hover {
|
||||
background-color: @BaseMediumLow;
|
||||
background-color: var(--colorNeutralBackground2);
|
||||
}
|
||||
|
||||
.ms-DetailsRow-cell {
|
||||
padding: @MediumSpace 20px;
|
||||
font-size: @DefaultFontSize;
|
||||
color: @BaseHigh;
|
||||
color: var(--colorNeutralForeground1);
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.jobNameLink {
|
||||
color: @LinkColor;
|
||||
color: var(--colorBrandForeground1);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -171,7 +227,7 @@
|
||||
}
|
||||
.ms-DetailsRow-cell {
|
||||
font-size: @DefaultFontSize;
|
||||
color: @BaseHigh;
|
||||
color: var(--colorNeutralForeground1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { IndexingPolicy } from "@azure/cosmos";
|
||||
import { act } from "@testing-library/react";
|
||||
import { AuthType } from "AuthType";
|
||||
import { shallow } from "enzyme";
|
||||
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
|
||||
import ko from "knockout";
|
||||
import React from "react";
|
||||
import { updateCollection } from "../../../Common/dataAccess/updateCollection";
|
||||
@@ -444,3 +447,49 @@ describe("SettingsComponent", () => {
|
||||
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsComponent - indexing policy subscription", () => {
|
||||
const baseProps: SettingsComponentProps = {
|
||||
settingsTab: new CollectionSettingsTabV2({
|
||||
collection: collection,
|
||||
tabKind: ViewModels.CollectionTabKind.CollectionSettingsV2,
|
||||
title: "Scale & Settings",
|
||||
tabPath: "",
|
||||
node: undefined,
|
||||
}),
|
||||
};
|
||||
|
||||
it("subscribes to the correct container's indexing policy and updates state on change", async () => {
|
||||
const containerId = collection.id();
|
||||
const mockIndexingPolicy: IndexingPolicy = {
|
||||
automatic: false,
|
||||
indexingMode: "lazy",
|
||||
includedPaths: [{ path: "/foo/*" }],
|
||||
excludedPaths: [{ path: "/bar/*" }],
|
||||
compositeIndexes: [],
|
||||
spatialIndexes: [],
|
||||
vectorIndexes: [],
|
||||
fullTextIndexes: [],
|
||||
};
|
||||
|
||||
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
||||
const instance = wrapper.instance() as SettingsComponent;
|
||||
|
||||
await act(async () => {
|
||||
useIndexingPolicyStore.setState({
|
||||
indexingPolicies: {
|
||||
[containerId]: mockIndexingPolicy,
|
||||
},
|
||||
});
|
||||
// Wait for the async refreshCollectionData to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.state("indexingPolicyContent")).toEqual(mockIndexingPolicy);
|
||||
expect(wrapper.state("indexingPolicyContentBaseline")).toEqual(mockIndexingPolicy);
|
||||
// @ts-expect-error: rawDataModel is intentionally accessed for test validation
|
||||
expect(instance.collection.rawDataModel.indexingPolicy).toEqual(mockIndexingPolicy);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ThroughputBucketsComponent,
|
||||
ThroughputBucketsComponentProps,
|
||||
} from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent";
|
||||
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
|
||||
@@ -73,7 +74,6 @@ import {
|
||||
parseConflictResolutionMode,
|
||||
parseConflictResolutionProcedure,
|
||||
} from "./SettingsUtils";
|
||||
|
||||
interface SettingsV2TabInfo {
|
||||
tab: SettingsV2TabTypes;
|
||||
content: JSX.Element;
|
||||
@@ -182,7 +182,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
private totalThroughputUsed: number;
|
||||
private throughputBucketsEnabled: boolean;
|
||||
public mongoDBCollectionResource: MongoDBCollectionResource;
|
||||
|
||||
private unsubscribe: () => void;
|
||||
constructor(props: SettingsComponentProps) {
|
||||
super(props);
|
||||
|
||||
@@ -312,6 +312,13 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
if (this.isCollectionSettingsTab) {
|
||||
this.refreshIndexTransformationProgress();
|
||||
this.loadMongoIndexes();
|
||||
this.unsubscribe = useIndexingPolicyStore.subscribe(
|
||||
() => {
|
||||
this.refreshCollectionData();
|
||||
},
|
||||
(state) => state.indexingPolicies[this.collection?.id()],
|
||||
);
|
||||
this.refreshCollectionData();
|
||||
}
|
||||
|
||||
this.setBaseline();
|
||||
@@ -319,7 +326,11 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
useCommandBar.getState().setContextButtons(this.getTabsButtons());
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
}
|
||||
}
|
||||
componentDidUpdate(): void {
|
||||
if (this.props.settingsTab.isActive()) {
|
||||
useCommandBar.getState().setContextButtons(this.getTabsButtons());
|
||||
@@ -849,7 +860,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
{ name: "name_of_property", query: "query_to_compute_property" },
|
||||
] as DataModels.ComputedProperties;
|
||||
}
|
||||
|
||||
const throughputBuckets = this.offer?.throughputBuckets;
|
||||
|
||||
return {
|
||||
@@ -1009,10 +1019,31 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
startKey,
|
||||
);
|
||||
};
|
||||
private refreshCollectionData = async (): Promise<void> => {
|
||||
const containerId = this.collection.id();
|
||||
const latestIndexingPolicy = useIndexingPolicyStore.getState().indexingPolicies[containerId];
|
||||
const rawPolicy = latestIndexingPolicy ?? this.collection.indexingPolicy();
|
||||
|
||||
const latestCollection: DataModels.IndexingPolicy = {
|
||||
automatic: rawPolicy?.automatic ?? true,
|
||||
indexingMode: rawPolicy?.indexingMode ?? "consistent",
|
||||
includedPaths: rawPolicy?.includedPaths ?? [],
|
||||
excludedPaths: rawPolicy?.excludedPaths ?? [],
|
||||
compositeIndexes: rawPolicy?.compositeIndexes ?? [],
|
||||
spatialIndexes: rawPolicy?.spatialIndexes ?? [],
|
||||
vectorIndexes: rawPolicy?.vectorIndexes ?? [],
|
||||
fullTextIndexes: rawPolicy?.fullTextIndexes ?? [],
|
||||
};
|
||||
|
||||
this.collection.rawDataModel.indexingPolicy = latestCollection;
|
||||
this.setState({
|
||||
indexingPolicyContent: latestCollection,
|
||||
indexingPolicyContentBaseline: latestCollection,
|
||||
});
|
||||
};
|
||||
|
||||
private saveCollectionSettings = async (startKey: number): Promise<void> => {
|
||||
const newCollection: DataModels.Collection = { ...this.collection.rawDataModel };
|
||||
|
||||
if (
|
||||
this.state.isSubSettingsSaveable ||
|
||||
this.state.isContainerPolicyDirty ||
|
||||
@@ -1252,7 +1283,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
onScaleDiscardableChange: this.onScaleDiscardableChange,
|
||||
throughputError: this.state.throughputError,
|
||||
};
|
||||
|
||||
if (!this.isCollectionSettingsTab) {
|
||||
return (
|
||||
<div className="settingsV2MainContainer">
|
||||
|
||||
@@ -155,7 +155,12 @@ export class ComputedPropertiesComponent extends React.Component<
|
||||
</Link>
|
||||
  about how to define computed properties and how to use them.
|
||||
</Text>
|
||||
<div className="settingsV2Editor" tabIndex={0} ref={this.computedPropertiesDiv}></div>
|
||||
<div
|
||||
className="settingsV2Editor"
|
||||
tabIndex={0}
|
||||
ref={this.computedPropertiesDiv}
|
||||
data-test="computed-properties-editor"
|
||||
></div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as React from "react";
|
||||
import { MessageBar, MessageBarType } from "@fluentui/react";
|
||||
import * as React from "react";
|
||||
import { handleError } from "../../../../../Common/ErrorHandlingUtils";
|
||||
import {
|
||||
mongoIndexTransformationRefreshingMessage,
|
||||
renderMongoIndexTransformationRefreshMessage,
|
||||
} from "../../SettingsRenderUtils";
|
||||
import { handleError } from "../../../../../Common/ErrorHandlingUtils";
|
||||
import { isIndexTransforming } from "../../SettingsUtils";
|
||||
|
||||
export interface IndexingPolicyRefreshComponentProps {
|
||||
|
||||
@@ -302,8 +302,8 @@ export class SubSettingsComponent extends React.Component<SubSettingsComponentPr
|
||||
);
|
||||
|
||||
private geoSpatialConfigTypeChoiceGroupOptions: IChoiceGroupOption[] = [
|
||||
{ key: GeospatialConfigType.Geography, text: "Geography" },
|
||||
{ key: GeospatialConfigType.Geometry, text: "Geometry" },
|
||||
{ key: GeospatialConfigType.Geography, text: "Geography", ariaLabel: "geography-option" },
|
||||
{ key: GeospatialConfigType.Geometry, text: "Geometry", ariaLabel: "geometry-option" },
|
||||
];
|
||||
|
||||
private getGeoSpatialComponent = (): JSX.Element => (
|
||||
|
||||
@@ -103,7 +103,10 @@ export const ThroughputBucketsComponent: FC<ThroughputBucketsComponentProps> = (
|
||||
offText="Inactive"
|
||||
checked={bucket.maxThroughputPercentage !== 100}
|
||||
onChange={(event, checked) => onToggle(bucket.id, checked)}
|
||||
styles={{ root: { marginBottom: 0 }, text: { fontSize: 12 } }}
|
||||
styles={{
|
||||
root: { marginBottom: 0 },
|
||||
text: { fontSize: 12, color: "var(--colorNeutralForeground1)" },
|
||||
}}
|
||||
></Toggle>
|
||||
</Stack>
|
||||
))}
|
||||
|
||||
@@ -31,6 +31,7 @@ exports[`ComputedPropertiesComponent renders 1`] = `
|
||||
</Text>
|
||||
<div
|
||||
className="settingsV2Editor"
|
||||
data-test="computed-properties-editor"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -167,10 +167,12 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = `
|
||||
options={
|
||||
[
|
||||
{
|
||||
"ariaLabel": "geography-option",
|
||||
"key": "Geography",
|
||||
"text": "Geography",
|
||||
},
|
||||
{
|
||||
"ariaLabel": "geometry-option",
|
||||
"key": "Geometry",
|
||||
"text": "Geometry",
|
||||
},
|
||||
@@ -652,10 +654,12 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = `
|
||||
options={
|
||||
[
|
||||
{
|
||||
"ariaLabel": "geography-option",
|
||||
"key": "Geography",
|
||||
"text": "Geography",
|
||||
},
|
||||
{
|
||||
"ariaLabel": "geometry-option",
|
||||
"key": "Geometry",
|
||||
"text": "Geometry",
|
||||
},
|
||||
@@ -1224,10 +1228,12 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = `
|
||||
options={
|
||||
[
|
||||
{
|
||||
"ariaLabel": "geography-option",
|
||||
"key": "Geography",
|
||||
"text": "Geography",
|
||||
},
|
||||
{
|
||||
"ariaLabel": "geometry-option",
|
||||
"key": "Geometry",
|
||||
"text": "Geometry",
|
||||
},
|
||||
@@ -1760,10 +1766,12 @@ exports[`SubSettingsComponent renders 1`] = `
|
||||
options={
|
||||
[
|
||||
{
|
||||
"ariaLabel": "geography-option",
|
||||
"key": "Geography",
|
||||
"text": "Geography",
|
||||
},
|
||||
{
|
||||
"ariaLabel": "geometry-option",
|
||||
"key": "Geometry",
|
||||
"text": "Geometry",
|
||||
},
|
||||
@@ -2330,10 +2338,12 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
|
||||
options={
|
||||
[
|
||||
{
|
||||
"ariaLabel": "geography-option",
|
||||
"key": "Geography",
|
||||
"text": "Geography",
|
||||
},
|
||||
{
|
||||
"ariaLabel": "geometry-option",
|
||||
"key": "Geometry",
|
||||
"text": "Geometry",
|
||||
},
|
||||
|
||||
@@ -153,6 +153,16 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"partitionKey",
|
||||
],
|
||||
"rawDataModel": {
|
||||
"indexingPolicy": {
|
||||
"automatic": true,
|
||||
"compositeIndexes": [],
|
||||
"excludedPaths": [],
|
||||
"fullTextIndexes": [],
|
||||
"includedPaths": [],
|
||||
"indexingMode": "consistent",
|
||||
"spatialIndexes": [],
|
||||
"vectorIndexes": [],
|
||||
},
|
||||
"uniqueKeyPolicy": {
|
||||
"uniqueKeys": [
|
||||
{
|
||||
@@ -264,6 +274,16 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"partitionKey",
|
||||
],
|
||||
"rawDataModel": {
|
||||
"indexingPolicy": {
|
||||
"automatic": true,
|
||||
"compositeIndexes": [],
|
||||
"excludedPaths": [],
|
||||
"fullTextIndexes": [],
|
||||
"includedPaths": [],
|
||||
"indexingMode": "consistent",
|
||||
"spatialIndexes": [],
|
||||
"vectorIndexes": [],
|
||||
},
|
||||
"uniqueKeyPolicy": {
|
||||
"uniqueKeys": [
|
||||
{
|
||||
@@ -476,6 +496,16 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"partitionKey",
|
||||
],
|
||||
"rawDataModel": {
|
||||
"indexingPolicy": {
|
||||
"automatic": true,
|
||||
"compositeIndexes": [],
|
||||
"excludedPaths": [],
|
||||
"fullTextIndexes": [],
|
||||
"includedPaths": [],
|
||||
"indexingMode": "consistent",
|
||||
"spatialIndexes": [],
|
||||
"vectorIndexes": [],
|
||||
},
|
||||
"uniqueKeyPolicy": {
|
||||
"uniqueKeys": [
|
||||
{
|
||||
@@ -653,6 +683,16 @@ exports[`SettingsComponent renders 1`] = `
|
||||
"partitionKey",
|
||||
],
|
||||
"rawDataModel": {
|
||||
"indexingPolicy": {
|
||||
"automatic": true,
|
||||
"compositeIndexes": [],
|
||||
"excludedPaths": [],
|
||||
"fullTextIndexes": [],
|
||||
"includedPaths": [],
|
||||
"indexingMode": "consistent",
|
||||
"spatialIndexes": [],
|
||||
"vectorIndexes": [],
|
||||
},
|
||||
"uniqueKeyPolicy": {
|
||||
"uniqueKeys": [
|
||||
{
|
||||
|
||||
@@ -53,6 +53,7 @@ type VectorEmbeddingPolicyProperty = "dataType" | "distanceFunction" | "indexTyp
|
||||
const labelStyles = {
|
||||
root: {
|
||||
fontSize: 12,
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -63,6 +64,8 @@ const textFieldStyles: IStyleFunctionOrObject<ITextFieldStyleProps, ITextFieldSt
|
||||
field: {
|
||||
fontSize: 12,
|
||||
padding: "0 8px",
|
||||
backgroundColor: "var(--colorNeutralBackground1)",
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -437,13 +437,14 @@ export default class Explorer {
|
||||
public onRefreshResourcesClick = async (): Promise<void> => {
|
||||
if (isFabricMirroredKey()) {
|
||||
scheduleRefreshFabricToken(true).then(() => this.refreshAllDatabases());
|
||||
return;
|
||||
} else {
|
||||
await (userContext.authType === AuthType.ResourceToken
|
||||
? this.refreshDatabaseForResourceToken()
|
||||
: this.refreshAllDatabases());
|
||||
await this.refreshNotebookList();
|
||||
}
|
||||
|
||||
await (userContext.authType === AuthType.ResourceToken
|
||||
? this.refreshDatabaseForResourceToken()
|
||||
: this.refreshAllDatabases());
|
||||
await this.refreshNotebookList();
|
||||
logConsoleInfo("Successfully refreshed databases");
|
||||
};
|
||||
|
||||
// Facade
|
||||
|
||||
@@ -853,7 +853,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
|
||||
{!isSynapseLinkEnabled() && (
|
||||
<Stack className="panelGroupSpacing">
|
||||
<Text variant="small">
|
||||
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
Azure Synapse Link is required for creating an analytical store{" "}
|
||||
{getCollectionName().toLocaleLowerCase()}. Enable Synapse Link for this Cosmos DB account. <br />
|
||||
<Link
|
||||
|
||||
@@ -475,6 +475,11 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
|
||||
className="panelGroupSpacing"
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
{
|
||||
"color": "var(--colorNeutralForeground1)",
|
||||
}
|
||||
}
|
||||
variant="small"
|
||||
>
|
||||
Azure Synapse Link is required for creating an analytical store
|
||||
|
||||
@@ -8,7 +8,6 @@ describe("PaneContainerComponent test", () => {
|
||||
headerText: "test",
|
||||
panelContent: <div></div>,
|
||||
isOpen: true,
|
||||
isLightDismiss: true,
|
||||
hasConsole: false,
|
||||
isConsoleExpanded: false,
|
||||
};
|
||||
@@ -21,7 +20,6 @@ describe("PaneContainerComponent test", () => {
|
||||
headerText: "test",
|
||||
panelContent: <div></div>,
|
||||
isOpen: true,
|
||||
isLightDismiss: true,
|
||||
hasConsole: true,
|
||||
isConsoleExpanded: false,
|
||||
};
|
||||
@@ -34,7 +32,6 @@ describe("PaneContainerComponent test", () => {
|
||||
headerText: "test",
|
||||
panelContent: undefined,
|
||||
isOpen: true,
|
||||
isLightDismiss: true,
|
||||
hasConsole: true,
|
||||
isConsoleExpanded: false,
|
||||
};
|
||||
@@ -47,7 +44,6 @@ describe("PaneContainerComponent test", () => {
|
||||
headerText: "test",
|
||||
panelContent: <div></div>,
|
||||
isOpen: true,
|
||||
isLightDismiss: true,
|
||||
hasConsole: true,
|
||||
isConsoleExpanded: true,
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ export interface PanelContainerProps {
|
||||
isConsoleExpanded: boolean;
|
||||
isOpen: boolean;
|
||||
hasConsole: boolean;
|
||||
isLightDismiss: boolean;
|
||||
isConsoleAnimationFinished?: boolean;
|
||||
panelWidth?: string;
|
||||
onRenderNavigationContent?: IRenderFunction<IPanelProps>;
|
||||
@@ -59,7 +58,7 @@ export class PanelContainerComponent extends React.Component<PanelContainerProps
|
||||
headerText={this.props.headerText}
|
||||
isOpen={this.props.isOpen}
|
||||
onDismiss={this.onDissmiss}
|
||||
isLightDismiss={this.props.isLightDismiss}
|
||||
isLightDismiss
|
||||
type={PanelType.custom}
|
||||
closeButtonAriaLabel={`Close ${this.props.headerText}`}
|
||||
customWidth={this.props.panelWidth ? this.props.panelWidth : "440px"}
|
||||
@@ -141,11 +140,10 @@ export class PanelContainerComponent extends React.Component<PanelContainerProps
|
||||
export const SidePanel: React.FC = () => {
|
||||
const isConsoleExpanded = useNotificationConsole((state) => state.isExpanded);
|
||||
const isConsoleAnimationFinished = useNotificationConsole((state) => state.consoleAnimationFinished);
|
||||
const { isOpen, hasConsole, isLightDismiss, panelContent, panelWidth, headerText } = useSidePanel((state) => {
|
||||
const { isOpen, hasConsole, panelContent, panelWidth, headerText } = useSidePanel((state) => {
|
||||
return {
|
||||
isOpen: state.isOpen,
|
||||
hasConsole: state.hasConsole,
|
||||
isLightDismiss: state.isLightDismiss,
|
||||
panelContent: state.panelContent,
|
||||
headerText: state.headerText,
|
||||
panelWidth: state.panelWidth,
|
||||
@@ -156,7 +154,6 @@ export const SidePanel: React.FC = () => {
|
||||
return (
|
||||
<PanelContainerComponent
|
||||
hasConsole={hasConsole}
|
||||
isLightDismiss={isLightDismiss}
|
||||
isOpen={isOpen}
|
||||
panelContent={panelContent}
|
||||
headerText={headerText}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import LoadingIndicator_3Squares from "../../../images/LoadingIndicator_3Squares.gif";
|
||||
|
||||
export const PanelLoadingScreen: React.FunctionComponent = () => (
|
||||
<div id="loadingScreen" className="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer">
|
||||
<div id="loadingScreen" className="dataExplorerLoaderContainer dataExplorerLoaderforcopyJobs">
|
||||
<img className="dataExplorerLoader" src={LoadingIndicator_3Squares} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -205,7 +205,7 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ onUpl
|
||||
tooltip="Select one or more JSON files to upload. Each file can contain a single JSON document or an array of JSON documents. The combined size of all files in an individual upload operation must be less than 2 MB. You can perform multiple upload operations for larger data sets."
|
||||
/>
|
||||
{uploadFileData?.length > 0 && (
|
||||
<div className="fileUploadSummaryContainer">
|
||||
<div className="fileUploadSummaryContainer" data-test="file-upload-status">
|
||||
<b style={{ color: "var(--colorNeutralForeground1)" }}>File upload status</b>
|
||||
<DetailsList
|
||||
items={uploadFileData}
|
||||
|
||||
107
src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx
Normal file
107
src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { CircleFilled } from "@fluentui/react-icons";
|
||||
import type { IIndexMetric } from "Explorer/Tabs/QueryTab/ResultsView";
|
||||
import { useIndexAdvisorStyles } from "Explorer/Tabs/QueryTab/StylesAdvisor";
|
||||
import * as React from "react";
|
||||
|
||||
// SDK response format
|
||||
export interface IndexMetricsResponse {
|
||||
UtilizedIndexes?: {
|
||||
SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>;
|
||||
CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>;
|
||||
};
|
||||
PotentialIndexes?: {
|
||||
SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>;
|
||||
CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIndexMetrics(indexMetrics: IndexMetricsResponse): {
|
||||
included: IIndexMetric[];
|
||||
notIncluded: IIndexMetric[];
|
||||
} {
|
||||
const included: IIndexMetric[] = [];
|
||||
const notIncluded: IIndexMetric[] = [];
|
||||
|
||||
// Process UtilizedIndexes (Included in Current Policy)
|
||||
if (indexMetrics.UtilizedIndexes) {
|
||||
// Single indexes
|
||||
indexMetrics.UtilizedIndexes.SingleIndexes?.forEach((index) => {
|
||||
included.push({
|
||||
index: index.IndexSpec,
|
||||
impact: index.IndexImpactScore || "Utilized",
|
||||
section: "Included",
|
||||
path: index.IndexSpec,
|
||||
});
|
||||
});
|
||||
|
||||
// Composite indexes
|
||||
indexMetrics.UtilizedIndexes.CompositeIndexes?.forEach((index) => {
|
||||
const compositeSpec = index.IndexSpecs.join(", ");
|
||||
included.push({
|
||||
index: compositeSpec,
|
||||
impact: index.IndexImpactScore || "Utilized",
|
||||
section: "Included",
|
||||
composite: index.IndexSpecs.map((spec) => {
|
||||
const [path, order] = spec.trim().split(/\s+/);
|
||||
return {
|
||||
path: path.trim(),
|
||||
order: order?.toLowerCase() === "desc" ? "descending" : "ascending",
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Process PotentialIndexes (Not Included in Current Policy)
|
||||
if (indexMetrics.PotentialIndexes) {
|
||||
// Single indexes
|
||||
indexMetrics.PotentialIndexes.SingleIndexes?.forEach((index) => {
|
||||
notIncluded.push({
|
||||
index: index.IndexSpec,
|
||||
impact: index.IndexImpactScore || "Unknown",
|
||||
section: "Not Included",
|
||||
path: index.IndexSpec,
|
||||
});
|
||||
});
|
||||
|
||||
// Composite indexes
|
||||
indexMetrics.PotentialIndexes.CompositeIndexes?.forEach((index) => {
|
||||
const compositeSpec = index.IndexSpecs.join(", ");
|
||||
notIncluded.push({
|
||||
index: compositeSpec,
|
||||
impact: index.IndexImpactScore || "Unknown",
|
||||
section: "Not Included",
|
||||
composite: index.IndexSpecs.map((spec) => {
|
||||
const [path, order] = spec.trim().split(/\s+/);
|
||||
return {
|
||||
path: path.trim(),
|
||||
order: order?.toLowerCase() === "desc" ? "descending" : "ascending",
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { included, notIncluded };
|
||||
}
|
||||
|
||||
export const renderImpactDots = (impact: string): JSX.Element => {
|
||||
const style = useIndexAdvisorStyles();
|
||||
let count = 0;
|
||||
|
||||
if (impact === "High") {
|
||||
count = 3;
|
||||
} else if (impact === "Medium") {
|
||||
count = 2;
|
||||
} else if (impact === "Low") {
|
||||
count = 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.indexAdvisorImpactDots}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<CircleFilled key={i} className={style.indexAdvisorImpactDot} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,18 +3,21 @@ import QueryError from "Common/QueryError";
|
||||
import { IndeterminateProgressBar } from "Explorer/Controls/IndeterminateProgressBar";
|
||||
import { MessageBanner } from "Explorer/Controls/MessageBanner";
|
||||
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||
import useZoomLevel from "hooks/useZoomLevel";
|
||||
import React from "react";
|
||||
import { conditionalClass } from "Utils/StyleUtils";
|
||||
import RunQuery from "../../../../images/RunQuery.png";
|
||||
import { QueryResults } from "../../../Contracts/ViewModels";
|
||||
import { ErrorList } from "./ErrorList";
|
||||
import { ResultsView } from "./ResultsView";
|
||||
import useZoomLevel from "hooks/useZoomLevel";
|
||||
import { conditionalClass } from "Utils/StyleUtils";
|
||||
|
||||
export interface ResultsViewProps {
|
||||
isMongoDB: boolean;
|
||||
queryResults: QueryResults;
|
||||
executeQueryDocumentsPage: (firstItemIndex: number) => Promise<void>;
|
||||
queryEditorContent?: string;
|
||||
databaseId?: string;
|
||||
containerId?: string;
|
||||
}
|
||||
|
||||
interface QueryResultProps extends ResultsViewProps {
|
||||
@@ -49,6 +52,8 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
|
||||
queryResults,
|
||||
executeQueryDocumentsPage,
|
||||
isExecuting,
|
||||
databaseId,
|
||||
containerId,
|
||||
}: QueryResultProps): JSX.Element => {
|
||||
const styles = useQueryTabStyles();
|
||||
const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent);
|
||||
@@ -91,6 +96,9 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
|
||||
queryResults={queryResults}
|
||||
executeQueryDocumentsPage={executeQueryDocumentsPage}
|
||||
isMongoDB={isMongoDB}
|
||||
queryEditorContent={queryEditorContent}
|
||||
databaseId={databaseId}
|
||||
containerId={containerId}
|
||||
/>
|
||||
) : (
|
||||
<ExecuteQueryCallToAction />
|
||||
|
||||
@@ -52,8 +52,9 @@ describe("QueryTabComponent", () => {
|
||||
copilotVersion: "v3.0",
|
||||
},
|
||||
});
|
||||
|
||||
const propsMock: Readonly<IQueryTabComponentProps> = {
|
||||
collection: { databaseId: "CopilotSampleDB" },
|
||||
collection: { databaseId: "CopilotSampleDB", id: () => "CopilotContainer" },
|
||||
onTabAccessor: () => jest.fn(),
|
||||
isExecutionError: false,
|
||||
tabId: "mockTabId",
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useMonacoTheme } from "hooks/useTheme";
|
||||
import React, { Fragment, createRef } from "react";
|
||||
import "react-splitter-layout/lib/index.css";
|
||||
import { format } from "react-string-format";
|
||||
import create from "zustand";
|
||||
//TODO: Uncomment next two lines when query copilot is reinstated in DE
|
||||
// import QueryCommandIcon from "../../../../images/CopilotCommand.svg";
|
||||
// import LaunchCopilot from "../../../../images/CopilotTabIcon.svg";
|
||||
@@ -57,6 +58,20 @@ import { SaveQueryPane } from "../../Panes/SaveQueryPane/SaveQueryPane";
|
||||
import TabsBase from "../TabsBase";
|
||||
import "./QueryTabComponent.less";
|
||||
|
||||
export interface QueryMetadataStore {
|
||||
userQuery: string;
|
||||
databaseId: string;
|
||||
containerId: string;
|
||||
setMetadata: (query1: string, db: string, container: string) => void;
|
||||
}
|
||||
|
||||
export const useQueryMetadataStore = create<QueryMetadataStore>((set) => ({
|
||||
userQuery: "",
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }),
|
||||
}));
|
||||
|
||||
enum ToggleState {
|
||||
Result,
|
||||
QueryMetrics,
|
||||
@@ -264,6 +279,10 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
}
|
||||
|
||||
public onExecuteQueryClick = async (): Promise<void> => {
|
||||
const query1 = this.state.sqlQueryEditorContent;
|
||||
const db = this.props.collection.databaseId;
|
||||
const container = this.props.collection.id();
|
||||
useQueryMetadataStore.getState().setMetadata(query1, db, container);
|
||||
this._iterator = undefined;
|
||||
|
||||
setTimeout(async () => {
|
||||
@@ -780,6 +799,8 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
errors={this.props.copilotStore?.errors}
|
||||
isExecuting={this.props.copilotStore?.isExecuting}
|
||||
queryResults={this.props.copilotStore?.queryResults}
|
||||
databaseId={this.props.collection.databaseId}
|
||||
containerId={this.props.collection.id()}
|
||||
executeQueryDocumentsPage={(firstItemIndex: number) =>
|
||||
QueryDocumentsPerPage(
|
||||
firstItemIndex,
|
||||
@@ -795,6 +816,8 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
errors={this.state.errors}
|
||||
isExecuting={this.state.isExecuting}
|
||||
queryResults={this.state.queryResults}
|
||||
databaseId={this.props.collection.databaseId}
|
||||
containerId={this.props.collection.id()}
|
||||
executeQueryDocumentsPage={(firstItemIndex: number) =>
|
||||
this._executeQueryDocumentsPage(firstItemIndex)
|
||||
}
|
||||
|
||||
170
src/Explorer/Tabs/QueryTab/ResultsView.test.tsx
Normal file
170
src/Explorer/Tabs/QueryTab/ResultsView.test.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { IndexAdvisorTab } from "Explorer/Tabs/QueryTab/ResultsView";
|
||||
import React from "react";
|
||||
|
||||
const mockReplace = jest.fn();
|
||||
const mockFetchAll = jest.fn();
|
||||
const mockRead = jest.fn();
|
||||
const mockLogConsoleProgress = jest.fn();
|
||||
const mockHandleError = jest.fn();
|
||||
|
||||
const indexMetricsResponse = {
|
||||
UtilizedIndexes: {
|
||||
SingleIndexes: [{ IndexSpec: "/foo/?", IndexImpactScore: "High" }],
|
||||
CompositeIndexes: [{ IndexSpecs: ["/baz/? DESC", "/qux/? ASC"], IndexImpactScore: "Low" }],
|
||||
},
|
||||
PotentialIndexes: {
|
||||
SingleIndexes: [{ IndexSpec: "/bar/?", IndexImpactScore: "Medium" }],
|
||||
CompositeIndexes: [] as Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>,
|
||||
},
|
||||
};
|
||||
|
||||
const mockQueryResults = {
|
||||
documents: [] as unknown[],
|
||||
hasMoreResults: false,
|
||||
itemCount: 0,
|
||||
firstItemIndex: 0,
|
||||
lastItemIndex: 0,
|
||||
requestCharge: 0,
|
||||
activityId: "test-activity-id",
|
||||
};
|
||||
|
||||
mockRead.mockResolvedValue({
|
||||
resource: {
|
||||
indexingPolicy: {
|
||||
automatic: true,
|
||||
indexingMode: "consistent",
|
||||
includedPaths: [{ path: "/*" }, { path: "/foo/?" }],
|
||||
excludedPaths: [],
|
||||
},
|
||||
partitionKey: "pk",
|
||||
},
|
||||
});
|
||||
|
||||
mockReplace.mockResolvedValue({
|
||||
resource: {
|
||||
indexingPolicy: {
|
||||
automatic: true,
|
||||
indexingMode: "consistent",
|
||||
includedPaths: [{ path: "/*" }],
|
||||
excludedPaths: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
jest.mock("Common/CosmosClient", () => ({
|
||||
client: () => ({
|
||||
database: () => ({
|
||||
container: () => ({
|
||||
items: {
|
||||
query: () => ({
|
||||
fetchAll: mockFetchAll,
|
||||
}),
|
||||
},
|
||||
read: mockRead,
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("./StylesAdvisor", () => ({
|
||||
useIndexAdvisorStyles: () => ({}),
|
||||
}));
|
||||
|
||||
jest.mock("../../../Utils/NotificationConsoleUtils", () => ({
|
||||
logConsoleProgress: (...args: unknown[]) => {
|
||||
mockLogConsoleProgress(...args);
|
||||
return () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../Common/ErrorHandlingUtils", () => ({
|
||||
handleError: (...args: unknown[]) => mockHandleError(...args),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchAll.mockResolvedValue({ indexMetrics: indexMetricsResponse });
|
||||
});
|
||||
|
||||
describe("IndexAdvisorTab Basic Tests", () => {
|
||||
test("component renders without crashing", () => {
|
||||
const { container } = render(
|
||||
<IndexAdvisorTab queryEditorContent="SELECT * FROM c" databaseId="db1" containerId="col1" />,
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
test("renders component and handles missing parameters", () => {
|
||||
const { container } = render(<IndexAdvisorTab />);
|
||||
expect(container).toBeTruthy();
|
||||
// Should not crash when parameters are missing
|
||||
});
|
||||
|
||||
test("fetches index metrics with query results", async () => {
|
||||
render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="db1"
|
||||
containerId="col1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(mockFetchAll).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test("displays content after loading", async () => {
|
||||
render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="db1"
|
||||
containerId="col1"
|
||||
/>,
|
||||
);
|
||||
// Wait for the component to finish loading
|
||||
await waitFor(() => expect(mockFetchAll).toHaveBeenCalled());
|
||||
// Component should have rendered some content
|
||||
expect(screen.getByText(/Index Advisor/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls log console progress when fetching metrics", async () => {
|
||||
render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="db1"
|
||||
containerId="col1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(mockLogConsoleProgress).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test("handles error when fetch fails", async () => {
|
||||
mockFetchAll.mockRejectedValueOnce(new Error("fetch failed"));
|
||||
render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="db1"
|
||||
containerId="col1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(mockHandleError).toHaveBeenCalled(), { timeout: 3000 });
|
||||
});
|
||||
|
||||
test("renders with all required props", () => {
|
||||
const { container } = render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="testDb"
|
||||
containerId="testContainer"
|
||||
/>,
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { CompositePath, IndexingPolicy } from "@azure/cosmos";
|
||||
import { FontIcon } from "@fluentui/react";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DataGrid,
|
||||
DataGridBody,
|
||||
DataGridCell,
|
||||
@@ -8,28 +11,45 @@ import {
|
||||
DataGridRow,
|
||||
SelectTabData,
|
||||
SelectTabEvent,
|
||||
Spinner,
|
||||
Tab,
|
||||
TabList,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumnDefinition,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
createTableColumn,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons";
|
||||
import { ArrowDownloadRegular, ChevronDown20Regular, ChevronRight20Regular, CopyRegular } from "@fluentui/react-icons";
|
||||
import copy from "clipboard-copy";
|
||||
import { HttpHeaders } from "Common/Constants";
|
||||
import MongoUtility from "Common/MongoUtility";
|
||||
import { QueryMetrics } from "Contracts/DataModels";
|
||||
import { QueryResults } from "Contracts/ViewModels";
|
||||
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
|
||||
import {
|
||||
parseIndexMetrics,
|
||||
renderImpactDots,
|
||||
type IndexMetricsResponse,
|
||||
} from "Explorer/Tabs/QueryTab/IndexAdvisorUtils";
|
||||
import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent";
|
||||
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { userContext } from "UserContext";
|
||||
import copy from "clipboard-copy";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { logConsoleProgress } from "Utils/NotificationConsoleUtils";
|
||||
import create from "zustand";
|
||||
import { client } from "../../../Common/CosmosClient";
|
||||
import { handleError } from "../../../Common/ErrorHandlingUtils";
|
||||
import { sampleDataClient } from "../../../Common/SampleDataClient";
|
||||
import { ResultsViewProps } from "./QueryResultSection";
|
||||
|
||||
import { useIndexAdvisorStyles } from "./StylesAdvisor";
|
||||
enum ResultsTabs {
|
||||
Results = "results",
|
||||
QueryStats = "queryStats",
|
||||
IndexAdvisor = "indexadv",
|
||||
}
|
||||
|
||||
const ResultsTab: React.FC<ResultsViewProps> = ({ queryResults, isMongoDB, executeQueryDocumentsPage }) => {
|
||||
const styles = useQueryTabStyles();
|
||||
/* eslint-disable react/prop-types */
|
||||
@@ -523,14 +543,331 @@ const QueryStatsTab: React.FC<Pick<ResultsViewProps, "queryResults">> = ({ query
|
||||
);
|
||||
};
|
||||
|
||||
export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => {
|
||||
export interface IIndexMetric {
|
||||
index: string;
|
||||
impact: string;
|
||||
section: "Included" | "Not Included" | "Header";
|
||||
path?: string;
|
||||
composite?: { path: string; order: string }[];
|
||||
}
|
||||
export const IndexAdvisorTab: React.FC<{
|
||||
queryResults?: QueryResults;
|
||||
queryEditorContent?: string;
|
||||
databaseId?: string;
|
||||
containerId?: string;
|
||||
}> = ({ queryResults, queryEditorContent, databaseId, containerId }) => {
|
||||
const style = useIndexAdvisorStyles();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [indexMetrics, setIndexMetrics] = useState<IndexMetricsResponse | null>(null);
|
||||
const [showIncluded, setShowIncluded] = useState(true);
|
||||
const [showNotIncluded, setShowNotIncluded] = useState(true);
|
||||
const [selectedIndexes, setSelectedIndexes] = useState<IIndexMetric[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
const [updateMessageShown, setUpdateMessageShown] = useState(false);
|
||||
const [included, setIncludedIndexes] = useState<IIndexMetric[]>([]);
|
||||
const [notIncluded, setNotIncludedIndexes] = useState<IIndexMetric[]>([]);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [justUpdatedPolicy, setJustUpdatedPolicy] = useState(false);
|
||||
const indexingMetricsDocLink = "https://learn.microsoft.com/azure/cosmos-db/nosql/index-metrics";
|
||||
|
||||
const fetchIndexMetrics = async () => {
|
||||
if (!queryEditorContent || !databaseId || !containerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const clearMessage = logConsoleProgress(`Querying items with IndexMetrics in container ${containerId}`);
|
||||
try {
|
||||
const querySpec = {
|
||||
query: queryEditorContent,
|
||||
};
|
||||
|
||||
// Use sampleDataClient for CopilotSampleDB, regular client for other databases
|
||||
const cosmosClient = databaseId === "CopilotSampleDB" ? sampleDataClient() : client();
|
||||
|
||||
const sdkResponse = await cosmosClient
|
||||
.database(databaseId)
|
||||
.container(containerId)
|
||||
.items.query(querySpec, {
|
||||
populateIndexMetrics: true,
|
||||
})
|
||||
.fetchAll();
|
||||
|
||||
const parsedMetrics =
|
||||
typeof sdkResponse.indexMetrics === "string" ? JSON.parse(sdkResponse.indexMetrics) : sdkResponse.indexMetrics;
|
||||
|
||||
setIndexMetrics(parsedMetrics);
|
||||
} catch (error) {
|
||||
handleError(error, "queryItemsWithIndexMetrics", `Error querying items from ${containerId}`);
|
||||
} finally {
|
||||
clearMessage();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch index metrics when query results change (i.e., when Execute Query is clicked)
|
||||
useEffect(() => {
|
||||
if (queryEditorContent && databaseId && containerId && queryResults) {
|
||||
fetchIndexMetrics();
|
||||
}
|
||||
}, [queryResults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!indexMetrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { included, notIncluded } = parseIndexMetrics(indexMetrics);
|
||||
setIncludedIndexes(included);
|
||||
setNotIncludedIndexes(notIncluded);
|
||||
if (justUpdatedPolicy) {
|
||||
setJustUpdatedPolicy(false);
|
||||
} else {
|
||||
setUpdateMessageShown(false);
|
||||
}
|
||||
}, [indexMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
const allSelected =
|
||||
notIncluded.length > 0 && notIncluded.every((item) => selectedIndexes.some((s) => s.index === item.index));
|
||||
setSelectAll(allSelected);
|
||||
}, [selectedIndexes, notIncluded]);
|
||||
|
||||
const handleCheckboxChange = (indexObj: IIndexMetric, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIndexes((prev) => [...prev, indexObj]);
|
||||
} else {
|
||||
setSelectedIndexes((prev) => prev.filter((item) => item.index !== indexObj.index));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
setSelectAll(checked);
|
||||
setSelectedIndexes(checked ? notIncluded : []);
|
||||
};
|
||||
|
||||
const handleUpdatePolicy = async () => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const containerRef = client().database(databaseId).container(containerId);
|
||||
const { resource: containerDef } = await containerRef.read();
|
||||
|
||||
const newIncludedPaths = selectedIndexes
|
||||
.filter((index) => !index.composite)
|
||||
.map((index) => {
|
||||
return {
|
||||
path: index.path,
|
||||
};
|
||||
});
|
||||
|
||||
const newCompositeIndexes: CompositePath[][] = selectedIndexes
|
||||
.filter((index) => Array.isArray(index.composite))
|
||||
.map(
|
||||
(index) =>
|
||||
(index.composite as { path: string; order: string }[]).map((comp) => ({
|
||||
path: comp.path,
|
||||
order: comp.order === "descending" ? "descending" : "ascending",
|
||||
})) as CompositePath[],
|
||||
);
|
||||
|
||||
const updatedPolicy: IndexingPolicy = {
|
||||
...containerDef.indexingPolicy,
|
||||
includedPaths: [...(containerDef.indexingPolicy?.includedPaths || []), ...newIncludedPaths],
|
||||
compositeIndexes: [...(containerDef.indexingPolicy?.compositeIndexes || []), ...newCompositeIndexes],
|
||||
automatic: containerDef.indexingPolicy?.automatic ?? true,
|
||||
indexingMode: containerDef.indexingPolicy?.indexingMode ?? "consistent",
|
||||
excludedPaths: containerDef.indexingPolicy?.excludedPaths ?? [],
|
||||
};
|
||||
await containerRef.replace({
|
||||
id: containerId,
|
||||
partitionKey: containerDef.partitionKey,
|
||||
indexingPolicy: updatedPolicy,
|
||||
});
|
||||
useIndexingPolicyStore.getState().setIndexingPolicyFor(containerId, updatedPolicy);
|
||||
const selectedIndexSet = new Set(selectedIndexes.map((s) => s.index));
|
||||
const updatedNotIncluded: typeof notIncluded = [];
|
||||
const newlyIncluded: typeof included = [];
|
||||
for (const item of notIncluded) {
|
||||
if (selectedIndexSet.has(item.index)) {
|
||||
newlyIncluded.push(item);
|
||||
} else {
|
||||
updatedNotIncluded.push(item);
|
||||
}
|
||||
}
|
||||
const newIncluded = [...included, ...newlyIncluded];
|
||||
const newNotIncluded = updatedNotIncluded;
|
||||
setIncludedIndexes(newIncluded);
|
||||
setNotIncludedIndexes(newNotIncluded);
|
||||
setSelectedIndexes([]);
|
||||
setSelectAll(false);
|
||||
setUpdateMessageShown(true);
|
||||
setJustUpdatedPolicy(true);
|
||||
} catch (err) {
|
||||
console.error("Failed to update indexing policy:", err);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderRow = (item: IIndexMetric, index: number) => {
|
||||
const isHeader = item.section === "Header";
|
||||
const isNotIncluded = item.section === "Not Included";
|
||||
|
||||
return (
|
||||
<TableRow key={index}>
|
||||
<TableCell colSpan={2}>
|
||||
<div className={style.indexAdvisorGrid}>
|
||||
{isNotIncluded ? (
|
||||
<Checkbox
|
||||
checked={selectedIndexes.some((selected) => selected.index === item.index)}
|
||||
onChange={(_, data) => handleCheckboxChange(item, data.checked === true)}
|
||||
/>
|
||||
) : isHeader && item.index === "Not Included in Current Policy" && notIncluded.length > 0 ? (
|
||||
<Checkbox checked={selectAll} onChange={(_, data) => handleSelectAll(data.checked === true)} />
|
||||
) : (
|
||||
<div className={style.indexAdvisorCheckboxSpacer}></div>
|
||||
)}
|
||||
{isHeader ? (
|
||||
<span
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => {
|
||||
if (item.index === "Included in Current Policy") {
|
||||
setShowIncluded(!showIncluded);
|
||||
} else if (item.index === "Not Included in Current Policy") {
|
||||
setShowNotIncluded(!showNotIncluded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.index === "Included in Current Policy" ? (
|
||||
showIncluded ? (
|
||||
<ChevronDown20Regular />
|
||||
) : (
|
||||
<ChevronRight20Regular />
|
||||
)
|
||||
) : showNotIncluded ? (
|
||||
<ChevronDown20Regular />
|
||||
) : (
|
||||
<ChevronRight20Regular />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<div className={style.indexAdvisorChevronSpacer}></div>
|
||||
)}
|
||||
<div className={isHeader ? style.indexAdvisorRowBold : style.indexAdvisorRowNormal}>{item.index}</div>
|
||||
<div className={isHeader ? style.indexAdvisorRowImpactHeader : style.indexAdvisorRowImpact}>
|
||||
{!isHeader && item.impact}
|
||||
</div>
|
||||
<div>{!isHeader && renderImpactDots(item.impact)}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
const indexMetricItems = React.useMemo(() => {
|
||||
const items: IIndexMetric[] = [];
|
||||
items.push({ index: "Not Included in Current Policy", impact: "", section: "Header" });
|
||||
if (showNotIncluded) {
|
||||
notIncluded.forEach((item) => items.push({ ...item, section: "Not Included" }));
|
||||
}
|
||||
items.push({ index: "Included in Current Policy", impact: "", section: "Header" });
|
||||
if (showIncluded) {
|
||||
included.forEach((item) => items.push({ ...item, section: "Included" }));
|
||||
}
|
||||
return items;
|
||||
}, [included, notIncluded, showIncluded, showNotIncluded]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<Spinner
|
||||
size="small"
|
||||
style={
|
||||
{
|
||||
"--spinner-size": "16px",
|
||||
"--spinner-thickness": "2px",
|
||||
"--spinner-color": "#0078D4",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={style.indexAdvisorMessage}>
|
||||
{updateMessageShown ? (
|
||||
<>
|
||||
<span className={style.indexAdvisorSuccessIcon}>
|
||||
<FontIcon iconName="CheckMark" style={{ color: "white", fontSize: 12 }} />
|
||||
</span>
|
||||
<span>
|
||||
Your indexing policy has been updated with the new included paths. You may review the changes in Scale &
|
||||
Settings.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>
|
||||
Index Advisor uses Indexing Metrics to suggest query paths that, when included in your indexing policy,
|
||||
can improve the performance of this query by reducing RU costs and lowering latency.{" "}
|
||||
<a href={indexingMetricsDocLink} target="_blank" rel="noopener noreferrer">
|
||||
Learn more about Indexing Metrics
|
||||
</a>
|
||||
.{" "}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={style.indexAdvisorTitle}>Indexes analysis</div>
|
||||
<Table className={style.indexAdvisorTable}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={2}>
|
||||
<div className={style.indexAdvisorGrid}>
|
||||
<div className={style.indexAdvisorCheckboxSpacer}></div>
|
||||
<div className={style.indexAdvisorChevronSpacer}></div>
|
||||
<div>Index</div>
|
||||
<div>
|
||||
<span style={{ whiteSpace: "nowrap" }}>Estimated Impact</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{indexMetricItems.map(renderRow)}</TableBody>
|
||||
</Table>
|
||||
{selectedIndexes.length > 0 && (
|
||||
<div className={style.indexAdvisorButtonBar}>
|
||||
{isUpdating ? (
|
||||
<div className={style.indexAdvisorButtonSpinner}>
|
||||
<Spinner size="tiny" />{" "}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleUpdatePolicy} className={style.indexAdvisorButton}>
|
||||
Update Indexing Policy with selected index(es)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const ResultsView: React.FC<ResultsViewProps> = ({
|
||||
isMongoDB,
|
||||
queryResults,
|
||||
executeQueryDocumentsPage,
|
||||
queryEditorContent,
|
||||
databaseId,
|
||||
containerId,
|
||||
}) => {
|
||||
const styles = useQueryTabStyles();
|
||||
const [activeTab, setActiveTab] = useState<ResultsTabs>(ResultsTabs.Results);
|
||||
|
||||
const onTabSelect = useCallback((event: SelectTabEvent, data: SelectTabData) => {
|
||||
setActiveTab(data.value as ResultsTabs);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div data-test="QueryTab/ResultsPane/ResultsView" className={styles.queryResultsTabPanel}>
|
||||
<TabList selectedValue={activeTab} onTabSelect={onTabSelect}>
|
||||
@@ -548,6 +885,13 @@ export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResult
|
||||
>
|
||||
Query Stats
|
||||
</Tab>
|
||||
<Tab
|
||||
data-test="QueryTab/ResultsPane/ResultsView/IndexAdvisorTab"
|
||||
id={ResultsTabs.IndexAdvisor}
|
||||
value={ResultsTabs.IndexAdvisor}
|
||||
>
|
||||
Index Advisor
|
||||
</Tab>
|
||||
</TabList>
|
||||
<div className={styles.queryResultsTabContentContainer}>
|
||||
{activeTab === ResultsTabs.Results && (
|
||||
@@ -558,7 +902,30 @@ export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResult
|
||||
/>
|
||||
)}
|
||||
{activeTab === ResultsTabs.QueryStats && <QueryStatsTab queryResults={queryResults} />}
|
||||
{activeTab === ResultsTabs.IndexAdvisor && (
|
||||
<IndexAdvisorTab
|
||||
queryResults={queryResults}
|
||||
queryEditorContent={queryEditorContent}
|
||||
databaseId={databaseId}
|
||||
containerId={containerId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export interface IndexingPolicyStore {
|
||||
indexingPolicies: { [containerId: string]: IndexingPolicy };
|
||||
setIndexingPolicyFor: (containerId: string, indexingPolicy: IndexingPolicy) => void;
|
||||
}
|
||||
|
||||
export const useIndexingPolicyStore = create<IndexingPolicyStore>((set) => ({
|
||||
indexingPolicies: {},
|
||||
setIndexingPolicyFor: (containerId, indexingPolicy) =>
|
||||
set((state) => ({
|
||||
indexingPolicies: {
|
||||
...state.indexingPolicies,
|
||||
[containerId]: { ...indexingPolicy },
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
95
src/Explorer/Tabs/QueryTab/StylesAdvisor.ts
Normal file
95
src/Explorer/Tabs/QueryTab/StylesAdvisor.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { makeStyles } from "@fluentui/react-components";
|
||||
export type IndexAdvisorStyles = ReturnType<typeof useIndexAdvisorStyles>;
|
||||
export const useIndexAdvisorStyles = makeStyles({
|
||||
indexAdvisorMessage: {
|
||||
padding: "1rem",
|
||||
fontSize: "1.2rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
indexAdvisorSuccessIcon: {
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#107C10",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
indexAdvisorTitle: {
|
||||
padding: "1rem",
|
||||
fontSize: "1.3rem",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
indexAdvisorTable: {
|
||||
display: "block",
|
||||
alignItems: "center",
|
||||
marginBottom: "7rem",
|
||||
},
|
||||
indexAdvisorGrid: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "30px 30px 1fr 50px 120px",
|
||||
alignItems: "center",
|
||||
gap: "15px",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
indexAdvisorCheckboxSpacer: {
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
},
|
||||
indexAdvisorChevronSpacer: {
|
||||
width: "24px",
|
||||
},
|
||||
indexAdvisorRowBold: {
|
||||
fontWeight: "bold",
|
||||
},
|
||||
indexAdvisorRowNormal: {
|
||||
fontWeight: "normal",
|
||||
},
|
||||
indexAdvisorRowImpactHeader: {
|
||||
fontSize: 0,
|
||||
},
|
||||
indexAdvisorRowImpact: {
|
||||
fontWeight: "normal",
|
||||
},
|
||||
indexAdvisorImpactDot: {
|
||||
color: "#0078D4",
|
||||
fontSize: "12px",
|
||||
display: "inline-flex",
|
||||
},
|
||||
indexAdvisorImpactDots: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
},
|
||||
indexAdvisorButtonBar: {
|
||||
padding: "1rem",
|
||||
marginTop: "-7rem",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
indexAdvisorButtonSpinner: {
|
||||
marginTop: "1rem",
|
||||
minWidth: "320px",
|
||||
minHeight: "40px",
|
||||
display: "flex",
|
||||
alignItems: "left",
|
||||
justifyContent: "left",
|
||||
marginLeft: "10rem",
|
||||
},
|
||||
indexAdvisorButton: {
|
||||
backgroundColor: "#0078D4",
|
||||
color: "white",
|
||||
padding: "8px 16px",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
marginTop: "1rem",
|
||||
fontSize: "1rem",
|
||||
fontWeight: 500,
|
||||
transition: "background 0.2s",
|
||||
":hover": {
|
||||
backgroundColor: "#005a9e",
|
||||
},
|
||||
},
|
||||
});
|
||||
15
src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts
Normal file
15
src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import create from "zustand";
|
||||
|
||||
interface QueryMetadataStore {
|
||||
userQuery: string;
|
||||
databaseId: string;
|
||||
containerId: string;
|
||||
setMetadata: (query1: string, db: string, container: string) => void;
|
||||
}
|
||||
|
||||
export const useQueryMetadataStore = create<QueryMetadataStore>((set) => ({
|
||||
userQuery: "",
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }),
|
||||
}));
|
||||
@@ -435,7 +435,6 @@ export default class StoredProcedureTabComponent extends React.Component<
|
||||
});
|
||||
useCommandBar.getState().setContextButtons(this.getTabsButtons());
|
||||
}, 100);
|
||||
|
||||
return createdResource;
|
||||
},
|
||||
(createError) => {
|
||||
|
||||
@@ -128,7 +128,6 @@ const App = (): JSX.Element => {
|
||||
<>
|
||||
<ContainerCopyPanel explorer={explorer} />
|
||||
<SidePanel />
|
||||
<Dialog />
|
||||
</>
|
||||
) : (
|
||||
<DivExplorer explorer={explorer} />
|
||||
|
||||
@@ -4,24 +4,20 @@ export interface SidePanelState {
|
||||
isOpen: boolean;
|
||||
panelWidth: string;
|
||||
hasConsole: boolean;
|
||||
isLightDismiss: boolean;
|
||||
panelContent?: JSX.Element;
|
||||
headerText?: string;
|
||||
setHeaderText: (headerText: string) => void;
|
||||
openSidePanel: (headerText: string, panelContent: JSX.Element, panelWidth?: string, onClose?: () => void) => void;
|
||||
closeSidePanel: () => void;
|
||||
setPanelHasConsole: (hasConsole: boolean) => void;
|
||||
setLightDismiss: (isLightDismiss: boolean) => void;
|
||||
getRef?: React.RefObject<HTMLElement>; // Optional ref for focusing the last element.
|
||||
}
|
||||
export const useSidePanel: UseStore<SidePanelState> = create((set) => ({
|
||||
isOpen: false,
|
||||
panelWidth: "440px",
|
||||
hasConsole: true,
|
||||
isLightDismiss: true,
|
||||
setHeaderText: (headerText: string) => set((state) => ({ ...state, headerText })),
|
||||
setPanelHasConsole: (hasConsole: boolean) => set((state) => ({ ...state, hasConsole })),
|
||||
setLightDismiss: (isLightDismiss: boolean) => set((state) => ({ ...state, isLightDismiss })),
|
||||
openSidePanel: (headerText, panelContent, panelWidth = "440px") =>
|
||||
set((state) => ({ ...state, headerText, panelContent, panelWidth, isOpen: true })),
|
||||
closeSidePanel: () => {
|
||||
|
||||
@@ -316,11 +316,6 @@ body.isDarkMode {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
// High specificity override for any nested elements
|
||||
* {
|
||||
color: var(--colorNeutralForeground1);
|
||||
}
|
||||
|
||||
// Ensure links maintain proper colors
|
||||
.ms-Link {
|
||||
color: var(--colorBrandForeground1);
|
||||
@@ -438,7 +433,6 @@ body.isDarkMode {
|
||||
|
||||
button {
|
||||
&:not(.ms-Button):not(.ms-IconButton) {
|
||||
background-color: var(--colorNeutralBackground1);
|
||||
color: var(--colorNeutralForeground1);
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
--colorCompoundBrandStroke1: @SelectionColor;
|
||||
--colorBrandForeground1: @LinkColor;
|
||||
--colorPaletteRedForeground1: @ErrorColor;
|
||||
--colorSuccessGreen: #107c10;
|
||||
--overlayBackground: rgba(0, 0, 0, 0.4);
|
||||
--colorBrandBackground: @SelectionColor;
|
||||
--colorBrandBackgroundHover: @AccentMediumHigh;
|
||||
@@ -32,6 +33,7 @@ body.isDarkMode {
|
||||
--colorCompoundBrandStroke1: #4db6e8;
|
||||
--colorBrandForeground1: #4db6e8;
|
||||
--colorPaletteRedForeground1: #f25d5d;
|
||||
--colorSuccessGreen: #107c10;
|
||||
--overlayBackground: rgba(0, 0, 0, 0.4);
|
||||
--colorBrandBackground: #0078d4;
|
||||
--colorBrandBackgroundHover: #106ebe;
|
||||
|
||||
@@ -8,7 +8,8 @@ test("Cassandra keyspace and table CRUD", async ({ page }) => {
|
||||
|
||||
const explorer = await DataExplorer.open(page, TestAccount.Cassandra);
|
||||
|
||||
await explorer.globalCommandButton("New Table").click();
|
||||
const newTableButton = await explorer.globalCommandButton("New Table");
|
||||
await newTableButton.click();
|
||||
await explorer.whilePanelOpen(
|
||||
"Add Table",
|
||||
async (panel, okButton) => {
|
||||
|
||||
104
test/fx.ts
104
test/fx.ts
@@ -378,7 +378,9 @@ type PanelOpenOptions = {
|
||||
|
||||
export enum CommandBarButton {
|
||||
Save = "Save",
|
||||
Execute = "Execute",
|
||||
ExecuteQuery = "Execute Query",
|
||||
UploadItem = "Upload Item",
|
||||
}
|
||||
|
||||
/** Helper class that provides locator methods for DataExplorer components, on top of a Frame */
|
||||
@@ -405,8 +407,9 @@ export class DataExplorer {
|
||||
*
|
||||
* There's only a single "primary" button, but we still require you to pass the label to confirm you're selecting the right button.
|
||||
*/
|
||||
globalCommandButton(label: string): Locator {
|
||||
return this.frame.getByTestId("GlobalCommands").getByText(label);
|
||||
async globalCommandButton(label: string): Promise<Locator> {
|
||||
await this.frame.getByTestId("GlobalCommands").click();
|
||||
return this.frame.getByRole("menuitem", { name: label });
|
||||
}
|
||||
|
||||
/** Select the command bar button with the specified label */
|
||||
@@ -512,6 +515,15 @@ export class DataExplorer {
|
||||
const containerNode = await this.waitForContainerNode(context.database.id, context.container.id);
|
||||
await containerNode.expand();
|
||||
|
||||
// refresh tree to remove deleted database
|
||||
const consoleMessages = await this.getNotificationConsoleMessages();
|
||||
const refreshButton = this.frame.getByTestId("Sidebar/RefreshButton");
|
||||
await refreshButton.click();
|
||||
await expect(consoleMessages).toContainText("Successfully refreshed databases", {
|
||||
timeout: ONE_MINUTE_MS,
|
||||
});
|
||||
await this.collapseNotificationConsole();
|
||||
|
||||
const scaleAndSettingsButton = this.frame.getByTestId(
|
||||
`TreeNode:${context.database.id}/${context.container.id}/Scale & Settings`,
|
||||
);
|
||||
@@ -519,10 +531,44 @@ export class DataExplorer {
|
||||
}
|
||||
|
||||
/** Gets the console message element */
|
||||
getConsoleMessage(): Locator {
|
||||
getConsoleHeaderStatus(): Locator {
|
||||
return this.frame.getByTestId("notification-console/header-status");
|
||||
}
|
||||
|
||||
async expandNotificationConsole(): Promise<void> {
|
||||
await this.setNotificationConsoleExpanded(true);
|
||||
}
|
||||
|
||||
async collapseNotificationConsole(): Promise<void> {
|
||||
await this.setNotificationConsoleExpanded(false);
|
||||
}
|
||||
|
||||
async setNotificationConsoleExpanded(expanded: boolean): Promise<void> {
|
||||
const notificationConsoleToggleButton = this.frame.getByTestId("NotificationConsole/ExpandCollapseButton");
|
||||
const alt = await notificationConsoleToggleButton.locator("img").getAttribute("alt");
|
||||
|
||||
// When expanded, the icon says "Collapse icon"
|
||||
if (expanded && alt === "Expand icon") {
|
||||
await notificationConsoleToggleButton.click();
|
||||
} else if (!expanded && alt === "Collapse icon") {
|
||||
await notificationConsoleToggleButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
async getNotificationConsoleMessages(): Promise<Locator> {
|
||||
await this.setNotificationConsoleExpanded(true);
|
||||
return this.frame.getByTestId("NotificationConsole/Contents");
|
||||
}
|
||||
|
||||
async getDropdownItemByName(name: string, ariaLabel?: string): Promise<Locator> {
|
||||
const dropdownItemsWrapper = this.frame.locator("div.ms-Dropdown-items");
|
||||
if (ariaLabel) {
|
||||
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(ariaLabel);
|
||||
}
|
||||
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
|
||||
return containerDropdownItems.filter({ hasText: name });
|
||||
}
|
||||
|
||||
/** Waits for the Data Explorer app to load */
|
||||
static async waitForExplorer(page: Page, options?: TestExplorerUrlOptions): Promise<DataExplorer> {
|
||||
const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle();
|
||||
@@ -558,30 +604,46 @@ export async function waitForApiResponse(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
payloadValidator?: (payload: any) => boolean,
|
||||
) {
|
||||
return page.waitForResponse(async (response) => {
|
||||
const request = response.request();
|
||||
|
||||
if (!request.url().includes(urlPattern)) {
|
||||
return false;
|
||||
try {
|
||||
// Check if page is still valid before waiting
|
||||
if (page.isClosed()) {
|
||||
throw new Error(`Page is closed, cannot wait for API response: ${urlPattern}`);
|
||||
}
|
||||
|
||||
if (method && request.method() !== method) {
|
||||
return false;
|
||||
}
|
||||
return page.waitForResponse(
|
||||
async (response) => {
|
||||
const request = response.request();
|
||||
|
||||
if (payloadValidator && (request.method() === "POST" || request.method() === "PUT")) {
|
||||
const postData = request.postData();
|
||||
if (postData) {
|
||||
try {
|
||||
const payload = JSON.parse(postData);
|
||||
return payloadValidator(payload);
|
||||
} catch {
|
||||
if (!request.url().includes(urlPattern)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (method && request.method() !== method) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payloadValidator && (request.method() === "POST" || request.method() === "PUT")) {
|
||||
const postData = request.postData();
|
||||
if (postData) {
|
||||
try {
|
||||
const payload = JSON.parse(postData);
|
||||
return payloadValidator(payload);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: 60 * 1000 },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("Target page, context or browser has been closed")) {
|
||||
console.warn("Page was closed while waiting for API response:", urlPattern);
|
||||
throw new Error(`Page closed while waiting for API response: ${urlPattern}`);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export async function interceptAndInspectApiRequest(
|
||||
page: Page,
|
||||
|
||||
@@ -9,7 +9,8 @@ test("Gremlin graph CRUD", async ({ page }) => {
|
||||
const explorer = await DataExplorer.open(page, TestAccount.Gremlin);
|
||||
|
||||
// Create new database and graph
|
||||
await explorer.globalCommandButton("New Graph").click();
|
||||
const newGraphButton = await explorer.globalCommandButton("New Graph");
|
||||
await newGraphButton.click();
|
||||
await explorer.whilePanelOpen(
|
||||
"New Graph",
|
||||
async (panel, okButton) => {
|
||||
|
||||
@@ -14,7 +14,8 @@ import { DataExplorer, TEST_AUTOSCALE_THROUGHPUT_RU, TestAccount, generateUnique
|
||||
|
||||
const explorer = await DataExplorer.open(page, accountType);
|
||||
|
||||
await explorer.globalCommandButton("New Collection").click();
|
||||
const newCollectionButton = await explorer.globalCommandButton("New Collection");
|
||||
await newCollectionButton.click();
|
||||
await explorer.whilePanelOpen(
|
||||
"New Collection",
|
||||
async (panel, okButton) => {
|
||||
|
||||
@@ -8,7 +8,8 @@ test("SQL database and container CRUD", async ({ page }) => {
|
||||
|
||||
const explorer = await DataExplorer.open(page, TestAccount.SQL);
|
||||
|
||||
await explorer.globalCommandButton("New Container").click();
|
||||
const newContainerButton = await explorer.globalCommandButton("New Container");
|
||||
await newContainerButton.click();
|
||||
await explorer.whilePanelOpen(
|
||||
"New Container",
|
||||
async (panel, okButton) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { DataExplorer, DocumentsTab, TestAccount } from "../fx";
|
||||
import { retry, setPartitionKeys } from "../testData";
|
||||
import { existsSync, mkdtempSync, rmdirSync, unlinkSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import { CommandBarButton, DataExplorer, DocumentsTab, ONE_MINUTE_MS, TestAccount } from "../fx";
|
||||
import {
|
||||
createTestSQLContainer,
|
||||
itemsPerPartition,
|
||||
partitionCount,
|
||||
retry,
|
||||
setPartitionKeys,
|
||||
TestContainerContext,
|
||||
TestData,
|
||||
} from "../testData";
|
||||
import { documentTestCases } from "./testCases";
|
||||
|
||||
let explorer: DataExplorer = null!;
|
||||
@@ -95,3 +106,105 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test.describe.serial("Upload Item", () => {
|
||||
let context: TestContainerContext = null!;
|
||||
let uploadDocumentDirPath: string = null!;
|
||||
let uploadDocumentFilePath: string = null!;
|
||||
|
||||
test.beforeAll("Create Test database and open documents tab", async ({ browser }) => {
|
||||
uploadDocumentDirPath = mkdtempSync(path.join(tmpdir(), "upload-document-"));
|
||||
uploadDocumentFilePath = path.join(uploadDocumentDirPath, "uploadDocument.json");
|
||||
|
||||
const page = await browser.newPage();
|
||||
context = await createTestSQLContainer();
|
||||
explorer = await DataExplorer.open(page, TestAccount.SQL);
|
||||
|
||||
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
|
||||
await containerNode.expand();
|
||||
|
||||
const containerMenuNode = await explorer.waitForContainerItemsNode(context.database.id, context.container.id);
|
||||
await containerMenuNode.element.click();
|
||||
// We need to click twice in order to remove a tooltip
|
||||
await containerMenuNode.element.click();
|
||||
});
|
||||
|
||||
test.afterAll("Delete Test Database and uploadDocument temp folder", async () => {
|
||||
if (existsSync(uploadDocumentFilePath)) {
|
||||
unlinkSync(uploadDocumentFilePath);
|
||||
}
|
||||
if (existsSync(uploadDocumentDirPath)) {
|
||||
rmdirSync(uploadDocumentDirPath);
|
||||
}
|
||||
if (!process.env.CI) {
|
||||
await context?.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterEach("Close Upload Items panel if still open", async () => {
|
||||
const closeUploadItemsPanelButton = explorer.frame.getByLabel("Close Upload Items");
|
||||
if (await closeUploadItemsPanelButton.isVisible()) {
|
||||
await closeUploadItemsPanelButton.click();
|
||||
}
|
||||
});
|
||||
|
||||
test("upload document", async () => {
|
||||
// Create file to upload
|
||||
const TestDataJsonString: string = JSON.stringify(TestData, null, 2);
|
||||
writeFileSync(uploadDocumentFilePath, TestDataJsonString);
|
||||
|
||||
const uploadItemCommandBar = explorer.commandBarButton(CommandBarButton.UploadItem);
|
||||
await uploadItemCommandBar.click();
|
||||
|
||||
// Select file to upload
|
||||
await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath);
|
||||
|
||||
const uploadButton = explorer.frame.getByTestId("Panel/OkButton");
|
||||
await uploadButton.click();
|
||||
|
||||
// Verify upload success message
|
||||
const fileUploadStatusExpected: string = `${partitionCount * itemsPerPartition} created, 0 throttled, 0 errors`;
|
||||
const fileUploadStatus = explorer.frame.getByTestId("file-upload-status");
|
||||
await expect(fileUploadStatus).toContainText(fileUploadStatusExpected, {
|
||||
timeout: ONE_MINUTE_MS,
|
||||
});
|
||||
|
||||
// Select file to upload again
|
||||
await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath);
|
||||
await uploadButton.click();
|
||||
|
||||
// Verify upload failure message
|
||||
const errorIcon = explorer.frame.getByRole("img", { name: "error" });
|
||||
await expect(errorIcon).toBeVisible({ timeout: ONE_MINUTE_MS });
|
||||
await expect(fileUploadStatus).toContainText(
|
||||
`0 created, 0 throttled, ${partitionCount * itemsPerPartition} errors`,
|
||||
{
|
||||
timeout: ONE_MINUTE_MS,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("upload invalid json", async () => {
|
||||
// Create file to upload
|
||||
let TestDataJsonString: string = JSON.stringify(TestData, null, 2);
|
||||
// Remove the first '[' so that it becomes invalid json
|
||||
TestDataJsonString = TestDataJsonString.substring(1);
|
||||
writeFileSync(uploadDocumentFilePath, TestDataJsonString);
|
||||
|
||||
const uploadItemCommandBar = explorer.commandBarButton(CommandBarButton.UploadItem);
|
||||
await uploadItemCommandBar.click();
|
||||
|
||||
// Select file to upload
|
||||
await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath);
|
||||
|
||||
const uploadButton = explorer.frame.getByTestId("Panel/OkButton");
|
||||
await uploadButton.click();
|
||||
|
||||
// Verify upload failure message
|
||||
const fileUploadErrorList = explorer.frame.getByLabel("error list");
|
||||
// The parsing error will show up differently in different browsers so just check for the word "JSON"
|
||||
await expect(fileUploadErrorList).toContainText("JSON", {
|
||||
timeout: ONE_MINUTE_MS,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
145
test/sql/indexAdvisor.spec.ts
Normal file
145
test/sql/indexAdvisor.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { CommandBarButton, DataExplorer, TestAccount } from "../fx";
|
||||
import { createTestSQLContainer, TestContainerContext } from "../testData";
|
||||
|
||||
// Test container context for setup and cleanup
|
||||
let testContainer: TestContainerContext;
|
||||
let DATABASE_ID: string;
|
||||
let CONTAINER_ID: string;
|
||||
|
||||
// Set up test database and container with data before all tests
|
||||
test.beforeAll(async () => {
|
||||
testContainer = await createTestSQLContainer(true);
|
||||
DATABASE_ID = testContainer.database.id;
|
||||
CONTAINER_ID = testContainer.container.id;
|
||||
});
|
||||
|
||||
// Clean up test database after all tests
|
||||
test.afterAll(async () => {
|
||||
if (testContainer) {
|
||||
await testContainer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to set up query tab and navigate to Index Advisor
|
||||
async function setupIndexAdvisorTab(page: Page, customQuery?: string) {
|
||||
const explorer = await DataExplorer.open(page, TestAccount.SQL);
|
||||
const databaseNode = await explorer.waitForNode(DATABASE_ID);
|
||||
await databaseNode.expand();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const containerNode = await explorer.waitForNode(`${DATABASE_ID}/${CONTAINER_ID}`);
|
||||
await containerNode.openContextMenu();
|
||||
await containerNode.contextMenuItem("New SQL Query").click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const queryTab = explorer.queryTab("tab0");
|
||||
const queryEditor = queryTab.editor();
|
||||
await queryEditor.locator.waitFor({ timeout: 30 * 1000 });
|
||||
await queryTab.executeCTA.waitFor();
|
||||
|
||||
if (customQuery) {
|
||||
await queryEditor.locator.click();
|
||||
await queryEditor.setText(customQuery);
|
||||
}
|
||||
|
||||
const executeQueryButton = explorer.commandBarButton(CommandBarButton.ExecuteQuery);
|
||||
await executeQueryButton.click();
|
||||
await expect(queryTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
|
||||
|
||||
const indexAdvisorTab = queryTab.resultsView.getByTestId("QueryTab/ResultsPane/ResultsView/IndexAdvisorTab");
|
||||
await indexAdvisorTab.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
return { explorer, queryTab, indexAdvisorTab };
|
||||
}
|
||||
|
||||
test("Index Advisor tab loads without errors", async ({ page }) => {
|
||||
const { indexAdvisorTab } = await setupIndexAdvisorTab(page);
|
||||
await expect(indexAdvisorTab).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
test("Verify UI sections are collapsible", async ({ page }) => {
|
||||
const { explorer } = await setupIndexAdvisorTab(page);
|
||||
|
||||
// Verify both section headers exist
|
||||
const includedHeader = explorer.frame.getByText("Included in Current Policy", { exact: true });
|
||||
const notIncludedHeader = explorer.frame.getByText("Not Included in Current Policy", { exact: true });
|
||||
|
||||
await expect(includedHeader).toBeVisible();
|
||||
await expect(notIncludedHeader).toBeVisible();
|
||||
|
||||
// Test collapsibility by checking if chevron/arrow icon changes state
|
||||
// Both sections should be expandable/collapsible regardless of content
|
||||
await includedHeader.click();
|
||||
await page.waitForTimeout(300);
|
||||
await includedHeader.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await notIncludedHeader.click();
|
||||
await page.waitForTimeout(300);
|
||||
await notIncludedHeader.click();
|
||||
await page.waitForTimeout(300);
|
||||
});
|
||||
|
||||
test("Verify SDK response structure - Case 1: Empty response", async ({ page }) => {
|
||||
const { explorer } = await setupIndexAdvisorTab(page);
|
||||
|
||||
// Verify both section headers still exist even with no data
|
||||
await expect(explorer.frame.getByText("Included in Current Policy", { exact: true })).toBeVisible();
|
||||
await expect(explorer.frame.getByText("Not Included in Current Policy", { exact: true })).toBeVisible();
|
||||
|
||||
// Verify table headers
|
||||
const table = explorer.frame.locator("table");
|
||||
await expect(table.getByText("Index", { exact: true })).toBeVisible();
|
||||
await expect(table.getByText("Estimated Impact", { exact: true })).toBeVisible();
|
||||
|
||||
// Verify "Update Indexing Policy" button is NOT visible when there are no potential indexes
|
||||
const updateButton = explorer.frame.getByRole("button", { name: /Update Indexing Policy/i });
|
||||
await expect(updateButton).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Verify index suggestions and apply potential index", async ({ page }) => {
|
||||
const customQuery = 'SELECT * FROM c WHERE c.partitionKey = "partition_1" ORDER BY c.randomData';
|
||||
const { explorer } = await setupIndexAdvisorTab(page, customQuery);
|
||||
|
||||
// Wait for Index Advisor to process the query
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify "Not Included in Current Policy" section has suggestions
|
||||
const notIncludedHeader = explorer.frame.getByText("Not Included in Current Policy", { exact: true });
|
||||
await expect(notIncludedHeader).toBeVisible();
|
||||
|
||||
// Find the checkbox for the suggested composite index
|
||||
// The composite index should be /partitionKey ASC, /randomData ASC
|
||||
const checkboxes = explorer.frame.locator('input[type="checkbox"]');
|
||||
const checkboxCount = await checkboxes.count();
|
||||
|
||||
// Should have at least one checkbox for the potential index
|
||||
expect(checkboxCount).toBeGreaterThan(0);
|
||||
|
||||
// Select the first checkbox (the high-impact composite index)
|
||||
await checkboxes.first().check();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify "Update Indexing Policy" button becomes visible
|
||||
const updateButton = explorer.frame.getByRole("button", { name: /Update Indexing Policy/i });
|
||||
await expect(updateButton).toBeVisible();
|
||||
|
||||
// Click the "Update Indexing Policy" button
|
||||
await updateButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify success message appears
|
||||
const successMessage = explorer.frame.getByText(/Your indexing policy has been updated with the new included paths/i);
|
||||
await expect(successMessage).toBeVisible();
|
||||
|
||||
// Verify the message mentions reviewing changes in Scale & Settings
|
||||
const reviewMessage = explorer.frame.getByText(/You may review the changes in Scale & Settings/i);
|
||||
await expect(reviewMessage).toBeVisible();
|
||||
|
||||
// Verify the checkmark icon is shown
|
||||
const checkmarkIcon = explorer.frame.locator('[data-icon-name="CheckMark"]');
|
||||
await expect(checkmarkIcon).toBeVisible();
|
||||
});
|
||||
@@ -30,9 +30,12 @@ test.beforeEach("Open new query tab", async ({ page }) => {
|
||||
await explorer.frame.getByTestId("NotificationConsole/Contents").waitFor();
|
||||
});
|
||||
|
||||
test.afterAll("Delete Test Database", async () => {
|
||||
await context?.dispose();
|
||||
});
|
||||
// Delete database only if not running in CI
|
||||
if (!process.env.CI) {
|
||||
test.afterAll("Delete Test Database", async () => {
|
||||
await context?.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
test("Query results", async () => {
|
||||
// Run the query and verify the results
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
import { expect, Page, test } from "@playwright/test";
|
||||
import { DataExplorer, getDropdownItemByNameOrPosition, TestAccount } from "../../fx";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { DataExplorer, TestAccount } from "../../fx";
|
||||
import { createTestSQLContainer, TestContainerContext } from "../../testData";
|
||||
|
||||
test.describe("Change Partition Key", () => {
|
||||
let pageInstance: Page;
|
||||
let context: TestContainerContext = null!;
|
||||
let explorer: DataExplorer = null!;
|
||||
const newPartitionKeyPath = "/newPartitionKey";
|
||||
const newPartitionKeyPath = "newPartitionKey";
|
||||
const newContainerId = "testcontainer_1";
|
||||
let previousJobName: string | undefined;
|
||||
|
||||
test.beforeAll("Create Test Database", async () => {
|
||||
context = await createTestSQLContainer();
|
||||
});
|
||||
|
||||
test.beforeEach("Open container settings", async ({ page }) => {
|
||||
pageInstance = page;
|
||||
explorer = await DataExplorer.open(page, TestAccount.SQL);
|
||||
|
||||
// Click Scale & Settings and open Partition Key tab
|
||||
await explorer.openScaleAndSettings(context);
|
||||
const PartitionKeyTab = explorer.frame.getByTestId("settings-tab-header/PartitionKeyTab");
|
||||
await expect(PartitionKeyTab).toBeVisible();
|
||||
await PartitionKeyTab.click();
|
||||
});
|
||||
|
||||
test.afterAll("Delete Test Database", async () => {
|
||||
await context?.dispose();
|
||||
});
|
||||
// Delete database only if not running in CI
|
||||
if (!process.env.CI) {
|
||||
test.afterEach("Delete Test Database", async () => {
|
||||
await context?.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
test("Change partition key path", async () => {
|
||||
test("Change partition key path", async ({ page }) => {
|
||||
await expect(explorer.frame.getByText("/partitionKey")).toBeVisible();
|
||||
await expect(explorer.frame.getByText("Change partition key")).toBeVisible();
|
||||
await expect(explorer.frame.getByText(/To safeguard the integrity of/)).toBeVisible();
|
||||
@@ -57,46 +60,83 @@ test.describe("Change Partition Key", () => {
|
||||
await expect(changePkPanel.getByTestId("new-container-sub-partition-key-input-0")).toBeVisible();
|
||||
await expect(changePkPanel.getByTestId("remove-sub-partition-key-button-0")).toBeVisible();
|
||||
await expect(changePkPanel.getByTestId("hierarchical-partitioning-info-text")).toBeVisible();
|
||||
changePkPanel.getByTestId("new-container-sub-partition-key-input-0").fill("/customerId");
|
||||
await changePkPanel.getByTestId("remove-sub-partition-key-button-0").click();
|
||||
|
||||
await changePkPanel.getByTestId("Panel/OkButton").click();
|
||||
|
||||
await pageInstance.waitForLoadState("networkidle");
|
||||
await expect(changePkPanel).not.toBeVisible({ timeout: 60 * 1000 });
|
||||
let jobName: string | undefined;
|
||||
await page.waitForRequest(
|
||||
(req) => {
|
||||
const requestUrl = req.url();
|
||||
if (requestUrl.includes("/dataTransferJobs") && req.method() === "PUT") {
|
||||
jobName = new URL(requestUrl).pathname.split("/").pop();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
|
||||
await expect(changePkPanel).not.toBeVisible({ timeout: 5 * 60 * 1000 });
|
||||
|
||||
// Verify partition key change job
|
||||
const jobText = explorer.frame.getByText(/Partition key change job/);
|
||||
await expect(jobText).toBeVisible();
|
||||
await expect(explorer.frame.locator(".ms-ProgressIndicator-itemName")).toContainText("Portal_testcontainer_1");
|
||||
// await expect(explorer.frame.locator(".ms-ProgressIndicator-itemName")).toContainText("Portal_testcontainer_1");
|
||||
await expect(explorer.frame.locator(".ms-ProgressIndicator-itemName")).toContainText(jobName!);
|
||||
|
||||
const jobRow = explorer.frame.locator(".ms-ProgressIndicator-itemDescription");
|
||||
await expect(jobRow.getByText("Completed")).toBeVisible({ timeout: 30 * 1000 });
|
||||
// await expect(jobRow.getByText("Pending")).toBeVisible({ timeout: 30 * 1000 });
|
||||
await expect(jobRow.getByText("Completed")).toBeVisible({ timeout: 5 * 60 * 1000 });
|
||||
|
||||
const newContainerNode = await explorer.waitForContainerNode(context.database.id, newContainerId);
|
||||
expect(newContainerNode).not.toBeNull();
|
||||
|
||||
// Now try to switch to existing container
|
||||
// Ensure this job name is different from the previously processed job name
|
||||
previousJobName = jobName;
|
||||
|
||||
await changePartitionKeyButton.click();
|
||||
await changePkPanel.getByText("Existing container").click();
|
||||
await changePkPanel.getByLabel("Use existing container").check();
|
||||
await changePkPanel.getByText("Choose an existing container").click();
|
||||
|
||||
const containerDropdownItem = await getDropdownItemByNameOrPosition(
|
||||
explorer.frame,
|
||||
{ name: newContainerId },
|
||||
{ ariaLabel: "Existing Containers" },
|
||||
);
|
||||
const containerDropdownItem = await explorer.getDropdownItemByName(newContainerId, "Existing Containers");
|
||||
await containerDropdownItem.click();
|
||||
|
||||
await changePkPanel.getByTestId("Panel/OkButton").click();
|
||||
await explorer.frame.getByRole("button", { name: "Cancel" }).click();
|
||||
let secondJobName: string | undefined;
|
||||
await Promise.all([
|
||||
page.waitForRequest(
|
||||
(req) => {
|
||||
const requestUrl = req.url();
|
||||
if (requestUrl.includes("/dataTransferJobs") && req.method() === "PUT") {
|
||||
secondJobName = new URL(requestUrl).pathname.split("/").pop();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ timeout: 120000 },
|
||||
),
|
||||
changePkPanel.getByTestId("Panel/OkButton").click(),
|
||||
]);
|
||||
|
||||
// Dismiss overlay if it appears
|
||||
const overlayFrame = explorer.frame.locator("#webpack-dev-server-client-overlay").first();
|
||||
if (await overlayFrame.count()) {
|
||||
await overlayFrame.contentFrame().getByLabel("Dismiss").click();
|
||||
const cancelButton = explorer.frame.getByRole("button", { name: "Cancel" });
|
||||
const isCancelButtonVisible = await cancelButton.isVisible().catch(() => false);
|
||||
if (isCancelButtonVisible) {
|
||||
await cancelButton.click();
|
||||
|
||||
// Dismiss overlay if it appears
|
||||
const overlayFrame = explorer.frame.locator("#webpack-dev-server-client-overlay").first();
|
||||
if (await overlayFrame.count()) {
|
||||
await overlayFrame.contentFrame().getByLabel("Dismiss").click();
|
||||
}
|
||||
|
||||
const cancelledJobRow = explorer.frame.getByTestId("Tab:tab0");
|
||||
await expect(cancelledJobRow.getByText("Cancelled")).toBeVisible({ timeout: 30 * 1000 });
|
||||
} else {
|
||||
const jobRow = explorer.frame.locator(".ms-ProgressIndicator-itemDescription");
|
||||
await expect(jobRow.getByText("Completed")).toBeVisible({ timeout: 5 * 60 * 1000 });
|
||||
expect(secondJobName).not.toBe(previousJobName);
|
||||
}
|
||||
const cancelledJobRow = explorer.frame.getByTestId("Tab:tab0");
|
||||
await expect(cancelledJobRow.getByText("Cancelled")).toBeVisible({ timeout: 30 * 1000 });
|
||||
});
|
||||
});
|
||||
|
||||
108
test/sql/scaleAndSettings/computedProperties.spec.ts
Normal file
108
test/sql/scaleAndSettings/computedProperties.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { expect, Page, test } from "@playwright/test";
|
||||
import * as DataModels from "../../../src/Contracts/DataModels";
|
||||
import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx";
|
||||
import { createTestSQLContainer, TestContainerContext } from "../../testData";
|
||||
|
||||
test.describe("Computed Properties", () => {
|
||||
let context: TestContainerContext = null!;
|
||||
let explorer: DataExplorer = null!;
|
||||
|
||||
test.beforeAll("Create Test Database", async () => {
|
||||
context = await createTestSQLContainer();
|
||||
});
|
||||
|
||||
test.beforeEach("Open Settings tab under Scale & Settings", async ({ page }) => {
|
||||
explorer = await DataExplorer.open(page, TestAccount.SQL);
|
||||
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
|
||||
await containerNode.expand();
|
||||
|
||||
// Click Scale & Settings and open Settings tab
|
||||
await explorer.openScaleAndSettings(context);
|
||||
const computedPropertiesTab = explorer.frame.getByTestId("settings-tab-header/ComputedPropertiesTab");
|
||||
await computedPropertiesTab.click();
|
||||
});
|
||||
|
||||
test.afterAll("Delete Test Database", async () => {
|
||||
await context?.dispose();
|
||||
});
|
||||
|
||||
test("Add valid computed property", async ({ page }) => {
|
||||
await clearComputedPropertiesTextBoxContent({ page });
|
||||
|
||||
// Create computed property
|
||||
const computedProperties: DataModels.ComputedProperties = [
|
||||
{
|
||||
name: "cp_lowerName",
|
||||
query: "SELECT VALUE LOWER(c.name) FROM c",
|
||||
},
|
||||
];
|
||||
const computedPropertiesString: string = JSON.stringify(computedProperties);
|
||||
await page.keyboard.type(computedPropertiesString);
|
||||
|
||||
// Save changes
|
||||
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
|
||||
await expect(saveButton).toBeEnabled();
|
||||
await saveButton.click();
|
||||
await expect(explorer.getConsoleHeaderStatus()).toContainText(
|
||||
`Successfully updated container ${context.container.id}`,
|
||||
{
|
||||
timeout: ONE_MINUTE_MS,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Add computed property with invalid query", async ({ page }) => {
|
||||
await clearComputedPropertiesTextBoxContent({ page });
|
||||
|
||||
// Create computed property with no VALUE keyword in query
|
||||
const computedProperties: DataModels.ComputedProperties = [
|
||||
{
|
||||
name: "cp_lowerName",
|
||||
query: "SELECT LOWER(c.name) FROM c",
|
||||
},
|
||||
];
|
||||
const computedPropertiesString: string = JSON.stringify(computedProperties);
|
||||
await page.keyboard.type(computedPropertiesString);
|
||||
|
||||
// Save changes
|
||||
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
|
||||
await expect(saveButton).toBeEnabled();
|
||||
await saveButton.click();
|
||||
await expect(explorer.getConsoleHeaderStatus()).toContainText(
|
||||
`Failed to update container ${context.container.id}`,
|
||||
{
|
||||
timeout: ONE_MINUTE_MS,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Add computed property with invalid json", async ({ page }) => {
|
||||
await clearComputedPropertiesTextBoxContent({ page });
|
||||
|
||||
// Create computed property with no VALUE keyword in query
|
||||
const computedProperties: DataModels.ComputedProperties = [
|
||||
{
|
||||
name: "cp_lowerName",
|
||||
query: "SELECT LOWER(c.name) FROM c",
|
||||
},
|
||||
];
|
||||
const computedPropertiesString: string = JSON.stringify(computedProperties);
|
||||
await page.keyboard.type(computedPropertiesString + "]");
|
||||
|
||||
// Save button should remain disabled due to invalid json
|
||||
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
|
||||
await expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
const clearComputedPropertiesTextBoxContent = async ({ page }: { page: Page }): Promise<void> => {
|
||||
// Get computed properties text box
|
||||
await explorer.frame.waitForSelector(".monaco-scrollable-element", { state: "visible" });
|
||||
const computedPropertiesEditor = explorer.frame.getByTestId("computed-properties-editor");
|
||||
await computedPropertiesEditor.click();
|
||||
|
||||
// Clear existing content (Ctrl+A + Backspace does not work with webkit)
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await page.keyboard.press("Backspace");
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, Locator, test } from "@playwright/test";
|
||||
import { Browser, expect, Locator, Page, test } from "@playwright/test";
|
||||
import {
|
||||
CommandBarButton,
|
||||
DataExplorer,
|
||||
@@ -9,121 +9,116 @@ import {
|
||||
} from "../../fx";
|
||||
import { createTestSQLContainer, TestContainerContext } from "../../testData";
|
||||
|
||||
test.describe("Autoscale and Manual throughput", () => {
|
||||
let context: TestContainerContext = null!;
|
||||
let explorer: DataExplorer = null!;
|
||||
interface SetupResult {
|
||||
context: TestContainerContext;
|
||||
page: Page;
|
||||
explorer: DataExplorer;
|
||||
}
|
||||
|
||||
test.beforeAll("Create Test Database", async () => {
|
||||
context = await createTestSQLContainer({ includeTestData: true });
|
||||
test.describe("Autoscale throughput", () => {
|
||||
let setup: SetupResult;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
setup = await openScaleTab(browser);
|
||||
|
||||
// Switch manual -> autoscale once for this suite
|
||||
const autoscaleRadioButton = setup.explorer.frame.getByText("Autoscale", { exact: true });
|
||||
await autoscaleRadioButton.click();
|
||||
await expect(setup.explorer.commandBarButton(CommandBarButton.Save)).toBeEnabled();
|
||||
await setup.explorer.commandBarButton(CommandBarButton.Save).click();
|
||||
await expect(setup.explorer.getConsoleHeaderStatus()).toContainText(
|
||||
`Successfully updated offer for collection ${setup.context.container.id}`,
|
||||
{ timeout: 2 * ONE_MINUTE_MS },
|
||||
);
|
||||
});
|
||||
|
||||
test.beforeEach("Open container settings", async ({ page }) => {
|
||||
explorer = await DataExplorer.open(page, TestAccount.SQL);
|
||||
|
||||
// Click Scale & Settings and open Scale tab
|
||||
await explorer.openScaleAndSettings(context);
|
||||
const scaleTab = explorer.frame.getByTestId("settings-tab-header/ScaleTab");
|
||||
await scaleTab.click();
|
||||
});
|
||||
|
||||
test.afterAll("Delete Test Database", async () => {
|
||||
await context?.dispose();
|
||||
test.afterAll(async () => {
|
||||
await cleanup(setup);
|
||||
});
|
||||
|
||||
test("Update autoscale max throughput", async () => {
|
||||
// By default the created container has manual throughput (Containers created via JS SDK v4.7.0 cannot be created with autoscale throughput)
|
||||
await switchManualToAutoscaleThroughput();
|
||||
await getThroughputInput(setup.explorer, "autopilot").fill(TEST_AUTOSCALE_MAX_THROUGHPUT_RU_2K.toString());
|
||||
await setup.explorer.commandBarButton(CommandBarButton.Save).click();
|
||||
|
||||
// Update autoscale max throughput
|
||||
await getThroughputInput("autopilot").fill(TEST_AUTOSCALE_MAX_THROUGHPUT_RU_2K.toString());
|
||||
|
||||
// Save
|
||||
await explorer.commandBarButton(CommandBarButton.Save).click();
|
||||
|
||||
// Read console message
|
||||
await expect(explorer.getConsoleMessage()).toContainText(
|
||||
`Successfully updated offer for collection ${context.container.id}`,
|
||||
{
|
||||
timeout: 2 * ONE_MINUTE_MS,
|
||||
},
|
||||
await expect(setup.explorer.getConsoleHeaderStatus()).toContainText(
|
||||
`Successfully updated offer for collection ${setup.context.container.id}`,
|
||||
{ timeout: 2 * ONE_MINUTE_MS },
|
||||
);
|
||||
});
|
||||
|
||||
test("Update autoscale max throughput passed allowed limit", async () => {
|
||||
// By default the created container has manual throughput (Containers created via JS SDK v4.7.0 cannot be created with autoscale throughput)
|
||||
await switchManualToAutoscaleThroughput();
|
||||
|
||||
// Get soft allowed max throughput and remove commas
|
||||
const softAllowedMaxThroughputString = await explorer.frame
|
||||
const softAllowedMaxThroughputString = await setup.explorer.frame
|
||||
.getByTestId("soft-allowed-maximum-throughput")
|
||||
.innerText();
|
||||
const softAllowedMaxThroughput = Number(softAllowedMaxThroughputString.replace(/,/g, ""));
|
||||
|
||||
// Try to set autoscale max throughput above allowed limit
|
||||
await getThroughputInput("autopilot").fill((softAllowedMaxThroughput * 10).toString());
|
||||
await expect(explorer.commandBarButton(CommandBarButton.Save)).toBeDisabled();
|
||||
await expect(getThroughputInputErrorMessage("autopilot")).toContainText(
|
||||
"This update isn't possible because it would increase the total throughput",
|
||||
);
|
||||
await getThroughputInput(setup.explorer, "autopilot").fill((softAllowedMaxThroughput * 10).toString());
|
||||
await expect(setup.explorer.commandBarButton(CommandBarButton.Save)).toBeDisabled();
|
||||
await expect(delayedApplyWarning(setup.explorer)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Update autoscale max throughput with invalid increment", async () => {
|
||||
// By default the created container has manual throughput (Containers created via JS SDK v4.7.0 cannot be created with autoscale throughput)
|
||||
await switchManualToAutoscaleThroughput();
|
||||
|
||||
// Try to set autoscale max throughput with invalid increment
|
||||
await getThroughputInput("autopilot").fill("1100");
|
||||
await expect(explorer.commandBarButton(CommandBarButton.Save)).toBeDisabled();
|
||||
await expect(getThroughputInputErrorMessage("autopilot")).toContainText(
|
||||
await getThroughputInput(setup.explorer, "autopilot").fill("1100");
|
||||
await expect(setup.explorer.commandBarButton(CommandBarButton.Save)).toBeDisabled();
|
||||
await expect(getThroughputInputErrorMessage(setup.explorer, "autopilot")).toContainText(
|
||||
"Throughput value must be in increments of 1000",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Manual throughput", () => {
|
||||
let setup: SetupResult;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
setup = await openScaleTab(browser);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await cleanup(setup);
|
||||
});
|
||||
|
||||
test("Update manual throughput", async () => {
|
||||
await getThroughputInput("manual").fill(TEST_MANUAL_THROUGHPUT_RU_2K.toString());
|
||||
await explorer.commandBarButton(CommandBarButton.Save).click();
|
||||
await expect(explorer.getConsoleMessage()).toContainText(
|
||||
`Successfully updated offer for collection ${context.container.id}`,
|
||||
{
|
||||
timeout: 2 * ONE_MINUTE_MS,
|
||||
},
|
||||
await getThroughputInput(setup.explorer, "manual").fill(TEST_MANUAL_THROUGHPUT_RU_2K.toString());
|
||||
await setup.explorer.commandBarButton(CommandBarButton.Save).click();
|
||||
await expect(setup.explorer.getConsoleHeaderStatus()).toContainText(
|
||||
`Successfully updated offer for collection ${setup.context.container.id}`,
|
||||
{ timeout: 2 * ONE_MINUTE_MS },
|
||||
);
|
||||
});
|
||||
|
||||
test("Update manual throughput passed allowed limit", async () => {
|
||||
// Get soft allowed max throughput and remove commas
|
||||
const softAllowedMaxThroughputString = await explorer.frame
|
||||
const softAllowedMaxThroughputString = await setup.explorer.frame
|
||||
.getByTestId("soft-allowed-maximum-throughput")
|
||||
.innerText();
|
||||
const softAllowedMaxThroughput = Number(softAllowedMaxThroughputString.replace(/,/g, ""));
|
||||
|
||||
// Try to set manual throughput above allowed limit
|
||||
await getThroughputInput("manual").fill((softAllowedMaxThroughput * 10).toString());
|
||||
await expect(explorer.commandBarButton(CommandBarButton.Save)).toBeDisabled();
|
||||
await expect(getThroughputInputErrorMessage("manual")).toContainText(
|
||||
"This update isn't possible because it would increase the total throughput",
|
||||
);
|
||||
await getThroughputInput(setup.explorer, "manual").fill((softAllowedMaxThroughput * 10).toString());
|
||||
await expect(delayedApplyWarning(setup.explorer)).toBeVisible();
|
||||
});
|
||||
|
||||
// Helper methods
|
||||
const getThroughputInput = (type: "manual" | "autopilot"): Locator => {
|
||||
return explorer.frame.getByTestId(`${type}-throughput-input`);
|
||||
};
|
||||
|
||||
const getThroughputInputErrorMessage = (type: "manual" | "autopilot"): Locator => {
|
||||
return explorer.frame.getByTestId(`${type}-throughput-input-error`);
|
||||
};
|
||||
|
||||
const switchManualToAutoscaleThroughput = async (): Promise<void> => {
|
||||
const autoscaleRadioButton = explorer.frame.getByText("Autoscale", { exact: true });
|
||||
await autoscaleRadioButton.click();
|
||||
await expect(explorer.commandBarButton(CommandBarButton.Save)).toBeEnabled();
|
||||
await explorer.commandBarButton(CommandBarButton.Save).click();
|
||||
await expect(explorer.getConsoleMessage()).toContainText(
|
||||
`Successfully updated offer for collection ${context.container.id}`,
|
||||
{
|
||||
timeout: ONE_MINUTE_MS,
|
||||
},
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const delayedApplyWarning = (explorer: DataExplorer): Locator =>
|
||||
explorer.frame.locator("#updateThroughputDelayedApplyWarningMessage");
|
||||
|
||||
const getThroughputInput = (explorer: DataExplorer, type: "manual" | "autopilot"): Locator =>
|
||||
explorer.frame.getByTestId(`${type}-throughput-input`);
|
||||
|
||||
const getThroughputInputErrorMessage = (explorer: DataExplorer, type: "manual" | "autopilot"): Locator =>
|
||||
explorer.frame.getByTestId(`${type}-throughput-input-error`);
|
||||
|
||||
async function openScaleTab(browser: Browser): Promise<SetupResult> {
|
||||
const context = await createTestSQLContainer();
|
||||
const page = await browser.newPage();
|
||||
const explorer = await DataExplorer.open(page, TestAccount.SQL);
|
||||
|
||||
await explorer.openScaleAndSettings(context);
|
||||
await explorer.frame.getByTestId("settings-tab-header/ScaleTab").click();
|
||||
|
||||
return { context, page, explorer };
|
||||
}
|
||||
|
||||
async function cleanup({ context }: Partial<SetupResult>) {
|
||||
if (!process.env.CI) {
|
||||
await context?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,33 +6,35 @@ test.describe("Settings under Scale & Settings", () => {
|
||||
let context: TestContainerContext = null!;
|
||||
let explorer: DataExplorer = null!;
|
||||
|
||||
test.beforeAll("Create Test Database", async () => {
|
||||
context = await createTestSQLContainer({ includeTestData: true });
|
||||
});
|
||||
|
||||
test.beforeEach("Open Settings tab under Scale & Settings", async ({ page }) => {
|
||||
test.beforeAll("Create Test Database & Open Settings tab", async ({ browser }) => {
|
||||
context = await createTestSQLContainer();
|
||||
const page = await browser.newPage();
|
||||
explorer = await DataExplorer.open(page, TestAccount.SQL);
|
||||
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
|
||||
await containerNode.expand();
|
||||
|
||||
// Click Scale & Settings and open Scale tab
|
||||
// Click Scale & Settings and open Settings tab
|
||||
await explorer.openScaleAndSettings(context);
|
||||
const settingsTab = explorer.frame.getByTestId("settings-tab-header/SubSettingsTab");
|
||||
await settingsTab.click();
|
||||
});
|
||||
|
||||
test.afterAll("Delete Test Database", async () => {
|
||||
await context?.dispose();
|
||||
});
|
||||
// Delete database only if not running in CI
|
||||
if (!process.env.CI) {
|
||||
test.afterAll("Delete Test Database", async () => {
|
||||
await context?.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
test("Update TTL to On (no default)", async () => {
|
||||
const ttlOnNoDefaultRadioButton = explorer.frame.getByRole("radio", { name: "ttl-on-no-default-option" });
|
||||
await ttlOnNoDefaultRadioButton.click();
|
||||
|
||||
await explorer.commandBarButton(CommandBarButton.Save).click();
|
||||
await expect(explorer.getConsoleMessage()).toContainText(`Successfully updated container ${context.container.id}`, {
|
||||
timeout: ONE_MINUTE_MS,
|
||||
});
|
||||
await expect(explorer.getConsoleHeaderStatus()).toContainText(
|
||||
`Successfully updated container ${context.container.id}`,
|
||||
{
|
||||
timeout: 2 * ONE_MINUTE_MS,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Update TTL to On (with user entry)", async () => {
|
||||
@@ -44,27 +46,35 @@ test.describe("Settings under Scale & Settings", () => {
|
||||
await ttlInput.fill("30000");
|
||||
|
||||
await explorer.commandBarButton(CommandBarButton.Save).click();
|
||||
await expect(explorer.getConsoleMessage()).toContainText(`Successfully updated container ${context.container.id}`, {
|
||||
timeout: ONE_MINUTE_MS,
|
||||
});
|
||||
await expect(explorer.getConsoleHeaderStatus()).toContainText(
|
||||
`Successfully updated container ${context.container.id}`,
|
||||
{
|
||||
timeout: 2 * ONE_MINUTE_MS,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Update TTL to Off", async () => {
|
||||
// By default TTL is set to off so we need to first set it to On
|
||||
const ttlOnNoDefaultRadioButton = explorer.frame.getByRole("radio", { name: "ttl-on-no-default-option" });
|
||||
await ttlOnNoDefaultRadioButton.click();
|
||||
await explorer.commandBarButton(CommandBarButton.Save).click();
|
||||
await expect(explorer.getConsoleMessage()).toContainText(`Successfully updated container ${context.container.id}`, {
|
||||
timeout: ONE_MINUTE_MS,
|
||||
});
|
||||
|
||||
// Set it to Off
|
||||
const ttlOffRadioButton = explorer.frame.getByRole("radio", { name: "ttl-off-option" });
|
||||
await ttlOffRadioButton.click();
|
||||
test("Set Geospatial Config to Geometry then Geography", async () => {
|
||||
const geometryRadioButton = explorer.frame.getByRole("radio", { name: "geometry-option" });
|
||||
await geometryRadioButton.click();
|
||||
|
||||
await explorer.commandBarButton(CommandBarButton.Save).click();
|
||||
await expect(explorer.getConsoleMessage()).toContainText(`Successfully updated container ${context.container.id}`, {
|
||||
timeout: ONE_MINUTE_MS,
|
||||
});
|
||||
await expect(explorer.getConsoleHeaderStatus()).toContainText(
|
||||
`Successfully updated container ${context.container.id}`,
|
||||
{
|
||||
timeout: ONE_MINUTE_MS,
|
||||
},
|
||||
);
|
||||
|
||||
const geographyRadioButton = explorer.frame.getByRole("radio", { name: "geography-option" });
|
||||
await geographyRadioButton.click();
|
||||
|
||||
await explorer.commandBarButton(CommandBarButton.Save).click();
|
||||
await expect(explorer.getConsoleHeaderStatus()).toContainText(
|
||||
`Successfully updated container ${context.container.id}`,
|
||||
{
|
||||
timeout: ONE_MINUTE_MS,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
78
test/sql/scripts/storedProcedure.spec.ts
Normal file
78
test/sql/scripts/storedProcedure.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx";
|
||||
import { createTestSQLContainer, TestContainerContext } from "../../testData";
|
||||
|
||||
test.describe("Stored Procedures", () => {
|
||||
let context: TestContainerContext = null!;
|
||||
let explorer: DataExplorer = null!;
|
||||
|
||||
test.beforeAll("Create Test Database", async () => {
|
||||
context = await createTestSQLContainer();
|
||||
});
|
||||
|
||||
test.beforeEach("Open container", async ({ page }) => {
|
||||
explorer = await DataExplorer.open(page, TestAccount.SQL);
|
||||
});
|
||||
|
||||
test.afterAll("Delete Test Database", async () => {
|
||||
await context?.dispose();
|
||||
});
|
||||
|
||||
test("Add, execute, and delete stored procedure", async ({ page }, testInfo) => {
|
||||
void page;
|
||||
// Open container context menu and click New Stored Procedure
|
||||
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
|
||||
await containerNode.openContextMenu();
|
||||
await containerNode.contextMenuItem("New Stored Procedure").click();
|
||||
|
||||
// Type stored procedure id and use stock procedure
|
||||
const storedProcedureIdTextBox = explorer.frame.getByLabel("Stored procedure id");
|
||||
await storedProcedureIdTextBox.isVisible();
|
||||
const storedProcedureName = `stored-procedure-${testInfo.testId}`;
|
||||
await storedProcedureIdTextBox.fill(storedProcedureName);
|
||||
|
||||
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
|
||||
await expect(saveButton).toBeEnabled();
|
||||
await saveButton.click();
|
||||
|
||||
await expect(explorer.getConsoleHeaderStatus()).toContainText(
|
||||
`Successfully created stored procedure ${storedProcedureName}`,
|
||||
{
|
||||
timeout: 2 * ONE_MINUTE_MS,
|
||||
},
|
||||
);
|
||||
|
||||
// Execute stored procedure
|
||||
const executeButton = explorer.commandBarButton(CommandBarButton.Execute);
|
||||
await executeButton.click();
|
||||
const executeSidePanelButton = explorer.frame.getByTestId("Panel/OkButton");
|
||||
await executeSidePanelButton.click();
|
||||
|
||||
const executeStoredProcedureResult = explorer.frame.getByLabel("Execute stored procedure result");
|
||||
await expect(executeStoredProcedureResult).toBeAttached({
|
||||
timeout: ONE_MINUTE_MS,
|
||||
});
|
||||
|
||||
// Delete stored procedure
|
||||
await containerNode.expand();
|
||||
const storedProceduresNode = await explorer.waitForNode(
|
||||
`${context.database.id}/${context.container.id}/Stored Procedures`,
|
||||
);
|
||||
await storedProceduresNode.expand();
|
||||
const storedProcedureNode = await explorer.waitForNode(
|
||||
`${context.database.id}/${context.container.id}/Stored Procedures/${storedProcedureName}`,
|
||||
);
|
||||
|
||||
await storedProcedureNode.openContextMenu();
|
||||
await storedProcedureNode.contextMenuItem("Delete Stored Procedure").click();
|
||||
const deleteStoredProcedureButton = explorer.frame.getByTestId("DialogButton:Delete");
|
||||
await deleteStoredProcedureButton.click();
|
||||
|
||||
await expect(explorer.getConsoleHeaderStatus()).toContainText(
|
||||
`Successfully deleted stored procedure ${storedProcedureName}`,
|
||||
{
|
||||
timeout: ONE_MINUTE_MS,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
80
test/sql/scripts/trigger.spec.ts
Normal file
80
test/sql/scripts/trigger.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx";
|
||||
import { createTestSQLContainer, TestContainerContext } from "../../testData";
|
||||
|
||||
test.describe("Triggers", () => {
|
||||
let context: TestContainerContext = null!;
|
||||
let explorer: DataExplorer = null!;
|
||||
const triggerBody = `function validateToDoItemTimestamp() {
|
||||
var context = getContext();
|
||||
var request = context.getRequest();
|
||||
|
||||
var itemToCreate = request.getBody();
|
||||
|
||||
if (!("timestamp" in itemToCreate)) {
|
||||
var ts = new Date();
|
||||
itemToCreate["timestamp"] = ts.getTime();
|
||||
}
|
||||
|
||||
request.setBody(itemToCreate);
|
||||
}`;
|
||||
test.beforeAll("Create Test Database", async () => {
|
||||
context = await createTestSQLContainer();
|
||||
});
|
||||
|
||||
test.beforeEach("Open container", async ({ page }) => {
|
||||
explorer = await DataExplorer.open(page, TestAccount.SQL);
|
||||
});
|
||||
|
||||
if (!process.env.CI) {
|
||||
test.afterAll("Delete Test Database", async () => {
|
||||
await context?.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
test("Add and delete trigger", async ({ page }, testInfo) => {
|
||||
// Open container context menu and click New Trigger
|
||||
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
|
||||
await containerNode.openContextMenu();
|
||||
await containerNode.contextMenuItem("New Trigger").click();
|
||||
|
||||
// Assign Trigger id
|
||||
const triggerIdTextBox = explorer.frame.getByLabel("Trigger Id");
|
||||
const triggerId: string = `validateItemTimestamp-${testInfo.testId}`;
|
||||
await triggerIdTextBox.fill(triggerId);
|
||||
|
||||
// Create Trigger body that validates item timestamp
|
||||
const triggerBodyTextArea = explorer.frame.getByTestId("EditorReact/Host/Loaded");
|
||||
await triggerBodyTextArea.click();
|
||||
|
||||
// Clear existing content
|
||||
const isMac: boolean = process.platform === "darwin";
|
||||
await page.keyboard.press(isMac ? "Meta+A" : "Control+A");
|
||||
await page.keyboard.press("Backspace");
|
||||
|
||||
await page.keyboard.type(triggerBody);
|
||||
|
||||
// Save changes
|
||||
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
|
||||
await saveButton.click();
|
||||
await expect(explorer.getConsoleHeaderStatus()).toContainText(`Successfully created trigger ${triggerId}`, {
|
||||
timeout: 2 * ONE_MINUTE_MS,
|
||||
});
|
||||
|
||||
// Delete Trigger
|
||||
await containerNode.expand();
|
||||
const triggersNode = await explorer.waitForNode(`${context.database.id}/${context.container.id}/Triggers`);
|
||||
await triggersNode.expand();
|
||||
const triggerNode = await explorer.waitForNode(
|
||||
`${context.database.id}/${context.container.id}/Triggers/${triggerId}`,
|
||||
);
|
||||
|
||||
await triggerNode.openContextMenu();
|
||||
await triggerNode.contextMenuItem("Delete Trigger").click();
|
||||
const deleteTriggerButton = explorer.frame.getByTestId("DialogButton:Delete");
|
||||
await deleteTriggerButton.click();
|
||||
await expect(explorer.getConsoleHeaderStatus()).toContainText(`Successfully deleted trigger ${triggerId}`, {
|
||||
timeout: ONE_MINUTE_MS,
|
||||
});
|
||||
});
|
||||
});
|
||||
82
test/sql/scripts/userDefinedFunction.spec.ts
Normal file
82
test/sql/scripts/userDefinedFunction.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx";
|
||||
import { createTestSQLContainer, TestContainerContext } from "../../testData";
|
||||
|
||||
test.describe("User Defined Functions", () => {
|
||||
let context: TestContainerContext = null!;
|
||||
let explorer: DataExplorer = null!;
|
||||
const udfBody: string = `function extractDocumentId(doc) {
|
||||
return {
|
||||
id: doc.id
|
||||
};
|
||||
}`;
|
||||
|
||||
test.beforeAll("Create Test Database", async () => {
|
||||
context = await createTestSQLContainer();
|
||||
});
|
||||
|
||||
test.beforeEach("Open container", async ({ page }) => {
|
||||
explorer = await DataExplorer.open(page, TestAccount.SQL);
|
||||
});
|
||||
|
||||
if (!process.env.CI) {
|
||||
test.afterAll("Delete Test Database", async () => {
|
||||
await context?.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
test("Add, execute, and delete user defined function", async ({ page }, testInfo) => {
|
||||
// Open container context menu and click New UDF
|
||||
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
|
||||
await containerNode.openContextMenu();
|
||||
await containerNode.contextMenuItem("New UDF").click();
|
||||
|
||||
// Assign UDF id
|
||||
const udfIdTextBox = explorer.frame.getByLabel("User Defined Function Id");
|
||||
const udfId: string = `extractDocumentId-${testInfo.testId}`;
|
||||
await udfIdTextBox.fill(udfId);
|
||||
|
||||
// Create UDF body that extracts the document id from a document
|
||||
const udfBodyTextArea = explorer.frame.getByTestId("EditorReact/Host/Loaded");
|
||||
await udfBodyTextArea.click();
|
||||
|
||||
// Clear existing content
|
||||
const isMac: boolean = process.platform === "darwin";
|
||||
await page.keyboard.press(isMac ? "Meta+A" : "Control+A");
|
||||
await page.keyboard.press("Backspace");
|
||||
|
||||
await page.keyboard.type(udfBody);
|
||||
|
||||
// Save changes
|
||||
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
|
||||
await expect(saveButton).toBeEnabled();
|
||||
await saveButton.click();
|
||||
await expect(explorer.getConsoleHeaderStatus()).toContainText(
|
||||
`Successfully created user defined function ${udfId}`,
|
||||
{
|
||||
timeout: 2 * ONE_MINUTE_MS,
|
||||
},
|
||||
);
|
||||
|
||||
// Delete UDF
|
||||
await containerNode.expand();
|
||||
const udfsNode = await explorer.waitForNode(
|
||||
`${context.database.id}/${context.container.id}/User Defined Functions`,
|
||||
);
|
||||
await udfsNode.expand();
|
||||
const udfNode = await explorer.waitForNode(
|
||||
`${context.database.id}/${context.container.id}/User Defined Functions/${udfId}`,
|
||||
);
|
||||
await udfNode.openContextMenu();
|
||||
await udfNode.contextMenuItem("Delete User Defined Function").click();
|
||||
const deleteUserDefinedFunctionButton = explorer.frame.getByTestId("DialogButton:Delete");
|
||||
await deleteUserDefinedFunctionButton.click();
|
||||
|
||||
await expect(explorer.getConsoleHeaderStatus()).toContainText(
|
||||
`Successfully deleted user defined function ${udfId}`,
|
||||
{
|
||||
timeout: ONE_MINUTE_MS,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,8 @@ test("Tables CRUD", async ({ page }) => {
|
||||
|
||||
const explorer = await DataExplorer.open(page, TestAccount.Tables);
|
||||
|
||||
await explorer.globalCommandButton("New Table").click();
|
||||
const newTableButton = explorer.frame.getByTestId("GlobalCommands").getByRole("button", { name: "New Table" });
|
||||
await newTableButton.click();
|
||||
await explorer.whilePanelOpen(
|
||||
"New Table",
|
||||
async (panel, okButton) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user