Compare commits

..

5 Commits

Author SHA1 Message Date
Bikram Choudhury
cbb4fbe8f4 update FTs for Migration type radio button and action confirmation dialog 2026-01-06 22:38:17 +05:30
Bikram Choudhury
844026dd3c Squashed commit of the following:
commit 8c67026760
Author: Bikram Choudhury <bchoudhury@microsoft.com>
Date:   Tue Dec 30 18:26:10 2025 +0530

    Refactor dropdown utilities and fix test helper functions

commit fda6b59bc1
Author: Bikram Choudhury <bchoudhury@microsoft.com>
Date:   Sun Dec 28 14:32:11 2025 +0530

    Make DataExplorerRoot as an optional check

commit 2ce91f1ef8
Author: Bikram Choudhury <bchoudhury@microsoft.com>
Date:   Sat Dec 13 18:16:53 2025 +0530

    Add test infrastructure and data-test attributes for Container Copy e2e testing
2026-01-06 22:38:17 +05:30
Bikram Choudhury
bfda4b0987 copy job content change 2026-01-06 22:38:17 +05:30
Bikram Choudhury
8f67dfd7ba added missed data-test attribute 2026-01-06 22:38:17 +05:30
Bikram Choudhury
a91043a0ba Change the migration type UI to radio buttons and add confirmation dialogs for canceling or confirming jobs 2026-01-06 22:38:14 +05:30
127 changed files with 1727 additions and 3440 deletions

View File

@@ -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, 17, 18, 19, 20]
shardTotal: [20]
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
shardTotal: [16]
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18.x
@@ -201,20 +201,18 @@ 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
- name: List test files for shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --list
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: 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
@@ -255,4 +253,4 @@ jobs:
with:
name: html-report--attempt-${{ github.run_attempt }}
path: playwright-report
retention-days: 14
retention-days: 14

View File

@@ -6,8 +6,8 @@ on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
schedule:
# Once every two hours
- cron: "0 */2 * * *"
# Once every hour
- cron: "0 15 * * *"
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

View File

@@ -406,11 +406,7 @@ body {
width: 440px;
min-height: 565px;
}
.dataExplorerLoaderforcopyJobs{
width: 100%;
min-height: 565px;
right: 0;
}
.dataExplorerTabLoaderContainer {
left: initial;
top: initial;

2
package-lock.json generated
View File

@@ -116,8 +116,8 @@
"tinykeys": "2.1.0",
"underscore": "1.12.1",
"utility-types": "3.10.0",
"uuid": "9.0.0",
"web-vitals": "4.2.4",
"uuid": "9.0.0",
"zustand": "3.5.0"
},
"devDependencies": {

View File

@@ -11,8 +11,8 @@ export default defineConfig({
reporter: process.env.CI ? "blob" : "html",
timeout: 10 * 60 * 1000,
use: {
trace: "retain-on-failure",
video: "retain-on-failure",
trace: "off",
video: "off",
screenshot: "on",
testIdAttribute: "data-test",
contextOptions: {

View File

@@ -1,5 +1,4 @@
import { Overlay, Spinner, SpinnerSize } from "@fluentui/react";
import { useThemeStore } from "hooks/useTheme";
import React from "react";
interface LoadingOverlayProps {
@@ -8,7 +7,6 @@ interface LoadingOverlayProps {
}
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading, label }) => {
const isDarkMode = useThemeStore((state) => state.isDarkMode);
if (!isLoading) {
return null;
}
@@ -18,7 +16,7 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading, label }) =>
data-test="loading-overlay"
styles={{
root: {
backgroundColor: isDarkMode ? "rgba(32, 31, 30, 0.9)" : "rgba(255,255,255,0.9)",
backgroundColor: "rgba(255,255,255,0.9)",
zIndex: 9999,
display: "flex",
alignItems: "center",
@@ -26,11 +24,7 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading, label }) =>
},
}}
>
<Spinner
size={SpinnerSize.large}
label={label}
styles={{ label: { fontWeight: 600, color: isDarkMode ? "#ffffff" : "#323130" } }}
/>
<Spinner size={SpinnerSize.large} label={label} styles={{ label: { fontWeight: 600 } }} />
</Overlay>
);
};

View File

@@ -11,14 +11,3 @@
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);
}

View File

@@ -59,7 +59,7 @@ const Pager: React.FC<PagerProps> = ({
return (
<div className={className || "pager-container"}>
{showItemCount && (
<Text className="themeText">
<Text>
Showing {startIndex + 1} - {endIndex} of {totalCount} items
</Text>
)}
@@ -82,7 +82,7 @@ const Pager: React.FC<PagerProps> = ({
disabled={disabled || currentPage === 1}
styles={iconButtonStyles}
/>
<Text className="themeText">
<Text>
Page {currentPage} of {totalPages}
</Text>
<IconButton

View File

@@ -9,7 +9,7 @@ import {
SqlStoredProcedureCreateUpdateParameters,
SqlStoredProcedureResource,
} from "../../Utils/arm/generatedClients/cosmos/types";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
@@ -20,7 +20,6 @@ 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 &&
@@ -61,16 +60,14 @@ export async function createStoredProcedure(
storedProcedure.id,
createSprocParams,
);
resource = rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource);
} else {
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.storedProcedures.create(storedProcedure);
resource = response.resource;
return rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource);
}
logConsoleInfo(`Successfully created stored procedure ${storedProcedure.id}`);
return resource;
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.storedProcedures.create(storedProcedure);
return response?.resource;
} catch (error) {
handleError(error, "CreateStoredProcedure", `Error while creating stored procedure ${storedProcedure.id}`);
throw error;

View File

@@ -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 { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
@@ -14,7 +14,6 @@ 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 &&
@@ -53,16 +52,14 @@ export async function createTrigger(
trigger.id,
createTriggerParams,
);
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;
return rpResponse && rpResponse.properties?.resource;
}
logConsoleInfo(`Successfully created trigger ${trigger.id}`);
return 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;
} catch (error) {
handleError(error, "CreateTrigger", `Error while creating trigger ${trigger.id}`);
throw error;

View File

@@ -9,7 +9,7 @@ import {
SqlUserDefinedFunctionCreateUpdateParameters,
SqlUserDefinedFunctionResource,
} from "../../Utils/arm/generatedClients/cosmos/types";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
@@ -20,7 +20,6 @@ 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 &&
@@ -61,17 +60,14 @@ export async function createUserDefinedFunction(
userDefinedFunction.id,
createUDFParams,
);
resource = rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource);
} else {
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.userDefinedFunctions.create(userDefinedFunction);
resource = response.resource;
return rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource);
}
logConsoleInfo(`Successfully created user defined function ${userDefinedFunction.id}`);
return resource;
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.userDefinedFunctions.create(userDefinedFunction);
return response?.resource;
} catch (error) {
handleError(
error,

View File

@@ -28,7 +28,6 @@ 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;

View File

@@ -24,7 +24,6 @@ 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;

View File

@@ -24,7 +24,6 @@ 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;

View File

@@ -348,7 +348,6 @@ export interface Offer {
export interface ThroughputBucket {
id: number;
maxThroughputPercentage: number;
isDefaultBucket?: boolean;
}
export interface SDKOfferDefinition extends Resource {

View File

@@ -1,6 +1,5 @@
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";
@@ -31,7 +30,6 @@ 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(() => {
@@ -43,8 +41,10 @@ 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,6 +52,7 @@ describe("CopyJobActions", () => {
openCreateCopyJobPanel(mockExplorer);
expect(mockSetPanelHasConsole).toHaveBeenCalledWith(false);
expect(mockSetLightDismiss).toHaveBeenCalledWith(false);
expect(mockOpenSidePanel).toHaveBeenCalledWith(expect.any(String), expect.any(Object), "650px");
});
@@ -61,6 +62,7 @@ describe("CopyJobActions", () => {
(useSidePanel.getState as jest.Mock).mockReturnValue({
setPanelHasConsole: jest.fn(),
setLightDismiss: jest.fn(),
openSidePanel: mockOpenSidePanel,
});
@@ -96,9 +98,11 @@ 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,
});
@@ -106,6 +110,7 @@ describe("CopyJobActions", () => {
openCopyJobDetailsPanel(mockJob);
expect(mockSetPanelHasConsole).toHaveBeenCalledWith(false);
expect(mockSetLightDismiss).toHaveBeenCalledWith(true);
expect(mockOpenSidePanel).toHaveBeenCalledWith(expect.stringContaining("test-job"), expect.any(Object), "650px");
});
@@ -135,6 +140,7 @@ describe("CopyJobActions", () => {
(useSidePanel.getState as jest.Mock).mockReturnValue({
setPanelHasConsole: jest.fn(),
setLightDismiss: jest.fn(),
openSidePanel: mockOpenSidePanel,
});
@@ -156,31 +162,33 @@ describe("CopyJobActions", () => {
});
it("should fetch and format copy jobs successfully", async () => {
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",
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",
},
},
},
},
];
],
};
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
@@ -201,36 +209,38 @@ describe("CopyJobActions", () => {
});
it("should filter jobs by CosmosDBSql component", async () => {
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" },
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" },
},
},
},
{
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" },
},
},
},
];
],
};
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
@@ -245,36 +255,38 @@ describe("CopyJobActions", () => {
});
it("should sort jobs by last updated time (newest first)", async () => {
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" },
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" },
},
},
},
{
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" },
},
},
},
];
],
};
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
@@ -289,23 +301,25 @@ describe("CopyJobActions", () => {
});
it("should calculate completion percentage correctly", async () => {
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" },
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" },
},
},
},
];
],
};
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
@@ -319,23 +333,25 @@ describe("CopyJobActions", () => {
});
it("should handle zero total count gracefully", async () => {
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" },
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" },
},
},
},
];
],
};
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
@@ -353,24 +369,26 @@ describe("CopyJobActions", () => {
message: "Error message line 1\r\n\r\nError message line 2",
code: "ErrorCode123",
};
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,
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,
},
},
},
];
],
};
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
@@ -398,7 +416,7 @@ describe("CopyJobActions", () => {
};
(global as any).AbortController = jest.fn(() => mockAbortController);
(getDataTransferJobs as jest.Mock).mockResolvedValue([]);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue({ value: [] });
getCopyJobs();
expect(mockAbortController.abort).not.toHaveBeenCalled();
@@ -408,7 +426,9 @@ describe("CopyJobActions", () => {
});
it("should throw error for invalid response format", async () => {
(getDataTransferJobs as jest.Mock).mockResolvedValue("not-an-array");
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue({
value: "not-an-array",
});
await expect(getCopyJobs()).rejects.toThrow("Invalid migration job status response: Expected an array of jobs.");
});
@@ -418,7 +438,7 @@ describe("CopyJobActions", () => {
message: "Aborted",
content: JSON.stringify({ message: "signal is aborted without reason" }),
};
(getDataTransferJobs as jest.Mock).mockRejectedValue(abortError);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockRejectedValue(abortError);
await expect(getCopyJobs()).rejects.toMatchObject({
message: expect.stringContaining("Previous copy job request was cancelled."),
@@ -427,7 +447,7 @@ describe("CopyJobActions", () => {
it("should handle generic errors", async () => {
const genericError = new Error("Network error");
(getDataTransferJobs as jest.Mock).mockRejectedValue(genericError);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockRejectedValue(genericError);
await expect(getCopyJobs()).rejects.toThrow("Network error");
});

View File

@@ -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,6 +34,7 @@ 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} />,
@@ -44,6 +45,7 @@ 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} />,
@@ -63,8 +65,14 @@ export const getCopyJobs = async (): Promise<CopyJobType[]> => {
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
userContext.databaseAccount?.id || "",
);
const jobs = await getDataTransferJobs(subscriptionId, resourceGroup, accountName, copyJobsAbortController.signal);
const response = await listByDatabaseAccount(
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.");
}

View File

@@ -39,7 +39,7 @@ describe("CopyJobCommandBar", () => {
render(<CopyJobCommandBar explorer={mockExplorer} />);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer, false);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer);
expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(1);
});
@@ -163,7 +163,7 @@ describe("CopyJobCommandBar", () => {
render(<CopyJobCommandBar explorer={mockExplorer} />);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer, false);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer);
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, false);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer1);
rerender(<CopyJobCommandBar explorer={mockExplorer2} />);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer2, false);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer2);
expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,28 +1,24 @@
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
import React from "react";
import { useThemeStore } from "../../../hooks/useTheme";
import { StyleConstants } from "../../../Common/StyleConstants";
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 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 commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer);
const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor);
return (
<div className="commandBarContainer" style={{ backgroundColor }}>
<div className="commandBarContainer">
<FluentCommandBar
ariaLabel="Use left and right arrow keys to navigate between commands"
styles={rootStyle}

View File

@@ -50,7 +50,7 @@ describe("CommandBar Utils", () => {
describe("getCommandBarButtons", () => {
it("should return an array of command button props", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
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, false);
const buttons = getCommandBarButtons(mockExplorer);
const createButton = buttons[0];
expect(createButton).toBeDefined();
@@ -70,7 +70,7 @@ describe("CommandBar Utils", () => {
});
it("should include refresh button", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
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, false);
const buttons = getCommandBarButtons(mockExplorer);
expect(buttons.length).toBe(4);
expect(buttons.length).toBe(3);
const feedbackButton = buttons[3];
const feedbackButton = buttons[2];
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, false);
const buttons = getCommandBarButtonsEmulator(mockExplorer);
expect(buttons.length).toBe(3);
expect(buttons.length).toBe(2);
});
it("should call openCreateCopyJobPanel when create button is clicked", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
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, false);
const buttons = getCommandBarButtons(mockExplorer);
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, false);
const feedbackButton = buttons[3];
const buttons = getCommandBarButtons(mockExplorer);
const feedbackButton = buttons[2];
feedbackButton.onCommandClick({} as React.SyntheticEvent);
@@ -139,7 +139,7 @@ describe("CommandBar Utils", () => {
});
it("should return buttons with correct icon sources", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
expect(buttons[0].iconSrc).toBeDefined();
expect(buttons[0].iconAlt).toBe("Create Copy Job");
@@ -148,10 +148,7 @@ describe("CommandBar Utils", () => {
expect(buttons[1].iconAlt).toBe("Refresh");
expect(buttons[2].iconSrc).toBeDefined();
expect(buttons[2].iconAlt).toBe("Dark Theme");
expect(buttons[3].iconSrc).toBeDefined();
expect(buttons[3].iconAlt).toBe("Feedback");
expect(buttons[2].iconAlt).toBe("Feedback");
});
it("should handle null MonitorCopyJobsRefState ref gracefully", () => {
@@ -160,14 +157,14 @@ describe("CommandBar Utils", () => {
return selector(state);
});
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
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, false);
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(button.hasPopup).toBe(false);
@@ -175,7 +172,7 @@ describe("CommandBar Utils", () => {
});
it("should set commandButtonLabel to undefined for all buttons", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(button.commandButtonLabel).toBeUndefined();
@@ -183,7 +180,7 @@ describe("CommandBar Utils", () => {
});
it("should respect disabled state when provided", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(button.disabled).toBe(false);
@@ -191,7 +188,7 @@ describe("CommandBar Utils", () => {
});
it("should return CommandButtonComponentProps with all required properties", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button: CommandButtonComponentProps) => {
expect(button).toHaveProperty("iconSrc");
@@ -205,19 +202,18 @@ describe("CommandBar Utils", () => {
});
});
it("should maintain button order: create, refresh, themeToggle, feedback", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
it("should maintain button order: create, refresh, feedback", () => {
const buttons = getCommandBarButtons(mockExplorer);
expect(buttons[0].tooltipText).toBe("Create Copy Job");
expect(buttons[1].tooltipText).toBe("Refresh");
expect(buttons[2].tooltipText).toBe("Dark Theme");
expect(buttons[3].tooltipText).toBe("Feedback");
expect(buttons[2].tooltipText).toBe("Feedback");
});
});
describe("Button click handlers", () => {
it("should execute click handlers without errors", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(() => button.onCommandClick({} as React.SyntheticEvent)).not.toThrow();
@@ -225,7 +221,7 @@ describe("CommandBar Utils", () => {
});
it("should call correct action for each button", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
buttons[0].onCommandClick({} as React.SyntheticEvent);
expect(Actions.openCreateCopyJobPanel).toHaveBeenCalledWith(mockExplorer);
@@ -233,14 +229,14 @@ describe("CommandBar Utils", () => {
buttons[1].onCommandClick({} as React.SyntheticEvent);
expect(mockRefreshJobList).toHaveBeenCalled();
buttons[3].onCommandClick({} as React.SyntheticEvent);
buttons[2].onCommandClick({} as React.SyntheticEvent);
expect(mockOpenContainerCopyFeedbackBlade).toHaveBeenCalled();
});
});
describe("Accessibility", () => {
it("should have aria labels for all buttons", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(button.ariaLabel).toBeDefined();
@@ -250,7 +246,7 @@ describe("CommandBar Utils", () => {
});
it("should have tooltip text for all buttons", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(button.tooltipText).toBeDefined();
@@ -260,7 +256,7 @@ describe("CommandBar Utils", () => {
});
it("should have icon alt text for all buttons", () => {
const buttons = getCommandBarButtons(mockExplorer, false);
const buttons = getCommandBarButtons(mockExplorer);
buttons.forEach((button) => {
expect(button.iconAlt).toBeDefined();

View File

@@ -1,10 +1,7 @@
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";
@@ -12,7 +9,7 @@ import ContainerCopyMessages from "../ContainerCopyMessages";
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
import { CopyJobCommandBarBtnType } from "../Types/CopyJobTypes";
function getCopyJobBtns(explorer: Explorer, isDarkMode: boolean): CopyJobCommandBarBtnType[] {
function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] {
const monitorCopyJobsRef = MonitorCopyJobsRefState((state) => state.ref);
const buttons: CopyJobCommandBarBtnType[] = [
{
@@ -29,15 +26,7 @@ function getCopyJobBtns(explorer: Explorer, isDarkMode: boolean): CopyJobCommand
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",
@@ -65,6 +54,6 @@ function btnMapper(config: CopyJobCommandBarBtnType): CommandButtonComponentProp
};
}
export function getCommandBarButtons(explorer: Explorer, isDarkMode: boolean): CommandButtonComponentProps[] {
return getCopyJobBtns(explorer, isDarkMode).map(btnMapper);
export function getCommandBarButtons(explorer: Explorer): CommandButtonComponentProps[] {
return getCopyJobBtns(explorer).map(btnMapper);
}

View File

@@ -184,5 +184,10 @@ export default {
Skipped: "Cancelled",
Cancelled: "Cancelled",
},
dialog: {
heading: "",
confirmButtonText: "Confirm",
cancelButtonText: "Cancel",
},
},
};

View File

@@ -12,12 +12,7 @@ import useToggle from "./hooks/useToggle";
const managedIdentityTooltip = (
<Text>
{ContainerCopyMessages.addManagedIdentity.tooltip.content} &nbsp;
<Link
style={{ color: "var(--colorBrandForeground1)" }}
href={ContainerCopyMessages.addManagedIdentity.tooltip.href}
target="_blank"
rel="noopener noreferrer"
>
<Link href={ContainerCopyMessages.addManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.addManagedIdentity.tooltip.hrefText}
</Link>
</Text>
@@ -31,7 +26,7 @@ const AddManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
return (
<Stack className="addManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<Text className="themeText">
<Text>
{ContainerCopyMessages.addManagedIdentity.description}&ensp;
<Link href={ContainerCopyMessages.addManagedIdentity.descriptionHref} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.addManagedIdentity.descriptionHrefText}

View File

@@ -13,12 +13,7 @@ import useToggle from "./hooks/useToggle";
const TooltipContent = (
<Text>
{ContainerCopyMessages.readPermissionAssigned.tooltip.content} &nbsp;
<Link
style={{ color: "var(--colorBrandForeground1)" }}
href={ContainerCopyMessages.readPermissionAssigned.tooltip.href}
target="_blank"
rel="noopener noreferrer"
>
<Link href={ContainerCopyMessages.readPermissionAssigned.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.readPermissionAssigned.tooltip.hrefText}
</Link>
</Text>

View File

@@ -48,8 +48,8 @@ const PermissionGroup: React.FC<PermissionGroupConfig> = ({ id, title, descripti
tokens={{ childrenGap: 15 }}
styles={{
root: {
background: "var(--colorNeutralBackground2)",
border: "1px solid var(--colorNeutralStroke1)",
background: "#fafafa",
border: "1px solid #e1e1e1",
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, color: "var(--colorNeutralForeground1)" }}>
<Text variant="medium" style={{ fontWeight: 600 }}>
{title}
</Text>
{description && (
<Text variant="small" styles={{ root: { color: "var(--colorNeutralForeground2)" } }}>
<Text variant="small" styles={{ root: { color: "#605E5C" } }}>
{description}
</Text>
)}
@@ -105,7 +105,7 @@ const AssignPermissions = () => {
className="assignPermissionsContainer"
tokens={{ childrenGap: 20 }}
>
<Text variant="medium" style={{ color: "var(--colorNeutralForeground1)" }}>
<Text variant="medium">
{isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online
? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription(
copyJobState?.source?.account?.name || "",

View File

@@ -12,12 +12,7 @@ import useToggle from "./hooks/useToggle";
const managedIdentityTooltip = (
<Text>
{ContainerCopyMessages.defaultManagedIdentity.tooltip.content} &nbsp;
<Link
style={{ color: "var(--colorBrandForeground1)" }}
href={ContainerCopyMessages.defaultManagedIdentity.tooltip.href}
target="_blank"
rel="noopener noreferrer"
>
<Link href={ContainerCopyMessages.defaultManagedIdentity.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.defaultManagedIdentity.tooltip.hrefText}
</Link>
</Text>

View File

@@ -13,12 +13,7 @@ import InfoTooltip from "../Components/InfoTooltip";
const tooltipContent = (
<Text>
{ContainerCopyMessages.pointInTimeRestore.tooltip.content} &nbsp;
<Link
style={{ color: "var(--colorBrandForeground1)" }}
href={ContainerCopyMessages.pointInTimeRestore.tooltip.href}
target="_blank"
rel="noopener noreferrer"
>
<Link href={ContainerCopyMessages.pointInTimeRestore.tooltip.href} target="_blank" rel="noopener noreferrer">
{ContainerCopyMessages.pointInTimeRestore.tooltip.hrefText}
</Link>
</Text>

View File

@@ -5,7 +5,7 @@ exports[`AddManagedIdentity Snapshot Tests renders initial state correctly 1`] =
class="ms-Stack addManagedIdentityContainer css-109"
>
<span
class="themeText css-110"
class="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 dont 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="themeText css-110"
class="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 dont have to store any credentials in code.
@@ -196,13 +196,13 @@ exports[`AddManagedIdentity Snapshot Tests renders loading state 1`] = `
</div>
</div>
<span
class="themeText css-124"
class="css-124"
style="font-weight: 600;"
>
Enable system assigned managed identity
</span>
<span
class="themeText css-110"
class="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="themeText css-110"
class="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 dont 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="themeText css-124"
class="css-124"
style="font-weight: 600;"
>
Enable system assigned managed identity
</span>
<span
class="themeText css-110"
class="css-110"
>
Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button.
</span>

View File

@@ -23,10 +23,10 @@ const PopoverContainer: React.FC<PopoverContainerProps> = React.memo(
style={{ maxWidth: 450 }}
>
<LoadingOverlay isLoading={isLoading} label={ContainerCopyMessages.popoverOverlaySpinnerLabel} />
<Text variant="mediumPlus" className="themeText" style={{ fontWeight: 600 }}>
<Text variant="mediumPlus" style={{ fontWeight: 600 }}>
{title}
</Text>
<Text className="themeText">{children}</Text>
<Text>{children}</Text>
<Stack horizontal tokens={{ childrenGap: 20 }}>
<PrimaryButton text={"Yes"} onClick={onPrimary} disabled={isLoading} />
<DefaultButton text="No" onClick={onCancel} disabled={isLoading} />

View File

@@ -8,11 +8,11 @@ exports[`PopoverMessage Component Edge Cases should handle empty string title 1`
style="max-width: 450px;"
>
<span
class="themeText css-110"
class="css-110"
style="font-weight: 600;"
/>
<span
class="themeText css-111"
class="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="themeText css-110"
class="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="themeText css-110"
class="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="themeText css-110"
class="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="themeText css-111"
class="css-111"
>
<div>
Test content
@@ -274,13 +274,13 @@ exports[`PopoverMessage Component Rendering should render correctly when visible
style="max-width: 450px;"
>
<span
class="themeText css-110"
class="css-110"
style="font-weight: 600;"
>
Test Title
</span>
<span
class="themeText css-111"
class="css-111"
>
<div>
Test content
@@ -344,13 +344,13 @@ exports[`PopoverMessage Component Rendering should render correctly with differe
style="max-width: 450px;"
>
<span
class="themeText css-110"
class="css-110"
style="font-weight: 600;"
>
Test Title
</span>
<span
class="themeText css-111"
class="css-111"
>
<div>
<p>
@@ -419,13 +419,13 @@ exports[`PopoverMessage Component Rendering should render correctly with differe
style="max-width: 450px;"
>
<span
class="themeText css-110"
class="css-110"
style="font-weight: 600;"
>
Custom Title
</span>
<span
class="themeText css-111"
class="css-111"
>
<div>
Test content
@@ -493,13 +493,13 @@ exports[`PopoverMessage Component Rendering should render correctly with loading
data-testid="loading-overlay"
/>
<span
class="themeText css-110"
class="css-110"
style="font-weight: 600;"
>
Test Title
</span>
<span
class="themeText css-111"
class="css-111"
>
<div>
Test content

View File

@@ -41,7 +41,7 @@ const AddCollectionPanelWrapper: React.FunctionComponent<AddCollectionPanelWrapp
return (
<Stack className="addCollectionPanelWrapper">
<Stack.Item className="addCollectionPanelHeader">
<Text className="themeText">{ContainerCopyMessages.createNewContainerSubHeading}</Text>
<Text>{ContainerCopyMessages.createNewContainerSubHeading}</Text>
</Stack.Item>
<Stack.Item className="addCollectionPanelBody">
<AddCollectionPanel explorer={explorer} isCopyJobFlow={true} onSubmitSuccess={handleAddCollectionSuccess} />

View File

@@ -9,7 +9,7 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot 1`]
class="ms-StackItem addCollectionPanelHeader css-110"
>
<span
class="themeText css-111"
class="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="themeText css-111"
class="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="themeText css-111"
class="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="themeText css-111"
class="css-111"
>
Select the properties for your container.
</span>

View File

@@ -36,16 +36,12 @@ const PreviewCopyJob: React.FC = () => {
<TextField data-test="job-name-textfield" value={jobName} onChange={onJobNameChange} />
</FieldRow>
<Stack>
<Text className="bold themeText">{ContainerCopyMessages.sourceSubscriptionLabel}</Text>
<Text data-test="source-subscription-name" className="themeText">
{copyJobState.source?.subscription?.displayName}
</Text>
<Text className="bold">{ContainerCopyMessages.sourceSubscriptionLabel}</Text>
<Text data-test="source-subscription-name">{copyJobState.source?.subscription?.displayName}</Text>
</Stack>
<Stack>
<Text className="bold themeText">{ContainerCopyMessages.sourceAccountLabel}</Text>
<Text data-test="source-account-name" className="themeText">
{copyJobState.source?.account?.name}
</Text>
<Text className="bold">{ContainerCopyMessages.sourceAccountLabel}</Text>
<Text data-test="source-account-name">{copyJobState.source?.account?.name}</Text>
</Stack>
<Stack>
<DetailsList

View File

@@ -47,12 +47,12 @@ exports[`PreviewCopyJob should handle special characters in database and contain
class="ms-Stack css-124"
>
<span
class="bold themeText css-125"
class="bold css-125"
>
Source subscription
</span>
<span
class="themeText css-125"
class="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 themeText css-125"
class="bold css-125"
>
Source account
</span>
<span
class="themeText css-125"
class="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 themeText css-125"
class="bold css-125"
>
Source subscription
</span>
<span
class="themeText css-125"
class="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 themeText css-125"
class="bold css-125"
>
Source account
</span>
<span
class="themeText css-125"
class="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 themeText css-125"
class="bold css-125"
>
Source subscription
</span>
<span
class="themeText css-125"
class="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 themeText css-125"
class="bold css-125"
>
Source account
</span>
<span
class="themeText css-125"
class="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 themeText css-125"
class="bold css-125"
>
Source subscription
</span>
<span
class="themeText css-125"
class="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 themeText css-125"
class="bold css-125"
>
Source account
</span>
<span
class="themeText css-125"
class="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 themeText css-125"
class="bold css-125"
>
Source subscription
</span>
<span
class="themeText css-125"
class="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 themeText css-125"
class="bold 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 themeText css-125"
class="bold 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 themeText css-125"
class="bold css-125"
>
Source account
</span>
<span
class="themeText css-125"
class="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 themeText css-125"
class="bold css-125"
>
Source subscription
</span>
<span
class="themeText css-125"
class="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 themeText css-125"
class="bold css-125"
>
Source account
</span>
<span
class="themeText css-125"
class="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 themeText css-125"
class="bold css-125"
>
Source subscription
</span>
<span
class="themeText css-125"
class="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 themeText css-125"
class="bold css-125"
>
Source account
</span>
<span
class="themeText css-125"
class="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 themeText css-125"
class="bold css-125"
>
Source subscription
</span>
<span
class="themeText css-125"
class="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 themeText css-125"
class="bold css-125"
>
Source account
</span>
<span
class="themeText css-125"
class="css-125"
data-test="source-account-name"
>
test-account

View File

@@ -54,7 +54,6 @@ 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]);

View File

@@ -97,9 +97,9 @@ describe("MigrationType", () => {
describe("Descriptions and Learn More Links", () => {
it("should render online description and learn more link when online is selected", () => {
const { container } = render(<MigrationType />);
render(<MigrationType />);
expect(container.querySelector("[data-test='migration-type-description-online']")).toBeInTheDocument();
expect(screen.getByTestId("migration-type-description-online")).toBeInTheDocument();
const learnMoreLink = screen.getByRole("link", {
name: "online copy jobs",
@@ -121,9 +121,9 @@ describe("MigrationType", () => {
},
});
const { container } = render(<MigrationType />);
render(<MigrationType />);
expect(container.querySelector("[data-test='migration-type-description-offline']")).toBeInTheDocument();
expect(screen.getByTestId("migration-type-description-offline")).toBeInTheDocument();
const learnMoreLink = screen.getByRole("link", {
name: "offline copy jobs",

View File

@@ -21,20 +21,6 @@ const options: IChoiceGroupOption[] = [
},
];
const choiceGroupStyles = {
flexContainer: { display: "flex" as const },
root: {
selectors: {
".ms-ChoiceField": {
color: "var(--colorNeutralForeground1)",
},
".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
color: "var(--colorNeutralForeground1)",
},
},
},
};
export const MigrationType: React.FC<MigrationTypeProps> = React.memo(() => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const handleChange = (_ev?: React.FormEvent, option?: IChoiceGroupOption) => {
@@ -58,7 +44,7 @@ export const MigrationType: React.FC<MigrationTypeProps> = React.memo(() => {
options={options}
onChange={handleChange}
ariaLabelledBy="migrationTypeChoiceGroup"
styles={choiceGroupStyles}
styles={{ flexContainer: { display: "flex" } }}
/>
</Stack.Item>
{selectedKeyContent && (
@@ -66,7 +52,7 @@ export const MigrationType: React.FC<MigrationTypeProps> = React.memo(() => {
<Text
variant="small"
className="migrationTypeDescription"
data-test={`migration-type-description-${selectedKeyLowercase}`}
data-testid={`migration-type-description-${selectedKeyLowercase}`}
>
<MarkdownRender source={selectedKeyContent.description} linkTarget="_blank" />
</Text>

View File

@@ -79,7 +79,7 @@ exports[`MigrationType Component Rendering should render migration type componen
>
<span
class="migrationTypeDescription css-124"
data-test="migration-type-description-online"
data-testid="migration-type-description-online"
>
<div
class="markdown-body "

View File

@@ -8,7 +8,7 @@ import { SubscriptionDropdown } from "./Components/SubscriptionDropdown";
const SelectAccount = React.memo(() => {
return (
<Stack data-test="Panel:SelectAccountContainer" className="selectAccountContainer" tokens={{ childrenGap: 15 }}>
<Text className="themeText">{ContainerCopyMessages.selectAccountDescription}</Text>
<Text>{ContainerCopyMessages.selectAccountDescription}</Text>
<SubscriptionDropdown />

View File

@@ -6,7 +6,7 @@ exports[`SelectAccount Component Rendering should render correctly with snapshot
data-test="Panel:SelectAccountContainer"
>
<span
class="themeText css-110"
class="css-110"
>
Please select a source account from which to copy.
</span>

View File

@@ -52,7 +52,7 @@ const SelectSourceAndTargetContainers = ({ showAddCollectionPanel }: SelectSourc
className="selectSourceAndTargetContainers"
tokens={{ childrenGap: 25 }}
>
<span className="themeText">{ContainerCopyMessages.selectSourceAndTargetContainersDescription}</span>
<span>{ContainerCopyMessages.selectSourceAndTargetContainersDescription}</span>
<DatabaseContainerSection
heading={ContainerCopyMessages.sourceContainerSubHeading}
databaseOptions={sourceDatabaseOptions}

View File

@@ -44,11 +44,7 @@ export const DatabaseContainerSection = ({
data-test={`${sectionType}-containerDropdown`}
/>
{handleOnDemandCreateContainer && (
<ActionButton
className="create-container-link-btn"
style={{ color: "var(--colorBrandForeground1)" }}
onClick={() => handleOnDemandCreateContainer()}
>
<ActionButton className="create-container-link-btn" onClick={() => handleOnDemandCreateContainer()}>
{ContainerCopyMessages.createContainerButtonLabel}
</ActionButton>
)}

View File

@@ -1,3 +1,4 @@
/* eslint-disable jest/no-conditional-expect */
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
@@ -5,6 +6,20 @@ 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: {
@@ -18,6 +33,11 @@ jest.mock("../../ContainerCopyMessages", () => ({
cancel: "Cancel",
complete: "Complete",
},
dialog: {
heading: "Confirm Action",
confirmButtonText: "Confirm",
cancelButtonText: "Cancel",
},
},
},
}));
@@ -50,6 +70,9 @@ describe("CopyJobActionMenu", () => {
beforeEach(() => {
jest.clearAllMocks();
mockShowOkCancelModalDialog.mockClear();
mockCloseDialog.mockClear();
mockOpenDialog.mockClear();
});
describe("Component Rendering", () => {
@@ -266,7 +289,29 @@ describe("CopyJobActionMenu", () => {
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.pause, expect.any(Function));
});
it("should call handleClick when cancel action is clicked", () => {
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", () => {
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
@@ -277,6 +322,9 @@ 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));
});
@@ -294,7 +342,33 @@ describe("CopyJobActionMenu", () => {
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.resume, expect.any(Function));
});
it("should call handleClick when complete action is clicked", () => {
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", () => {
const job = createMockJob({
Status: CopyJobStatusType.InProgress,
Mode: CopyJobMigrationType.Online,
@@ -308,10 +382,87 @@ 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;
@@ -339,8 +490,13 @@ describe("CopyJobActionMenu", () => {
const pauseButton = screen.getByText("Pause");
fireEvent.click(pauseButton);
fireEvent.click(actionButton);
const pauseButtonAfterClick = screen.getByText("Pause");
const pauseButtonAfterClick = screen.getByText("Pause").closest("button");
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", () => {
@@ -360,22 +516,6 @@ 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,
@@ -462,6 +602,7 @@ 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();
@@ -608,4 +749,129 @@ 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();
});
});
});

View File

@@ -1,5 +1,6 @@
import { IconButton, IContextualMenuProps } from "@fluentui/react";
import { DirectionalHint, IconButton, IContextualMenuProps, Stack } 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";
@@ -9,6 +10,28 @@ 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 (
@@ -22,9 +45,22 @@ 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 = [
{
@@ -32,21 +68,21 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
text: ContainerCopyMessages.MonitorJobs.Actions.pause,
iconProps: { iconName: "Pause" },
onClick: () => handleClick(job, CopyJobActions.pause, setUpdatingJobAction),
disabled: isThisJobUpdating && updatingAction === CopyJobActions.pause,
disabled: isThisJobUpdating,
},
{
key: CopyJobActions.cancel,
text: ContainerCopyMessages.MonitorJobs.Actions.cancel,
iconProps: { iconName: "Cancel" },
onClick: () => handleClick(job, CopyJobActions.cancel, setUpdatingJobAction),
disabled: isThisJobUpdating && updatingAction === CopyJobActions.cancel,
onClick: () => showActionConfirmationDialog(job, CopyJobActions.cancel),
disabled: isThisJobUpdating,
},
{
key: CopyJobActions.resume,
text: ContainerCopyMessages.MonitorJobs.Actions.resume,
iconProps: { iconName: "Play" },
onClick: () => handleClick(job, CopyJobActions.resume, setUpdatingJobAction),
disabled: isThisJobUpdating && updatingAction === CopyJobActions.resume,
disabled: isThisJobUpdating,
},
];
@@ -67,8 +103,8 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
key: CopyJobActions.complete,
text: ContainerCopyMessages.MonitorJobs.Actions.complete,
iconProps: { iconName: "CheckMark" },
onClick: () => handleClick(job, CopyJobActions.complete, setUpdatingJobAction),
disabled: isThisJobUpdating && updatingAction === CopyJobActions.complete,
onClick: () => showActionConfirmationDialog(job, CopyJobActions.complete),
disabled: isThisJobUpdating,
});
}
return filteredItems;
@@ -86,8 +122,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() }}
menuIconProps={{ iconName: "" }}
menuProps={{ items: getMenuItems(), directionalHint: DirectionalHint.leftTopEdge, directionalHintFixed: false }}
menuIconProps={{ iconName: "", className: "hidden" }}
ariaLabel={ContainerCopyMessages.MonitorJobs.Columns.actions}
title={ContainerCopyMessages.MonitorJobs.Columns.actions}
/>

View File

@@ -1,6 +1,5 @@
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";
@@ -64,19 +63,6 @@ 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",
@@ -91,10 +77,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 themeText" style={sectionCss.headingText}>
<Text className="bold" style={sectionCss.headingText}>
{ContainerCopyMessages.errorTitle}
</Text>
<Text as="pre" style={errorMessageStyle}>
<Text as="pre" style={{ whiteSpace: "pre-wrap" }}>
{job.Error.message}
</Text>
</Stack.Item>
@@ -102,16 +88,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 themeText">{ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime}</Text>
<Text className="themeText">{job.LastUpdatedTime}</Text>
<Text className="bold">{ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime}</Text>
<Text>{job.LastUpdatedTime}</Text>
</Stack.Item>
<Stack.Item style={sectionCss.verticalAlign}>
<Text className="bold themeText">{ContainerCopyMessages.sourceAccountLabel}</Text>
<Text className="themeText">{job.Source?.remoteAccountName}</Text>
<Text className="bold">{ContainerCopyMessages.sourceAccountLabel}</Text>
<Text>{job.Source?.remoteAccountName}</Text>
</Stack.Item>
<Stack.Item style={sectionCss.verticalAlign}>
<Text className="bold themeText">{ContainerCopyMessages.MonitorJobs.Columns.mode}</Text>
<Text className="themeText">{job.Mode}</Text>
<Text className="bold">{ContainerCopyMessages.MonitorJobs.Columns.mode}</Text>
<Text>{job.Mode}</Text>
</Stack.Item>
</Stack>
</Stack.Item>

View File

@@ -1,14 +1,30 @@
import { FontIcon, mergeStyles, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
import { FontIcon, getTheme, mergeStyles, mergeStyleSets, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
import PropTypes from "prop-types";
import React from "react";
import ContainerCopyMessages from "../../ContainerCopyMessages";
import { CopyJobStatusType } from "../../Enums/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",
@@ -19,17 +35,6 @@ 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;
}
@@ -42,17 +47,19 @@ 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={iconStyle} />
<FontIcon
aria-label={status}
iconName={iconMap[status] || "UnknownSolid"}
className={classNames[status] || classNames.unknown}
/>
)}
<Text className="themeText">{statusText}</Text>
<Text>{statusText}</Text>
</Stack>
);
});

View File

@@ -15,8 +15,6 @@ 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";
@@ -28,15 +26,13 @@ interface CopyJobsListProps {
}
const styles = {
container: { height: "100%" } as React.CSSProperties,
container: { height: "calc(100vh - 25em)" } 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);
@@ -92,28 +88,11 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
enableShimmer={false}
constrainMode={ConstrainMode.unconstrained}
layoutMode={DetailsListLayoutMode.justified}
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>
);
}}
onRenderDetailsHeader={(props, defaultRender) => (
<Sticky stickyPosition={StickyPositionType.Header} isScrollSynced>
{defaultRender({ ...props })}
</Sticky>
)}
/>
</ScrollablePane>
</Stack.Item>

View File

@@ -13,7 +13,7 @@ exports[`CopyJobStatusWithIcon Spinner Status Types renders InProgress with spin
/>
</div>
<span
class="themeText css-112"
class="css-112"
>
Running
</span>
@@ -33,7 +33,7 @@ exports[`CopyJobStatusWithIcon Spinner Status Types renders Partitioning with sp
/>
</div>
<span
class="themeText css-112"
class="css-112"
>
Running
</span>
@@ -53,7 +53,7 @@ exports[`CopyJobStatusWithIcon Spinner Status Types renders Running with spinner
/>
</div>
<span
class="themeText css-112"
class="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-styles"
class="ms-Icon root-105 css-118 mocked-style-Cancelled"
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="themeText css-112"
class="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-styles"
class="ms-Icon root-105 css-120 mocked-style-Completed"
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="themeText css-112"
class="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-styles"
class="ms-Icon root-105 css-118 mocked-style-Failed"
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="themeText css-112"
class="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-styles"
class="ms-Icon root-105 css-118 mocked-style-Faulted"
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="themeText css-112"
class="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-styles"
class="ms-Icon root-105 css-114 mocked-style-Paused"
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="themeText css-112"
class="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-styles"
class="ms-Icon root-105 css-111 mocked-style-Pending"
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="themeText css-112"
class="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-styles"
class="ms-Icon root-105 css-116 mocked-style-Skipped"
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="themeText css-112"
class="css-112"
>
Cancelled
</span>

View File

@@ -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 = 2 * 60 * 1000;
const FETCH_INTERVAL_MS = 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);
const intervalId = setInterval(fetchJobs, FETCH_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [fetchJobs]);

View File

@@ -1,30 +1,6 @@
@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;
@@ -33,30 +9,20 @@
.noCopyJobsMessage {
font-weight: 600;
margin: 0 auto;
color: var(--colorNeutralForeground2);
color: @FocusColor;
}
button.createCopyJobButton {
color: var(--colorBrandForeground1);
color: @LinkColor;
}
}
}
.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;
@@ -105,7 +71,7 @@
}
.foreground {
z-index: 10;
background-color: var(--colorNeutralBackground2);
background-color: #f9f9f9;
padding: 20px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transform: translate(0%, -9%);
@@ -114,48 +80,14 @@
.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);
}
}
.migrationTypeDescription {
p {
color: var(--colorNeutralForeground1);
}
a {
color: var(--colorBrandForeground1);
}
}
}
.create-container-link-btn {
padding: 0;
height: 25px;
color: var(--colorBrandForeground1);
color: @LinkColor;
&:focus {
outline: none;
}
}
/* Create collection panel */
@@ -173,6 +105,7 @@
width: 100%;
max-width: 100%;
margin: 0 auto;
.ms-DetailsList {
width: 100%;
@@ -181,36 +114,36 @@
padding: @DefaultSpace 20px;
font-weight: 600;
font-size: @DefaultFontSize;
color: var(--colorNeutralForeground1);
background-color: var(--colorNeutralBackground2);
border-bottom: @ButtonBorderWidth solid var(--colorNeutralStroke1);
color: @BaseHigh;
background-color: @BaseLow;
border-bottom: @ButtonBorderWidth solid @BaseMedium;
&:hover {
background-color: var(--colorNeutralBackground3);
background-color: @BaseMediumLow;
}
.ms-DetailsHeader-cellTitle {
padding-left: 20px;
}
}
.ms-DetailsHeader-cellTitle {
padding-left: 20px;
}
}
.ms-DetailsRow {
border-bottom: @ButtonBorderWidth solid var(--colorNeutralStroke1);
border-bottom: @ButtonBorderWidth solid @BaseMedium;
&:hover {
background-color: var(--colorNeutralBackground2);
background-color: @BaseMediumLow;
}
.ms-DetailsRow-cell {
padding: @MediumSpace 20px;
font-size: @DefaultFontSize;
color: var(--colorNeutralForeground1);
color: @BaseHigh;
min-height: 48px;
display: flex;
align-items: center;
.jobNameLink {
color: var(--colorBrandForeground1);
color: @LinkColor;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
@@ -238,7 +171,7 @@
}
.ms-DetailsRow-cell {
font-size: @DefaultFontSize;
color: var(--colorNeutralForeground1);
color: @BaseHigh;
}
}
}

View File

@@ -1,8 +1,5 @@
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";
@@ -447,49 +444,3 @@ 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);
});
});

View File

@@ -13,7 +13,6 @@ 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";
@@ -74,6 +73,7 @@ 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,13 +312,6 @@ 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();
@@ -326,11 +319,7 @@ 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());
@@ -860,6 +849,7 @@ 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 {
@@ -1019,31 +1009,10 @@ 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 ||
@@ -1283,6 +1252,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
onScaleDiscardableChange: this.onScaleDiscardableChange,
throughputError: this.state.throughputError,
};
if (!this.isCollectionSettingsTab) {
return (
<div className="settingsV2MainContainer">

View File

@@ -155,12 +155,7 @@ export class ComputedPropertiesComponent extends React.Component<
</Link>
&#160; about how to define computed properties and how to use them.
</Text>
<div
className="settingsV2Editor"
tabIndex={0}
ref={this.computedPropertiesDiv}
data-test="computed-properties-editor"
></div>
<div className="settingsV2Editor" tabIndex={0} ref={this.computedPropertiesDiv}></div>
</Stack>
);
}

View File

@@ -1,10 +1,10 @@
import { MessageBar, MessageBarType } from "@fluentui/react";
import * as React from "react";
import { handleError } from "../../../../../Common/ErrorHandlingUtils";
import { MessageBar, MessageBarType } from "@fluentui/react";
import {
mongoIndexTransformationRefreshingMessage,
renderMongoIndexTransformationRefreshMessage,
} from "../../SettingsRenderUtils";
import { handleError } from "../../../../../Common/ErrorHandlingUtils";
import { isIndexTransforming } from "../../SettingsUtils";
export interface IndexingPolicyRefreshComponentProps {

View File

@@ -302,8 +302,8 @@ export class SubSettingsComponent extends React.Component<SubSettingsComponentPr
);
private geoSpatialConfigTypeChoiceGroupOptions: IChoiceGroupOption[] = [
{ key: GeospatialConfigType.Geography, text: "Geography", ariaLabel: "geography-option" },
{ key: GeospatialConfigType.Geometry, text: "Geometry", ariaLabel: "geometry-option" },
{ key: GeospatialConfigType.Geography, text: "Geography" },
{ key: GeospatialConfigType.Geometry, text: "Geometry" },
];
private getGeoSpatialComponent = (): JSX.Element => (

View File

@@ -76,11 +76,11 @@ describe("ThroughputBucketsComponent", () => {
fireEvent.change(input, { target: { value: "70" } });
expect(mockOnBucketsChange).toHaveBeenCalledWith([
{ id: 1, maxThroughputPercentage: 70, isDefaultBucket: false },
{ id: 2, maxThroughputPercentage: 60, isDefaultBucket: false },
{ id: 3, maxThroughputPercentage: 100, isDefaultBucket: false },
{ id: 4, maxThroughputPercentage: 100, isDefaultBucket: false },
{ id: 5, maxThroughputPercentage: 100, isDefaultBucket: false },
{ id: 1, maxThroughputPercentage: 70 },
{ id: 2, maxThroughputPercentage: 60 },
{ id: 3, maxThroughputPercentage: 100 },
{ id: 4, maxThroughputPercentage: 100 },
{ id: 5, maxThroughputPercentage: 100 },
]);
});
@@ -102,11 +102,11 @@ describe("ThroughputBucketsComponent", () => {
fireEvent.change(input2, { target: { value: "80" } });
expect(mockOnBucketsChange).toHaveBeenCalledWith([
{ id: 1, maxThroughputPercentage: 70, isDefaultBucket: false },
{ id: 2, maxThroughputPercentage: 80, isDefaultBucket: false },
{ id: 3, maxThroughputPercentage: 100, isDefaultBucket: false },
{ id: 4, maxThroughputPercentage: 100, isDefaultBucket: false },
{ id: 5, maxThroughputPercentage: 100, isDefaultBucket: false },
{ id: 1, maxThroughputPercentage: 70 },
{ id: 2, maxThroughputPercentage: 80 },
{ id: 3, maxThroughputPercentage: 100 },
{ id: 4, maxThroughputPercentage: 100 },
{ id: 5, maxThroughputPercentage: 100 },
]);
});
@@ -134,8 +134,8 @@ describe("ThroughputBucketsComponent", () => {
<ThroughputBucketsComponent
{...defaultProps}
currentBuckets={[
{ id: 1, maxThroughputPercentage: 100, isDefaultBucket: false },
{ id: 2, maxThroughputPercentage: 50, isDefaultBucket: false },
{ id: 1, maxThroughputPercentage: 100 },
{ id: 2, maxThroughputPercentage: 50 },
]}
/>,
);
@@ -157,21 +157,21 @@ describe("ThroughputBucketsComponent", () => {
fireEvent.click(toggles[0]);
expect(mockOnBucketsChange).toHaveBeenCalledWith([
{ id: 1, maxThroughputPercentage: 100, isDefaultBucket: false },
{ id: 2, maxThroughputPercentage: 60, isDefaultBucket: false },
{ id: 3, maxThroughputPercentage: 100, isDefaultBucket: false },
{ id: 4, maxThroughputPercentage: 100, isDefaultBucket: false },
{ id: 5, maxThroughputPercentage: 100, isDefaultBucket: false },
{ id: 1, maxThroughputPercentage: 100 },
{ id: 2, maxThroughputPercentage: 60 },
{ id: 3, maxThroughputPercentage: 100 },
{ id: 4, maxThroughputPercentage: 100 },
{ id: 5, maxThroughputPercentage: 100 },
]);
fireEvent.click(toggles[0]);
expect(mockOnBucketsChange).toHaveBeenCalledWith([
{ id: 1, maxThroughputPercentage: 50, isDefaultBucket: false },
{ id: 2, maxThroughputPercentage: 60, isDefaultBucket: false },
{ id: 3, maxThroughputPercentage: 100, isDefaultBucket: false },
{ id: 4, maxThroughputPercentage: 100, isDefaultBucket: false },
{ id: 5, maxThroughputPercentage: 100, isDefaultBucket: false },
{ id: 1, maxThroughputPercentage: 50 },
{ id: 2, maxThroughputPercentage: 60 },
{ id: 3, maxThroughputPercentage: 100 },
{ id: 4, maxThroughputPercentage: 100 },
{ id: 5, maxThroughputPercentage: 100 },
]);
});

View File

@@ -1,16 +1,4 @@
import {
Dropdown,
Icon,
IDropdownOption,
Label,
Link,
Slider,
Stack,
Text,
TextField,
Toggle,
TooltipHost,
} from "@fluentui/react";
import { Label, Slider, Stack, TextField, Toggle } from "@fluentui/react";
import { ThroughputBucket } from "Contracts/DataModels";
import React, { FC, useEffect, useState } from "react";
import { isDirty } from "../../SettingsUtils";
@@ -20,7 +8,6 @@ const MAX_BUCKET_SIZES = 5;
const DEFAULT_BUCKETS = Array.from({ length: MAX_BUCKET_SIZES }, (_, i) => ({
id: i + 1,
maxThroughputPercentage: 100,
isDefaultBucket: false,
}));
export interface ThroughputBucketsComponentProps {
@@ -36,51 +23,19 @@ export const ThroughputBucketsComponent: FC<ThroughputBucketsComponentProps> = (
onBucketsChange,
onSaveableChange,
}) => {
const NoDefaultThroughputSelectedKey: number = -1;
const getThroughputBuckets = (buckets: ThroughputBucket[]): ThroughputBucket[] => {
if (!buckets || buckets.length === 0) {
return DEFAULT_BUCKETS;
}
const maxBuckets = Math.max(DEFAULT_BUCKETS.length, buckets.length);
const adjustedDefaultBuckets: ThroughputBucket[] = Array.from(
{ length: maxBuckets },
(_, i) =>
({
id: i + 1,
maxThroughputPercentage: 100,
isDefaultBucket: false,
}) as ThroughputBucket,
);
return adjustedDefaultBuckets.map((defaultBucket: ThroughputBucket) => {
const incoming: ThroughputBucket = buckets?.find((bucket) => bucket.id === defaultBucket.id);
return {
...defaultBucket,
...incoming,
...(incoming?.isDefaultBucket && { isDefaultBucket: true }),
};
});
};
const getThroughputBucketOptions = (): IDropdownOption[] => {
const noDefaultThroughputBucketSelected: IDropdownOption = {
key: NoDefaultThroughputSelectedKey,
text: "No Default Throughput Bucket Selected",
};
const throughputBucketOptions: IDropdownOption[] = throughputBuckets.map((bucket) => ({
key: bucket.id,
text: `Bucket ${bucket.id} - ${bucket.maxThroughputPercentage}%`,
disabled: bucket.maxThroughputPercentage === 100,
...(bucket.maxThroughputPercentage === 100 && {
data: {
tooltip: `Bucket ${bucket.id} is not active.`,
},
}),
const adjustedDefaultBuckets = Array.from({ length: maxBuckets }, (_, i) => ({
id: i + 1,
maxThroughputPercentage: 100,
}));
return [noDefaultThroughputBucketSelected, ...throughputBucketOptions];
return adjustedDefaultBuckets.map(
(defaultBucket) => buckets?.find((bucket) => bucket.id === defaultBucket.id) || defaultBucket,
);
};
const [throughputBuckets, setThroughputBuckets] = useState<ThroughputBucket[]>(getThroughputBuckets(currentBuckets));
@@ -97,13 +52,7 @@ export const ThroughputBucketsComponent: FC<ThroughputBucketsComponentProps> = (
const handleBucketChange = (id: number, newValue: number) => {
const updatedBuckets = throughputBuckets.map((bucket) =>
bucket.id === id
? {
...bucket,
maxThroughputPercentage: newValue,
isDefaultBucket: newValue === 100 ? false : bucket.isDefaultBucket,
}
: bucket,
bucket.id === id ? { ...bucket, maxThroughputPercentage: newValue } : bucket,
);
setThroughputBuckets(updatedBuckets);
const settingsChanged = isDirty(updatedBuckets, throughputBuckets);
@@ -114,35 +63,6 @@ export const ThroughputBucketsComponent: FC<ThroughputBucketsComponentProps> = (
handleBucketChange(id, checked ? 50 : 100);
};
const onDefaultBucketToggle = (id: number, checked: boolean): void => {
const updatedBuckets: ThroughputBucket[] = throughputBuckets.map((bucket) =>
bucket.id === id ? { ...bucket, isDefaultBucket: checked } : { ...bucket, isDefaultBucket: false },
);
setThroughputBuckets(updatedBuckets);
const settingsChanged = isDirty(updatedBuckets, throughputBuckets);
settingsChanged && onBucketsChange(updatedBuckets);
};
const onRenderDefaultThroughputBucketLabel = (): JSX.Element => {
const tooltipContent = (): JSX.Element => (
<Text>
The default throughput bucket is used for operations that do not specify a particular bucket.{" "}
<Link href="https://aka.ms/cosmsodb-bucketing" target="_blank">
Learn more.
</Link>
</Text>
);
return (
<Stack horizontal verticalAlign="center">
<Label>Default Throughput Bucket</Label>
<TooltipHost content={tooltipContent()}>
<Icon iconName="Info" styles={{ root: { marginLeft: 4, marginTop: 5 } }} />
</TooltipHost>
</Stack>
);
};
return (
<Stack tokens={{ childrenGap: "m" }} styles={{ root: { width: "70%", maxWidth: 700 } }}>
<Label styles={{ root: { color: "var(--colorNeutralForeground1)" } }}>Throughput Buckets</Label>
@@ -177,58 +97,17 @@ export const ThroughputBucketsComponent: FC<ThroughputBucketsComponentProps> = (
fieldGroup: { width: 80 },
}}
disabled={bucket.maxThroughputPercentage === 100}
data-test={`bucket-${bucket.id}-percentage-input`}
/>
<Toggle
onText="Active"
offText="Inactive"
checked={bucket.maxThroughputPercentage !== 100}
onChange={(event, checked) => onToggle(bucket.id, checked)}
styles={{
root: { marginBottom: 0 },
text: { fontSize: 12, color: "var(--colorNeutralForeground1)" },
}}
data-test={`bucket-${bucket.id}-active-toggle`}
styles={{ root: { marginBottom: 0 }, text: { fontSize: 12 } }}
></Toggle>
{/* <Toggle
onText="Default"
offText="Not Default"
checked={bucket.isDefaultBucket || false}
onChange={(_, checked) => onDefaultBucketToggle(bucket.id, checked)}
disabled={bucket.maxThroughputPercentage === 100}
styles={{
root: { marginBottom: 0 },
text: { fontSize: 12, color: "var(--colorNeutralForeground1)" },
}}
/> */}
</Stack>
))}
</Stack>
<Dropdown
placeholder="Select a default throughput bucket"
label="Default Throughput Bucket"
options={getThroughputBucketOptions()}
selectedKey={
throughputBuckets?.find((throughputbucket: ThroughputBucket) => throughputbucket.isDefaultBucket)?.id ||
NoDefaultThroughputSelectedKey
}
onChange={(_, option) => onDefaultBucketToggle(option.key as number, true)}
onRenderLabel={onRenderDefaultThroughputBucketLabel}
onRenderOption={(option: IDropdownOption) => {
const tooltip: string = option?.data?.tooltip;
if (!tooltip) {
return <>{option?.text}</>;
}
return (
<TooltipHost content={tooltip}>
<span>{option?.text}</span>
</TooltipHost>
);
}}
styles={{ root: { width: "50%" } }}
data-test="default-throughput-bucket-dropdown"
/>
</Stack>
);
};

View File

@@ -31,7 +31,6 @@ exports[`ComputedPropertiesComponent renders 1`] = `
</Text>
<div
className="settingsV2Editor"
data-test="computed-properties-editor"
tabIndex={0}
/>
</Stack>

View File

@@ -167,12 +167,10 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -654,12 +652,10 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -1228,12 +1224,10 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -1766,12 +1760,10 @@ exports[`SubSettingsComponent renders 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -2338,12 +2330,10 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},

View File

@@ -153,16 +153,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{
@@ -274,16 +264,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{
@@ -496,16 +476,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{
@@ -683,16 +653,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{

View File

@@ -53,7 +53,6 @@ type VectorEmbeddingPolicyProperty = "dataType" | "distanceFunction" | "indexTyp
const labelStyles = {
root: {
fontSize: 12,
color: "var(--colorNeutralForeground1)",
},
};
@@ -64,8 +63,6 @@ const textFieldStyles: IStyleFunctionOrObject<ITextFieldStyleProps, ITextFieldSt
field: {
fontSize: 12,
padding: "0 8px",
backgroundColor: "var(--colorNeutralBackground1)",
color: "var(--colorNeutralForeground1)",
},
};

View File

@@ -437,14 +437,13 @@ export default class Explorer {
public onRefreshResourcesClick = async (): Promise<void> => {
if (isFabricMirroredKey()) {
scheduleRefreshFabricToken(true).then(() => this.refreshAllDatabases());
} else {
await (userContext.authType === AuthType.ResourceToken
? this.refreshDatabaseForResourceToken()
: this.refreshAllDatabases());
await this.refreshNotebookList();
return;
}
logConsoleInfo("Successfully refreshed databases");
await (userContext.authType === AuthType.ResourceToken
? this.refreshDatabaseForResourceToken()
: this.refreshAllDatabases());
await this.refreshNotebookList();
};
// Facade

View File

@@ -853,7 +853,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
{!isSynapseLinkEnabled() && (
<Stack className="panelGroupSpacing">
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
<Text variant="small">
Azure Synapse Link is required for creating an analytical store{" "}
{getCollectionName().toLocaleLowerCase()}. Enable Synapse Link for this Cosmos DB account. <br />
<Link

View File

@@ -475,11 +475,6 @@ 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

View File

@@ -8,6 +8,7 @@ describe("PaneContainerComponent test", () => {
headerText: "test",
panelContent: <div></div>,
isOpen: true,
isLightDismiss: true,
hasConsole: false,
isConsoleExpanded: false,
};
@@ -20,6 +21,7 @@ describe("PaneContainerComponent test", () => {
headerText: "test",
panelContent: <div></div>,
isOpen: true,
isLightDismiss: true,
hasConsole: true,
isConsoleExpanded: false,
};
@@ -32,6 +34,7 @@ describe("PaneContainerComponent test", () => {
headerText: "test",
panelContent: undefined,
isOpen: true,
isLightDismiss: true,
hasConsole: true,
isConsoleExpanded: false,
};
@@ -44,6 +47,7 @@ describe("PaneContainerComponent test", () => {
headerText: "test",
panelContent: <div></div>,
isOpen: true,
isLightDismiss: true,
hasConsole: true,
isConsoleExpanded: true,
};

View File

@@ -9,6 +9,7 @@ export interface PanelContainerProps {
isConsoleExpanded: boolean;
isOpen: boolean;
hasConsole: boolean;
isLightDismiss: boolean;
isConsoleAnimationFinished?: boolean;
panelWidth?: string;
onRenderNavigationContent?: IRenderFunction<IPanelProps>;
@@ -58,7 +59,7 @@ export class PanelContainerComponent extends React.Component<PanelContainerProps
headerText={this.props.headerText}
isOpen={this.props.isOpen}
onDismiss={this.onDissmiss}
isLightDismiss
isLightDismiss={this.props.isLightDismiss}
type={PanelType.custom}
closeButtonAriaLabel={`Close ${this.props.headerText}`}
customWidth={this.props.panelWidth ? this.props.panelWidth : "440px"}
@@ -140,10 +141,11 @@ 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, panelContent, panelWidth, headerText } = useSidePanel((state) => {
const { isOpen, hasConsole, isLightDismiss, panelContent, panelWidth, headerText } = useSidePanel((state) => {
return {
isOpen: state.isOpen,
hasConsole: state.hasConsole,
isLightDismiss: state.isLightDismiss,
panelContent: state.panelContent,
headerText: state.headerText,
panelWidth: state.panelWidth,
@@ -154,6 +156,7 @@ export const SidePanel: React.FC = () => {
return (
<PanelContainerComponent
hasConsole={hasConsole}
isLightDismiss={isLightDismiss}
isOpen={isOpen}
panelContent={panelContent}
headerText={headerText}

View File

@@ -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 dataExplorerLoaderforcopyJobs">
<div id="loadingScreen" className="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer">
<img className="dataExplorerLoader" src={LoadingIndicator_3Squares} />
</div>
);

View File

@@ -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" data-test="file-upload-status">
<div className="fileUploadSummaryContainer">
<b style={{ color: "var(--colorNeutralForeground1)" }}>File upload status</b>
<DetailsList
items={uploadFileData}

View File

@@ -1,107 +0,0 @@
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>
);
};

View File

@@ -3,21 +3,18 @@ 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 {
@@ -52,8 +49,6 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
queryResults,
executeQueryDocumentsPage,
isExecuting,
databaseId,
containerId,
}: QueryResultProps): JSX.Element => {
const styles = useQueryTabStyles();
const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent);
@@ -96,9 +91,6 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
queryResults={queryResults}
executeQueryDocumentsPage={executeQueryDocumentsPage}
isMongoDB={isMongoDB}
queryEditorContent={queryEditorContent}
databaseId={databaseId}
containerId={containerId}
/>
) : (
<ExecuteQueryCallToAction />

View File

@@ -52,9 +52,8 @@ describe("QueryTabComponent", () => {
copilotVersion: "v3.0",
},
});
const propsMock: Readonly<IQueryTabComponentProps> = {
collection: { databaseId: "CopilotSampleDB", id: () => "CopilotContainer" },
collection: { databaseId: "CopilotSampleDB" },
onTabAccessor: () => jest.fn(),
isExecutionError: false,
tabId: "mockTabId",

View File

@@ -28,7 +28,6 @@ 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";
@@ -58,20 +57,6 @@ 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,
@@ -279,10 +264,6 @@ 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 () => {
@@ -799,8 +780,6 @@ 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,
@@ -816,8 +795,6 @@ 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)
}

View File

@@ -1,170 +0,0 @@
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();
});
});

View File

@@ -1,8 +1,5 @@
import type { CompositePath, IndexingPolicy } from "@azure/cosmos";
import { FontIcon } from "@fluentui/react";
import {
Button,
Checkbox,
DataGrid,
DataGridBody,
DataGridCell,
@@ -11,45 +8,28 @@ import {
DataGridRow,
SelectTabData,
SelectTabEvent,
Spinner,
Tab,
TabList,
Table,
TableBody,
TableCell,
TableColumnDefinition,
TableHeader,
TableRow,
createTableColumn,
} from "@fluentui/react-components";
import { ArrowDownloadRegular, ChevronDown20Regular, ChevronRight20Regular, CopyRegular } from "@fluentui/react-icons";
import copy from "clipboard-copy";
import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons";
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 { logConsoleProgress } from "Utils/NotificationConsoleUtils";
import create from "zustand";
import { client } from "../../../Common/CosmosClient";
import { handleError } from "../../../Common/ErrorHandlingUtils";
import { sampleDataClient } from "../../../Common/SampleDataClient";
import copy from "clipboard-copy";
import React, { useCallback, useState } from "react";
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 */
@@ -543,331 +523,14 @@ const QueryStatsTab: React.FC<Pick<ResultsViewProps, "queryResults">> = ({ query
);
};
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,
}) => {
export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => {
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}>
@@ -885,13 +548,6 @@ export const ResultsView: React.FC<ResultsViewProps> = ({
>
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 && (
@@ -902,30 +558,7 @@ export const ResultsView: React.FC<ResultsViewProps> = ({
/>
)}
{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 },
},
})),
}));

View File

@@ -1,95 +0,0 @@
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",
},
},
});

View File

@@ -1,15 +0,0 @@
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 }),
}));

View File

@@ -435,6 +435,7 @@ export default class StoredProcedureTabComponent extends React.Component<
});
useCommandBar.getState().setContextButtons(this.getTabsButtons());
}, 100);
return createdResource;
},
(createError) => {

View File

@@ -598,13 +598,7 @@ export default class Collection implements ViewModels.Collection {
public onSettingsClick = async (): Promise<void> => {
useSelectedNode.getState().setSelectedNode(this);
const throughputCap = userContext.databaseAccount?.properties.capacity?.totalThroughputLimit;
if (throughputCap && throughputCap !== -1) {
await this.container.onRefreshResourcesClick();
await useDatabases.getState().loadAllOffers();
} else {
await this.loadOffer();
}
// throughputCap && throughputCap !== -1 ? await useDatabases.getState().loadAllOffers() : await this.loadOffer();
throughputCap && throughputCap !== -1 ? await useDatabases.getState().loadAllOffers() : await this.loadOffer();
this.selectedSubnodeKind(ViewModels.CollectionTabKind.Settings);
TelemetryProcessor.trace(Action.SelectItem, ActionModifiers.Mark, {
description: "Settings node",

View File

@@ -71,7 +71,6 @@ export default class Database implements ViewModels.Database {
const throughputCap = userContext.databaseAccount?.properties.capacity?.totalThroughputLimit;
if (throughputCap && throughputCap !== -1) {
await this.container.onRefreshResourcesClick();
await useDatabases.getState().loadAllOffers();
}

View File

@@ -128,6 +128,7 @@ const App = (): JSX.Element => {
<>
<ContainerCopyPanel explorer={explorer} />
<SidePanel />
<Dialog />
</>
) : (
<DivExplorer explorer={explorer} />

View File

@@ -1,7 +1,6 @@
import { Metric, onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";
import { configContext } from "../ConfigContext";
import { Action } from "../Shared/Telemetry/TelemetryConstants";
import { traceFailure, traceMark, traceStart, traceSuccess } from "../Shared/Telemetry/TelemetryProcessor";
import { trackEvent } from "../Shared/appInsights";
import { userContext } from "../UserContext";
import MetricScenario, { reportHealthy, reportUnhealthy } from "./MetricEvents";
import { scenarioConfigs } from "./MetricScenarioConfigs";
@@ -84,13 +83,6 @@ class ScenarioMonitor {
ctx.phases.set(phase, { startMarkName: phaseStartMarkName });
});
traceMark(Action.MetricsScenario, {
event: "scenario_start",
scenario,
requiredPhases: config.requiredPhases.join(","),
timeoutMs: config.timeoutMs,
});
ctx.timeoutId = window.setTimeout(() => this.emit(ctx, false, true), config.timeoutMs);
this.contexts.set(scenario, ctx);
}
@@ -104,12 +96,6 @@ class ScenarioMonitor {
const startMarkName = `scenario_${scenario}_${phase}_start`;
performance.mark(startMarkName);
ctx.phases.set(phase, { startMarkName });
traceStart(Action.MetricsScenario, {
event: "phase_start",
scenario,
phase,
});
}
completePhase(scenario: MetricScenario, phase: MetricPhase) {
@@ -124,22 +110,6 @@ class ScenarioMonitor {
phaseCtx.endMarkName = endMarkName;
ctx.completed.add(phase);
const navigationStart = performance.timeOrigin;
const startEntry = performance.getEntriesByName(phaseCtx.startMarkName)[0];
const endEntry = performance.getEntriesByName(endMarkName)[0];
const endTimeISO = endEntry ? new Date(navigationStart + endEntry.startTime).toISOString() : undefined;
const durationMs = startEntry && endEntry ? endEntry.startTime - startEntry.startTime : undefined;
traceSuccess(Action.MetricsScenario, {
event: "phase_complete",
scenario,
phase,
endTimeISO,
durationMs,
completedCount: ctx.completed.size,
requiredCount: ctx.config.requiredPhases.length,
});
this.tryEmitIfReady(ctx);
}
@@ -163,14 +133,6 @@ class ScenarioMonitor {
// Build a snapshot with failure info
const failureSnapshot = this.buildSnapshot(ctx, { final: false, timedOut: false });
traceFailure(Action.MetricsScenario, {
event: "phase_fail",
scenario,
phase,
failedPhases: Array.from(ctx.failed).join(","),
completedPhases: Array.from(ctx.completed).join(","),
});
// Emit unhealthy immediately
this.emit(ctx, false, false, failureSnapshot);
}
@@ -229,22 +191,27 @@ class ScenarioMonitor {
// Build snapshot if not provided
const finalSnapshot = snapshot || this.buildSnapshot(ctx, { final: false, timedOut });
traceMark(Action.MetricsScenario, {
event: "scenario_end",
scenario: ctx.scenario,
healthy,
timedOut,
platform,
api,
durationMs: finalSnapshot.durationMs,
completedPhases: finalSnapshot.completed.join(","),
failedPhases: finalSnapshot.failedPhases?.join(","),
lcp: finalSnapshot.vitals?.lcp,
inp: finalSnapshot.vitals?.inp,
cls: finalSnapshot.vitals?.cls,
fcp: finalSnapshot.vitals?.fcp,
ttfb: finalSnapshot.vitals?.ttfb,
});
// Emit enriched telemetry with performance data
// TODO: Call portal backend metrics endpoint
trackEvent(
{ name: "MetricScenarioComplete" },
{
scenario: ctx.scenario,
healthy: healthy.toString(),
timedOut: timedOut.toString(),
platform,
api,
durationMs: finalSnapshot.durationMs.toString(),
completedPhases: finalSnapshot.completed.join(","),
failedPhases: finalSnapshot.failedPhases?.join(","),
lcp: finalSnapshot.vitals?.lcp?.toString(),
inp: finalSnapshot.vitals?.inp?.toString(),
cls: finalSnapshot.vitals?.cls?.toString(),
fcp: finalSnapshot.vitals?.fcp?.toString(),
ttfb: finalSnapshot.vitals?.ttfb?.toString(),
phaseTimings: JSON.stringify(finalSnapshot.phaseTimings),
},
);
// Call portal backend health metrics endpoint
if (healthy && !timedOut) {
@@ -260,16 +227,9 @@ class ScenarioMonitor {
private cleanupPerformanceEntries(ctx: InternalScenarioContext) {
performance.clearMarks(ctx.startMarkName);
ctx.config.requiredPhases.forEach((phase) => {
const phaseCtx = ctx.phases.get(phase);
if (phaseCtx?.startMarkName) {
performance.clearMarks(phaseCtx.startMarkName);
}
if (phaseCtx?.endMarkName) {
performance.clearMarks(phaseCtx.endMarkName);
}
performance.clearMarks(`scenario_${ctx.scenario}_${phase}_failed`);
performance.clearMeasures(`scenario_${ctx.scenario}_${phase}_duration`);
performance.clearMarks(`scenario_${ctx.scenario}_${phase}`);
});
performance.clearMeasures(`scenario_${ctx.scenario}_total`);
}
private buildSnapshot(

View File

@@ -2,7 +2,6 @@
// Some of the enums names are used in Fabric. Please do not rename them.
export enum Action {
CollapseTreeNode,
MetricsScenario,
CreateCollection, // Used in Fabric. Please do not rename.
CreateGlobalSecondaryIndex,
CreateDocument, // Used in Fabric. Please do not rename.

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2025-05-01-preview/cosmos-db.json
*/
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
const apiVersion = "2025-05-01-preview";
/* Lists the Cassandra keyspaces under an existing Azure Cosmos DB database account. */
export async function listCassandraKeyspaces(

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2025-05-01-preview/cosmos-db.json
*/
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
const apiVersion = "2025-05-01-preview";
/* Retrieves the metrics determined by the given filter for the given database account and collection. */
export async function listMetrics(

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2025-05-01-preview/cosmos-db.json
*/
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
const apiVersion = "2025-05-01-preview";
/* Retrieves the metrics determined by the given filter for the given collection, split by partition. */
export async function listMetrics(

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2025-05-01-preview/cosmos-db.json
*/
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
const apiVersion = "2025-05-01-preview";
/* Retrieves the metrics determined by the given filter for the given collection and region, split by partition. */
export async function listMetrics(

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2025-05-01-preview/cosmos-db.json
*/
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
const apiVersion = "2025-05-01-preview";
/* Retrieves the metrics determined by the given filter for the given database account, collection and region. */
export async function listMetrics(

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2025-05-01-preview/cosmos-db.json
*/
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
const apiVersion = "2025-05-01-preview";
/* Retrieves the metrics determined by the given filter for the given database account and database. */
export async function listMetrics(

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2025-05-01-preview/cosmos-db.json
*/
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
const apiVersion = "2025-05-01-preview";
/* Retrieves the metrics determined by the given filter for the given database account and region. */
export async function listMetrics(

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2025-05-01-preview/cosmos-db.json
*/
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
const apiVersion = "2025-05-01-preview";
/* Retrieves the properties of an existing Azure Cosmos DB database account. */
export async function get(

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2025-05-01-preview/cosmos-db.json
*/
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
const apiVersion = "2025-05-01-preview";
/* Lists the graphs under an existing Azure Cosmos DB database account. */
export async function listGraphs(

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2025-05-01-preview/cosmos-db.json
*/
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
const apiVersion = "2025-05-01-preview";
/* Lists the Gremlin databases under an existing Azure Cosmos DB database account. */
export async function listGremlinDatabases(

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2025-05-01-preview/cosmos-db.json
*/
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
const apiVersion = "2025-05-01-preview";
/* List Cosmos DB locations and their properties */
export async function list(subscriptionId: string): Promise<Types.LocationListResult | Types.CloudError> {

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2025-05-01-preview/cosmos-db.json
*/
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
const apiVersion = "2025-05-01-preview";
/* Lists the MongoDB databases under an existing Azure Cosmos DB database account. */
export async function listMongoDBDatabases(

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2025-05-01-preview/cosmos-db.json
*/
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
const apiVersion = "2025-05-01-preview";
/* Lists all of the available Cosmos DB Resource Provider operations. */
export async function list(): Promise<Types.OperationListResult> {

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2025-05-01-preview/cosmos-db.json
*/
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
const apiVersion = "2025-05-01-preview";
/* Retrieves the metrics determined by the given filter for the given partition key range id. */
export async function listMetrics(

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2025-05-01-preview/cosmos-db.json
*/
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
const apiVersion = "2025-05-01-preview";
/* Retrieves the metrics determined by the given filter for the given partition key range id and region. */
export async function listMetrics(

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2025-05-01-preview/cosmos-db.json
*/
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
const apiVersion = "2025-05-01-preview";
/* Retrieves the metrics determined by the given filter for the given database account. This url is only for PBS and Replication Latency data */
export async function listMetrics(

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2025-05-01-preview/cosmos-db.json
*/
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
const apiVersion = "2025-05-01-preview";
/* Retrieves the metrics determined by the given filter for the given account, source and target region. This url is only for PBS and Replication Latency data */
export async function listMetrics(

View File

@@ -3,13 +3,13 @@
Run "npm run generateARMClients" to regenerate
Edting this file directly should be done with extreme caution as not to diverge from ARM REST specs
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/DocumentDB/preview/2025-11-01-preview/cosmos-db.json
Generated from: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/cosmos-db/resource-manager/Microsoft.DocumentDB/preview/2025-05-01-preview/cosmos-db.json
*/
import { armRequest } from "../../request";
import * as Types from "./types";
import { configContext } from "../../../../ConfigContext";
const apiVersion = "2025-11-01-preview";
const apiVersion = "2025-05-01-preview";
/* Retrieves the metrics determined by the given filter for the given account target region. This url is only for PBS and Replication Latency data */
export async function listMetrics(

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