Compare commits

..

6 Commits

Author SHA1 Message Date
dependabot[bot]
ed43314903 Bump jws
Bumps  and [jws](https://github.com/brianloveswords/node-jws). These dependencies needed to be updated together.

Updates `jws` from 3.2.2 to 3.2.3
- [Release notes](https://github.com/brianloveswords/node-jws/releases)
- [Changelog](https://github.com/auth0/node-jws/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianloveswords/node-jws/compare/v3.2.2...v3.2.3)

Updates `jws` from 4.0.0 to 4.0.1
- [Release notes](https://github.com/brianloveswords/node-jws/releases)
- [Changelog](https://github.com/auth0/node-jws/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianloveswords/node-jws/compare/v3.2.2...v3.2.3)

---
updated-dependencies:
- dependency-name: jws
  dependency-version: 3.2.3
  dependency-type: indirect
- dependency-name: jws
  dependency-version: 4.0.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-09 22:55:51 +00:00
asier-isayas
92c8afd166 More playwright tests (#2310)
* Add playwright tests for Autoscale/Manual Throughpout and TTL

* fix unit tests and lint

* fix unit tests

* fix tests

* fix autoscale selector

* changed throughput above limit

* Add more playwright tests

* fix tests

* nit

* cleanup

* format

* stored procedure playwright test

* add user defined function playwright test

* Add user defined functions and trigger test

* fix upload items

* fix tests

* fix lint errors

* fix lint

* run cleanup every 3 hours

* keep cleanup at 2 hours

---------

Co-authored-by: Asier Isayas <aisayas@microsoft.com>
2026-01-09 10:23:35 -06:00
Nishtha Ahuja
234e4181fc Index Advisor (#2270)
Index Advisor on query
2026-01-09 13:32:44 +05:30
BChoudhury-ms
38823ac86f Fix change partition key FTs (#2309) 2026-01-08 20:15:25 +05:30
BChoudhury-ms
b71ea50972 Add test infrastructure and data-test attributes for Container Copy e2e tests (#2280)
* Add test infrastructure and data-test attributes for Container Copy e2e testing

* fix container copy FTs
2026-01-08 17:19:16 +05:30
sakshigupta12feb
e27cff0553 Copy jobs dark theme (#2308)
* added a dark theme toggle button on Copyjobs next to refresh button and covered full feature

* Fix formatting in Utils.test.ts

* updated infor icon , error icon and text on jobs details page

* rebased from master

* updated the conflicts

* updated the conflicts

* fixed the test suit

* fixed review comments

* test fix

---------

Co-authored-by: Sakshi Gupta <sakshig@microsoft.com>
2026-01-08 13:27:57 +05:30
82 changed files with 3067 additions and 729 deletions

View File

@@ -192,6 +192,9 @@ jobs:
NOSQL_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-readonly.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$NOSQL_READONLY_TESTACCOUNT_TOKEN"
echo NOSQL_READONLY_TESTACCOUNT_TOKEN=$NOSQL_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-containercopyonly.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN"
echo NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=$NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
TABLE_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-tables.documents.azure.com/.default" -o tsv --query accessToken)
echo "::add-mask::$TABLE_TESTACCOUNT_TOKEN"
echo TABLE_TESTACCOUNT_TOKEN=$TABLE_TESTACCOUNT_TOKEN >> $GITHUB_ENV
@@ -210,6 +213,8 @@ jobs:
# MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken)
# echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN"
# echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
- name: List test files for shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --list
- name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --workers=3
- name: Upload blob report to GitHub Actions Artifacts

View File

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

34
package-lock.json generated
View File

@@ -15191,6 +15191,8 @@
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
@@ -17899,6 +17901,8 @@
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
@@ -27702,21 +27706,23 @@
}
},
"node_modules/jsonwebtoken/node_modules/jwa": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "1.0.1",
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jsonwebtoken/node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
"integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.1",
"jwa": "^1.4.2",
"safe-buffer": "^5.0.1"
}
},
@@ -27763,19 +27769,23 @@
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.0",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "1.0.1",
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.0",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.0",
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},

View File

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

View File

@@ -11,3 +11,14 @@
gap: 8px;
align-items: center;
}
/* Override dark mode inherit for pagination icons */
body.isDarkMode .pager-container .ms-Button .ms-Button-icon,
body.isDarkMode .pager-container .ms-Button i {
color: var(--colorBrandForeground1);
}
body.isDarkMode .pager-container .ms-Button:disabled .ms-Button-icon,
body.isDarkMode .pager-container .ms-Button:disabled i {
color: var(--colorNeutralForegroundDisabled);
}

View File

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

View File

@@ -1,194 +0,0 @@
import { Callout, DirectionalHint, ISearchBoxStyles, Label, SearchBox } from "@fluentui/react";
import * as React from "react";
import { useCallback, useRef, useState } from "react";
interface SearchableDropdownProps<T> {
label: string;
items: T[];
selectedItem: T | null;
onSelect: (item: T) => void;
getKey: (item: T) => string;
getDisplayText: (item: T) => string;
placeholder?: string;
filterPlaceholder?: string;
className?: string;
disabled?: boolean;
onDismiss?: () => void;
buttonStyles?: React.CSSProperties;
searchBoxStyles?: Partial<ISearchBoxStyles>;
listStyles?: React.CSSProperties;
itemStyles?: React.CSSProperties;
}
export const SearchableDropdown = <T,>({
label,
items,
selectedItem,
onSelect,
getKey,
getDisplayText,
placeholder = "Select an item",
filterPlaceholder = "Filter items",
className,
disabled = false,
onDismiss,
buttonStyles: customButtonStyles,
searchBoxStyles: customSearchBoxStyles,
listStyles: customListStyles,
itemStyles: customItemStyles,
}: SearchableDropdownProps<T>): React.ReactElement => {
const [isOpen, setIsOpen] = useState(false);
const [filterText, setFilterText] = useState("");
const buttonRef = useRef<HTMLButtonElement>(null);
const defaultButtonStyles: React.CSSProperties = {
width: "100%",
height: "32px",
padding: "0 28px 0 8px",
border: "1px solid #8a8886",
background: "#fff",
color: "#323130",
textAlign: "left",
cursor: disabled ? "not-allowed" : "pointer",
position: "relative",
fontFamily: "inherit",
fontSize: "14px",
};
const defaultListStyles: React.CSSProperties = {
maxHeight: "300px",
overflowY: "auto",
};
const defaultItemStyles: React.CSSProperties = {
padding: "8px 12px",
cursor: "pointer",
fontSize: "14px",
};
// Merge custom styles with defaults
const buttonStyles = { ...defaultButtonStyles, ...customButtonStyles };
const listStyles = { ...defaultListStyles, ...customListStyles };
const itemStyles = { ...defaultItemStyles, ...customItemStyles };
const closeDropdown = useCallback(() => {
setIsOpen(false);
setFilterText("");
}, []);
const filteredItems = items?.filter((item) => getDisplayText(item).toLowerCase().includes(filterText.toLowerCase()));
const handleDismiss = useCallback(() => {
closeDropdown();
onDismiss?.();
}, [closeDropdown, onDismiss]);
const handleButtonClick = useCallback(
(event: React.MouseEvent) => {
if (disabled) {
return;
}
event.preventDefault();
event.stopPropagation();
if (isOpen) {
closeDropdown();
return;
}
setIsOpen(true);
return;
},
[isOpen, closeDropdown, disabled],
);
const handleSelect = useCallback(
(item: T) => {
onSelect(item);
closeDropdown();
},
[onSelect, closeDropdown],
);
const buttonLabel = selectedItem
? getDisplayText(selectedItem)
: items?.length === 0
? `No ${label}s Found`
: placeholder;
const buttonId = `${className}-button`;
return (
<div>
<Label htmlFor={buttonId}>{label}</Label>
<button
id={buttonId}
ref={buttonRef}
className={className}
onClick={handleButtonClick}
style={buttonStyles}
disabled={disabled}
>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", display: "block" }}>
{buttonLabel}
</span>
<span
style={{
position: "absolute",
right: "8px",
top: "50%",
transform: "translateY(-50%)",
pointerEvents: "none",
}}
>
</span>
</button>
{isOpen && (
<Callout
target={buttonRef.current}
onDismiss={handleDismiss}
directionalHint={DirectionalHint.bottomLeftEdge}
isBeakVisible={false}
gapSpace={0}
setInitialFocus
>
<div style={{ width: buttonRef.current?.offsetWidth || 300, display: "flex", flexDirection: "column" }}>
<SearchBox
placeholder={filterPlaceholder}
value={filterText}
onChange={(_, newValue) => setFilterText(newValue || "")}
styles={customSearchBoxStyles}
/>
<div style={listStyles}>
{filteredItems && filteredItems.length > 0 ? (
filteredItems.map((item) => {
const key = getKey(item);
const isSelected = selectedItem ? getKey(selectedItem) === key : false;
return (
<div
key={key}
onClick={() => handleSelect(item)}
style={{
...itemStyles,
backgroundColor: isSelected ? "#e6e6e6" : "transparent",
}}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "#f3f2f1")}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = isSelected ? "#e6e6e6" : "transparent")
}
>
{getDisplayText(item)}
</div>
);
})
) : (
<div style={{ padding: "8px 12px", color: "#605e5c" }}>No items found</div>
)}
</div>
</div>
</Callout>
)}
</div>
);
};

View File

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

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 { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
@@ -14,6 +14,7 @@ export async function createTrigger(
): Promise<TriggerDefinition | SqlTriggerResource> {
const clearMessage = logConsoleProgress(`Creating trigger ${trigger.id}`);
try {
let resource: SqlTriggerResource | TriggerDefinition;
if (
userContext.authType === AuthType.AAD &&
!userContext.features.enableSDKoperations &&
@@ -52,14 +53,16 @@ export async function createTrigger(
trigger.id,
createTriggerParams,
);
return rpResponse && rpResponse.properties?.resource;
resource = rpResponse && rpResponse.properties?.resource;
} else {
const sdkResponse = await client()
.database(databaseId)
.container(collectionId)
.scripts.triggers.create(trigger as unknown as TriggerDefinition); // TODO: TypeScript does not like the SQL SDK trigger type
resource = sdkResponse.resource;
}
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.triggers.create(trigger as unknown as TriggerDefinition); // TODO: TypeScript does not like the SQL SDK trigger type
return response.resource;
logConsoleInfo(`Successfully created trigger ${trigger.id}`);
return resource;
} catch (error) {
handleError(error, "CreateTrigger", `Error while creating trigger ${trigger.id}`);
throw error;

View File

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

View File

@@ -28,6 +28,7 @@ export async function deleteStoredProcedure(
} else {
await client().database(databaseId).container(collectionId).scripts.storedProcedure(storedProcedureId).delete();
}
logConsoleProgress(`Successfully deleted stored procedure ${storedProcedureId}`);
} catch (error) {
handleError(error, "DeleteStoredProcedure", `Error while deleting stored procedure ${storedProcedureId}`);
throw error;

View File

@@ -24,6 +24,7 @@ export async function deleteTrigger(databaseId: string, collectionId: string, tr
} else {
await client().database(databaseId).container(collectionId).scripts.trigger(triggerId).delete();
}
logConsoleProgress(`Successfully deleted trigger ${triggerId}`);
} catch (error) {
handleError(error, "DeleteTrigger", `Error while deleting trigger ${triggerId}`);
throw error;

View File

@@ -24,6 +24,7 @@ export async function deleteUserDefinedFunction(databaseId: string, collectionId
} else {
await client().database(databaseId).container(collectionId).scripts.userDefinedFunction(id).delete();
}
logConsoleProgress(`Successfully deleted user defined function ${id}`);
} catch (error) {
handleError(error, "DeleteUserDefinedFunction", `Error while deleting user defined function ${id}`);
throw error;

View File

@@ -1,5 +1,6 @@
import "@testing-library/jest-dom";
import Explorer from "Explorer/Explorer";
import { getDataTransferJobs } from "../../../Common/dataAccess/dataTransfers";
import * as Logger from "../../../Common/Logger";
import { useSidePanel } from "../../../hooks/useSidePanel";
import * as dataTransferService from "../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs";
@@ -30,6 +31,7 @@ jest.mock("../../../Common/Logger");
jest.mock("../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs");
jest.mock("../MonitorCopyJobs/MonitorCopyJobRefState");
jest.mock("../CopyJobUtils");
jest.mock("../../../Common/dataAccess/dataTransfers");
describe("CopyJobActions", () => {
beforeEach(() => {
@@ -154,33 +156,31 @@ describe("CopyJobActions", () => {
});
it("should fetch and format copy jobs successfully", async () => {
const mockResponse = {
value: [
{
properties: {
jobName: "job-1",
status: "InProgress",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 50,
totalCount: 100,
mode: "online",
duration: "01:30:45",
source: {
component: "CosmosDBSql",
databaseName: "source-db",
containerName: "source-container",
},
destination: {
component: "CosmosDBSql",
databaseName: "target-db",
containerName: "target-container",
},
const mockResponse = [
{
properties: {
jobName: "job-1",
status: "InProgress",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 50,
totalCount: 100,
mode: "online",
duration: "01:30:45",
source: {
component: "CosmosDBSql",
databaseName: "source-db",
containerName: "source-container",
},
destination: {
component: "CosmosDBSql",
databaseName: "target-db",
containerName: "target-container",
},
},
],
};
},
];
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
@@ -201,38 +201,36 @@ describe("CopyJobActions", () => {
});
it("should filter jobs by CosmosDBSql component", async () => {
const mockResponse = {
value: [
{
properties: {
jobName: "sql-job",
status: "Completed",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 100,
totalCount: 100,
mode: "offline",
duration: "02:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
const mockResponse = [
{
properties: {
jobName: "sql-job",
status: "Completed",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 100,
totalCount: 100,
mode: "offline",
duration: "02:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
{
properties: {
jobName: "other-job",
status: "Completed",
lastUpdatedUtcTime: "2025-01-01T11:00:00Z",
processedCount: 100,
totalCount: 100,
mode: "offline",
duration: "01:00:00",
source: { component: "OtherComponent", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
},
{
properties: {
jobName: "other-job",
status: "Completed",
lastUpdatedUtcTime: "2025-01-01T11:00:00Z",
processedCount: 100,
totalCount: 100,
mode: "offline",
duration: "01:00:00",
source: { component: "OtherComponent", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
],
};
},
];
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
@@ -247,38 +245,36 @@ describe("CopyJobActions", () => {
});
it("should sort jobs by last updated time (newest first)", async () => {
const mockResponse = {
value: [
{
properties: {
jobName: "older-job",
status: "Completed",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 100,
totalCount: 100,
mode: "offline",
duration: "01:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
const mockResponse = [
{
properties: {
jobName: "older-job",
status: "Completed",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 100,
totalCount: 100,
mode: "offline",
duration: "01:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
{
properties: {
jobName: "newer-job",
status: "InProgress",
lastUpdatedUtcTime: "2025-01-02T10:00:00Z",
processedCount: 50,
totalCount: 100,
mode: "online",
duration: "00:30:00",
source: { component: "CosmosDBSql", databaseName: "db3", containerName: "c3" },
destination: { component: "CosmosDBSql", databaseName: "db4", containerName: "c4" },
},
},
{
properties: {
jobName: "newer-job",
status: "InProgress",
lastUpdatedUtcTime: "2025-01-02T10:00:00Z",
processedCount: 50,
totalCount: 100,
mode: "online",
duration: "00:30:00",
source: { component: "CosmosDBSql", databaseName: "db3", containerName: "c3" },
destination: { component: "CosmosDBSql", databaseName: "db4", containerName: "c4" },
},
],
};
},
];
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
@@ -293,25 +289,23 @@ describe("CopyJobActions", () => {
});
it("should calculate completion percentage correctly", async () => {
const mockResponse = {
value: [
{
properties: {
jobName: "job-1",
status: "InProgress",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 75,
totalCount: 100,
mode: "online",
duration: "01:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
const mockResponse = [
{
properties: {
jobName: "job-1",
status: "InProgress",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 75,
totalCount: 100,
mode: "online",
duration: "01:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
],
};
},
];
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
@@ -325,25 +319,23 @@ describe("CopyJobActions", () => {
});
it("should handle zero total count gracefully", async () => {
const mockResponse = {
value: [
{
properties: {
jobName: "job-1",
status: "Pending",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 0,
totalCount: 0,
mode: "online",
duration: "00:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
const mockResponse = [
{
properties: {
jobName: "job-1",
status: "Pending",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 0,
totalCount: 0,
mode: "online",
duration: "00:00:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
},
],
};
},
];
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
@@ -361,26 +353,24 @@ describe("CopyJobActions", () => {
message: "Error message line 1\r\n\r\nError message line 2",
code: "ErrorCode123",
};
const mockResponse = {
value: [
{
properties: {
jobName: "failed-job",
status: "Failed",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 50,
totalCount: 100,
mode: "offline",
duration: "00:30:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
error: mockError,
},
const mockResponse = [
{
properties: {
jobName: "failed-job",
status: "Failed",
lastUpdatedUtcTime: "2025-01-01T10:00:00Z",
processedCount: 50,
totalCount: 100,
mode: "offline",
duration: "00:30:00",
source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" },
destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" },
error: mockError,
},
],
};
},
];
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse);
(getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse);
(CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({
formattedDateTime: "1/1/2025, 10:00:00 AM",
timestamp: 1704106800000,
@@ -408,7 +398,7 @@ describe("CopyJobActions", () => {
};
(global as any).AbortController = jest.fn(() => mockAbortController);
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue({ value: [] });
(getDataTransferJobs as jest.Mock).mockResolvedValue([]);
getCopyJobs();
expect(mockAbortController.abort).not.toHaveBeenCalled();
@@ -418,9 +408,7 @@ describe("CopyJobActions", () => {
});
it("should throw error for invalid response format", async () => {
(dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue({
value: "not-an-array",
});
(getDataTransferJobs as jest.Mock).mockResolvedValue("not-an-array");
await expect(getCopyJobs()).rejects.toThrow("Invalid migration job status response: Expected an array of jobs.");
});
@@ -430,7 +418,7 @@ describe("CopyJobActions", () => {
message: "Aborted",
content: JSON.stringify({ message: "signal is aborted without reason" }),
};
(dataTransferService.listByDatabaseAccount as jest.Mock).mockRejectedValue(abortError);
(getDataTransferJobs as jest.Mock).mockRejectedValue(abortError);
await expect(getCopyJobs()).rejects.toMatchObject({
message: expect.stringContaining("Previous copy job request was cancelled."),
@@ -439,7 +427,7 @@ describe("CopyJobActions", () => {
it("should handle generic errors", async () => {
const genericError = new Error("Network error");
(dataTransferService.listByDatabaseAccount as jest.Mock).mockRejectedValue(genericError);
(getDataTransferJobs as jest.Mock).mockRejectedValue(genericError);
await expect(getCopyJobs()).rejects.toThrow("Network error");
});

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";
@@ -63,14 +63,8 @@ export const getCopyJobs = async (): Promise<CopyJobType[]> => {
const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId(
userContext.databaseAccount?.id || "",
);
const response = await listByDatabaseAccount(
subscriptionId,
resourceGroup,
accountName,
copyJobsAbortController.signal,
);
const jobs = await getDataTransferJobs(subscriptionId, resourceGroup, accountName, copyJobsAbortController.signal);
const jobs = response.value || [];
if (!Array.isArray(jobs)) {
throw new Error("Invalid migration job status response: Expected an array of jobs.");
}

View File

@@ -39,7 +39,7 @@ describe("CopyJobCommandBar", () => {
render(<CopyJobCommandBar explorer={mockExplorer} />);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer, false);
expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(1);
});
@@ -163,7 +163,7 @@ describe("CopyJobCommandBar", () => {
render(<CopyJobCommandBar explorer={mockExplorer} />);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer, false);
expect(mockConvertButton.mock.calls[0][0]).toEqual(mockCommandButtonProps);
});
@@ -175,11 +175,11 @@ describe("CopyJobCommandBar", () => {
mockConvertButton.mockReturnValue([]);
const { rerender } = render(<CopyJobCommandBar explorer={mockExplorer1} />);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer1);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer1, false);
rerender(<CopyJobCommandBar explorer={mockExplorer2} />);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer2);
expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer2, false);
expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,24 +1,28 @@
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
import React from "react";
import { StyleConstants } from "../../../Common/StyleConstants";
import { useThemeStore } from "../../../hooks/useTheme";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import * as CommandBarUtil from "../../Menus/CommandBar/CommandBarUtil";
import { getThemeTokens } from "../../Theme/ThemeUtil";
import { ContainerCopyProps } from "../Types/CopyJobTypes";
import { getCommandBarButtons } from "./Utils";
const backgroundColor = StyleConstants.BaseLight;
const rootStyle = {
root: {
backgroundColor: backgroundColor,
},
};
const CopyJobCommandBar: React.FC<ContainerCopyProps> = ({ explorer }) => {
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer);
const isDarkMode = useThemeStore((state) => state.isDarkMode);
const themeTokens = getThemeTokens(isDarkMode);
const backgroundColor = themeTokens.colorNeutralBackground1;
const rootStyle = {
root: {
backgroundColor: backgroundColor,
},
};
const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer, isDarkMode);
const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor);
return (
<div className="commandBarContainer">
<div className="commandBarContainer" style={{ backgroundColor }}>
<FluentCommandBar
ariaLabel="Use left and right arrow keys to navigate between commands"
styles={rootStyle}

View File

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

View File

@@ -1,7 +1,10 @@
import AddIcon from "../../../../images/Add.svg";
import FeedbackIcon from "../../../../images/Feedback-Command.svg";
import MoonIcon from "../../../../images/MoonIcon.svg";
import RefreshIcon from "../../../../images/refresh-cosmos.svg";
import SunIcon from "../../../../images/SunIcon.svg";
import { configContext, Platform } from "../../../ConfigContext";
import { useThemeStore } from "../../../hooks/useTheme";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../../Explorer";
import * as Actions from "../Actions/CopyJobActions";
@@ -9,7 +12,7 @@ import ContainerCopyMessages from "../ContainerCopyMessages";
import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState";
import { CopyJobCommandBarBtnType } from "../Types/CopyJobTypes";
function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] {
function getCopyJobBtns(explorer: Explorer, isDarkMode: boolean): CopyJobCommandBarBtnType[] {
const monitorCopyJobsRef = MonitorCopyJobsRefState((state) => state.ref);
const buttons: CopyJobCommandBarBtnType[] = [
{
@@ -26,7 +29,15 @@ function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] {
ariaLabel: ContainerCopyMessages.refreshButtonAriaLabel,
onClick: () => monitorCopyJobsRef?.refreshJobList(),
},
{
key: "themeToggle",
iconSrc: isDarkMode ? SunIcon : MoonIcon,
label: isDarkMode ? "Light Theme" : "Dark Theme",
ariaLabel: isDarkMode ? "Switch to Light Theme" : "Switch to Dark Theme",
onClick: () => useThemeStore.getState().toggleTheme(),
},
];
if (configContext.platform === Platform.Portal) {
buttons.push({
key: "feedback",
@@ -54,6 +65,6 @@ function btnMapper(config: CopyJobCommandBarBtnType): CommandButtonComponentProp
};
}
export function getCommandBarButtons(explorer: Explorer): CommandButtonComponentProps[] {
return getCopyJobBtns(explorer).map(btnMapper);
export function getCommandBarButtons(explorer: Explorer, isDarkMode: boolean): CommandButtonComponentProps[] {
return getCopyJobBtns(explorer, isDarkMode).map(btnMapper);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ exports[`AddManagedIdentity Snapshot Tests renders initial state correctly 1`] =
class="ms-Stack addManagedIdentityContainer css-109"
>
<span
class="css-110"
class="themeText css-110"
>
A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you 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="css-110"
class="themeText css-110"
>
A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you dont have to store any credentials in code.
@@ -196,13 +196,13 @@ exports[`AddManagedIdentity Snapshot Tests renders loading state 1`] = `
</div>
</div>
<span
class="css-124"
class="themeText css-124"
style="font-weight: 600;"
>
Enable system assigned managed identity
</span>
<span
class="css-110"
class="themeText css-110"
>
Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button.
</span>
@@ -265,7 +265,7 @@ exports[`AddManagedIdentity Snapshot Tests renders with toggle on and popover vi
class="ms-Stack addManagedIdentityContainer css-109"
>
<span
class="css-110"
class="themeText css-110"
>
A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you 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="css-124"
class="themeText css-124"
style="font-weight: 600;"
>
Enable system assigned managed identity
</span>
<span
class="css-110"
class="themeText css-110"
>
Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button.
</span>

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" style={{ fontWeight: 600 }}>
<Text variant="mediumPlus" className="themeText" style={{ fontWeight: 600 }}>
{title}
</Text>
<Text>{children}</Text>
<Text className="themeText">{children}</Text>
<Stack horizontal tokens={{ childrenGap: 20 }}>
<PrimaryButton text={"Yes"} onClick={onPrimary} disabled={isLoading} />
<DefaultButton text="No" onClick={onCancel} disabled={isLoading} />

View File

@@ -8,11 +8,11 @@ exports[`PopoverMessage Component Edge Cases should handle empty string title 1`
style="max-width: 450px;"
>
<span
class="css-110"
class="themeText css-110"
style="font-weight: 600;"
/>
<span
class="css-111"
class="themeText css-111"
>
<div>
Test content
@@ -76,7 +76,7 @@ exports[`PopoverMessage Component Edge Cases should handle null children 1`] = `
style="max-width: 450px;"
>
<span
class="css-110"
class="themeText css-110"
style="font-weight: 600;"
>
Test Title
@@ -139,7 +139,7 @@ exports[`PopoverMessage Component Edge Cases should handle undefined children 1`
style="max-width: 450px;"
>
<span
class="css-110"
class="themeText css-110"
style="font-weight: 600;"
>
Test Title
@@ -202,13 +202,13 @@ exports[`PopoverMessage Component Edge Cases should handle very long title 1`] =
style="max-width: 450px;"
>
<span
class="css-110"
class="themeText css-110"
style="font-weight: 600;"
>
This is a very long title that might cause layout issues or text wrapping in the popover component
</span>
<span
class="css-111"
class="themeText css-111"
>
<div>
Test content
@@ -274,13 +274,13 @@ exports[`PopoverMessage Component Rendering should render correctly when visible
style="max-width: 450px;"
>
<span
class="css-110"
class="themeText css-110"
style="font-weight: 600;"
>
Test Title
</span>
<span
class="css-111"
class="themeText css-111"
>
<div>
Test content
@@ -344,13 +344,13 @@ exports[`PopoverMessage Component Rendering should render correctly with differe
style="max-width: 450px;"
>
<span
class="css-110"
class="themeText css-110"
style="font-weight: 600;"
>
Test Title
</span>
<span
class="css-111"
class="themeText css-111"
>
<div>
<p>
@@ -419,13 +419,13 @@ exports[`PopoverMessage Component Rendering should render correctly with differe
style="max-width: 450px;"
>
<span
class="css-110"
class="themeText css-110"
style="font-weight: 600;"
>
Custom Title
</span>
<span
class="css-111"
class="themeText css-111"
>
<div>
Test content
@@ -493,13 +493,13 @@ exports[`PopoverMessage Component Rendering should render correctly with loading
data-testid="loading-overlay"
/>
<span
class="css-110"
class="themeText css-110"
style="font-weight: 600;"
>
Test Title
</span>
<span
class="css-111"
class="themeText css-111"
>
<div>
Test content

View File

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

View File

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

View File

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

View File

@@ -47,12 +47,12 @@ exports[`PreviewCopyJob should handle special characters in database and contain
class="ms-Stack css-124"
>
<span
class="bold css-125"
class="bold themeText css-125"
>
Source subscription
</span>
<span
class="css-125"
class="themeText css-125"
data-test="source-subscription-name"
>
Test Subscription
@@ -62,12 +62,12 @@ exports[`PreviewCopyJob should handle special characters in database and contain
class="ms-Stack css-124"
>
<span
class="bold css-125"
class="bold themeText css-125"
>
Source account
</span>
<span
class="css-125"
class="themeText css-125"
data-test="source-account-name"
>
test-account
@@ -369,12 +369,12 @@ exports[`PreviewCopyJob should render component with cross-subscription setup 1`
class="ms-Stack css-124"
>
<span
class="bold css-125"
class="bold themeText css-125"
>
Source subscription
</span>
<span
class="css-125"
class="themeText css-125"
data-test="source-subscription-name"
>
Test Subscription
@@ -384,12 +384,12 @@ exports[`PreviewCopyJob should render component with cross-subscription setup 1`
class="ms-Stack css-124"
>
<span
class="bold css-125"
class="bold themeText css-125"
>
Source account
</span>
<span
class="css-125"
class="themeText css-125"
data-test="source-account-name"
>
test-account
@@ -691,12 +691,12 @@ exports[`PreviewCopyJob should render with default state and empty job name 1`]
class="ms-Stack css-124"
>
<span
class="bold css-125"
class="bold themeText css-125"
>
Source subscription
</span>
<span
class="css-125"
class="themeText css-125"
data-test="source-subscription-name"
>
Test Subscription
@@ -706,12 +706,12 @@ exports[`PreviewCopyJob should render with default state and empty job name 1`]
class="ms-Stack css-124"
>
<span
class="bold css-125"
class="bold themeText css-125"
>
Source account
</span>
<span
class="css-125"
class="themeText css-125"
data-test="source-account-name"
>
test-account
@@ -1013,12 +1013,12 @@ exports[`PreviewCopyJob should render with long subscription and account names 1
class="ms-Stack css-124"
>
<span
class="bold css-125"
class="bold themeText css-125"
>
Source subscription
</span>
<span
class="css-125"
class="themeText css-125"
data-test="source-subscription-name"
>
This is a very long subscription name that might cause display issues if not handled properly
@@ -1028,12 +1028,12 @@ exports[`PreviewCopyJob should render with long subscription and account names 1
class="ms-Stack css-124"
>
<span
class="bold css-125"
class="bold themeText css-125"
>
Source account
</span>
<span
class="css-125"
class="themeText css-125"
data-test="source-account-name"
>
this-is-a-very-long-database-account-name-that-might-cause-display-issues
@@ -1335,12 +1335,12 @@ exports[`PreviewCopyJob should render with missing source account information 1`
class="ms-Stack css-124"
>
<span
class="bold css-125"
class="bold themeText css-125"
>
Source subscription
</span>
<span
class="css-125"
class="themeText css-125"
data-test="source-subscription-name"
>
Test Subscription
@@ -1350,7 +1350,7 @@ exports[`PreviewCopyJob should render with missing source account information 1`
class="ms-Stack css-124"
>
<span
class="bold css-125"
class="bold themeText css-125"
>
Source account
</span>
@@ -1651,7 +1651,7 @@ exports[`PreviewCopyJob should render with missing source subscription informati
class="ms-Stack css-124"
>
<span
class="bold css-125"
class="bold themeText css-125"
>
Source subscription
</span>
@@ -1660,12 +1660,12 @@ exports[`PreviewCopyJob should render with missing source subscription informati
class="ms-Stack css-124"
>
<span
class="bold css-125"
class="bold themeText css-125"
>
Source account
</span>
<span
class="css-125"
class="themeText css-125"
data-test="source-account-name"
>
test-account
@@ -1967,12 +1967,12 @@ exports[`PreviewCopyJob should render with online migration type 1`] = `
class="ms-Stack css-124"
>
<span
class="bold css-125"
class="bold themeText css-125"
>
Source subscription
</span>
<span
class="css-125"
class="themeText css-125"
data-test="source-subscription-name"
>
Test Subscription
@@ -1982,12 +1982,12 @@ exports[`PreviewCopyJob should render with online migration type 1`] = `
class="ms-Stack css-124"
>
<span
class="bold css-125"
class="bold themeText css-125"
>
Source account
</span>
<span
class="css-125"
class="themeText css-125"
data-test="source-account-name"
>
test-account
@@ -2289,12 +2289,12 @@ exports[`PreviewCopyJob should render with pre-filled job name 1`] = `
class="ms-Stack css-124"
>
<span
class="bold css-125"
class="bold themeText css-125"
>
Source subscription
</span>
<span
class="css-125"
class="themeText css-125"
data-test="source-subscription-name"
>
Test Subscription
@@ -2304,12 +2304,12 @@ exports[`PreviewCopyJob should render with pre-filled job name 1`] = `
class="ms-Stack css-124"
>
<span
class="bold css-125"
class="bold themeText css-125"
>
Source account
</span>
<span
class="css-125"
class="themeText css-125"
data-test="source-account-name"
>
test-account
@@ -2611,12 +2611,12 @@ exports[`PreviewCopyJob should render with undefined database and container name
class="ms-Stack css-124"
>
<span
class="bold css-125"
class="bold themeText css-125"
>
Source subscription
</span>
<span
class="css-125"
class="themeText css-125"
data-test="source-subscription-name"
>
Test Subscription
@@ -2626,12 +2626,12 @@ exports[`PreviewCopyJob should render with undefined database and container name
class="ms-Stack css-124"
>
<span
class="bold css-125"
class="bold themeText css-125"
>
Source account
</span>
<span
class="css-125"
class="themeText css-125"
data-test="source-account-name"
>
test-account

View File

@@ -1,6 +1,6 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/display-name */
import { Checkbox, Stack } from "@fluentui/react";
import { Checkbox, ICheckboxStyles, Stack } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
@@ -9,8 +9,25 @@ interface MigrationTypeCheckboxProps {
onChange: (_ev?: React.FormEvent, checked?: boolean) => void;
}
export const MigrationTypeCheckbox: React.FC<MigrationTypeCheckboxProps> = React.memo(({ checked, onChange }) => (
<Stack horizontal horizontalAlign="space-between" className="migrationTypeRow" data-test="migration-type-checkbox">
<Checkbox label={ContainerCopyMessages.migrationTypeCheckboxLabel} checked={checked} onChange={onChange} />
</Stack>
));
const checkboxStyles: ICheckboxStyles = {
text: { color: "var(--colorNeutralForeground1)" },
checkbox: { borderColor: "var(--colorNeutralStroke1)" },
root: {
selectors: {
":hover .ms-Checkbox-text": { color: "var(--colorNeutralForeground1)" },
},
},
};
export const MigrationTypeCheckbox: React.FC<MigrationTypeCheckboxProps> = React.memo(({ checked, onChange }) => {
return (
<Stack horizontal horizontalAlign="space-between" className="migrationTypeRow" data-test="migration-type-checkbox">
<Checkbox
label={ContainerCopyMessages.migrationTypeCheckboxLabel}
checked={checked}
onChange={onChange}
styles={checkboxStyles}
/>
</Stack>
);
});

View File

@@ -21,7 +21,7 @@ const SelectAccount = React.memo(() => {
return (
<Stack data-test="Panel:SelectAccountContainer" className="selectAccountContainer" tokens={{ childrenGap: 15 }}>
<Text>{ContainerCopyMessages.selectAccountDescription}</Text>
<Text className="themeText">{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="css-110"
class="themeText 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>{ContainerCopyMessages.selectSourceAndTargetContainersDescription}</span>
<span className="themeText">{ContainerCopyMessages.selectSourceAndTargetContainersDescription}</span>
<DatabaseContainerSection
heading={ContainerCopyMessages.sourceContainerSubHeading}
databaseOptions={sourceDatabaseOptions}

View File

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

View File

@@ -1,5 +1,6 @@
import { DetailsList, DetailsListLayoutMode, IColumn, Stack, Text } from "@fluentui/react";
import React, { memo } from "react";
import { useThemeStore } from "../../../../hooks/useTheme";
import ContainerCopyMessages from "../../ContainerCopyMessages";
import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
import { CopyJobType } from "../../Types/CopyJobTypes";
@@ -63,6 +64,19 @@ const getCopyJobDetailsListColumns = (): IColumn[] => {
};
const CopyJobDetails: React.FC<CopyJobDetailsProps> = ({ job }) => {
const isDarkMode = useThemeStore((state) => state.isDarkMode);
const errorMessageStyle: React.CSSProperties = {
whiteSpace: "pre-wrap",
...(isDarkMode && {
whiteSpace: "pre-wrap",
backgroundColor: "var(--colorNeutralBackground2)",
color: "var(--colorNeutralForeground1)",
padding: "10px",
borderRadius: "4px",
}),
};
const selectedContainers = [
{
sourceContainerName: job?.Source?.containerName || "N/A",
@@ -77,10 +91,10 @@ const CopyJobDetails: React.FC<CopyJobDetailsProps> = ({ job }) => {
<Stack className="copyJobDetailsContainer" tokens={{ childrenGap: 15 }} data-testid="copy-job-details">
{job.Error ? (
<Stack.Item data-testid="error-stack" style={sectionCss.verticalAlign}>
<Text className="bold" style={sectionCss.headingText}>
<Text className="bold themeText" style={sectionCss.headingText}>
{ContainerCopyMessages.errorTitle}
</Text>
<Text as="pre" style={{ whiteSpace: "pre-wrap" }}>
<Text as="pre" style={errorMessageStyle}>
{job.Error.message}
</Text>
</Stack.Item>
@@ -88,16 +102,16 @@ const CopyJobDetails: React.FC<CopyJobDetailsProps> = ({ job }) => {
<Stack.Item data-testid="selectedcollection-stack">
<Stack tokens={{ childrenGap: 15 }}>
<Stack.Item style={sectionCss.verticalAlign}>
<Text className="bold">{ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime}</Text>
<Text>{job.LastUpdatedTime}</Text>
<Text className="bold themeText">{ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime}</Text>
<Text className="themeText">{job.LastUpdatedTime}</Text>
</Stack.Item>
<Stack.Item style={sectionCss.verticalAlign}>
<Text className="bold">{ContainerCopyMessages.sourceAccountLabel}</Text>
<Text>{job.Source?.remoteAccountName}</Text>
<Text className="bold themeText">{ContainerCopyMessages.sourceAccountLabel}</Text>
<Text className="themeText">{job.Source?.remoteAccountName}</Text>
</Stack.Item>
<Stack.Item style={sectionCss.verticalAlign}>
<Text className="bold">{ContainerCopyMessages.MonitorJobs.Columns.mode}</Text>
<Text>{job.Mode}</Text>
<Text className="bold themeText">{ContainerCopyMessages.MonitorJobs.Columns.mode}</Text>
<Text className="themeText">{job.Mode}</Text>
</Stack.Item>
</Stack>
</Stack.Item>

View File

@@ -1,30 +1,14 @@
import { FontIcon, getTheme, mergeStyles, mergeStyleSets, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
import { FontIcon, mergeStyles, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
import PropTypes from "prop-types";
import React from "react";
import ContainerCopyMessages from "../../ContainerCopyMessages";
import { CopyJobStatusType } from "../../Enums/CopyJobEnums";
const theme = getTheme();
const iconClass = mergeStyles({
fontSize: "16px",
marginRight: "8px",
});
const classNames = mergeStyleSets({
[CopyJobStatusType.Pending]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
[CopyJobStatusType.InProgress]: [{ color: theme.palette.themePrimary }, iconClass],
[CopyJobStatusType.Running]: [{ color: theme.palette.themePrimary }, iconClass],
[CopyJobStatusType.Partitioning]: [{ color: theme.palette.themePrimary }, iconClass],
[CopyJobStatusType.Paused]: [{ color: theme.palette.themePrimary }, iconClass],
[CopyJobStatusType.Skipped]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
[CopyJobStatusType.Cancelled]: [{ color: theme.semanticColors.bodySubtext }, iconClass],
[CopyJobStatusType.Failed]: [{ color: theme.semanticColors.errorIcon }, iconClass],
[CopyJobStatusType.Faulted]: [{ color: theme.semanticColors.errorIcon }, iconClass],
[CopyJobStatusType.Completed]: [{ color: theme.semanticColors.successIcon }, iconClass],
unknown: [{ color: theme.semanticColors.bodySubtext }, iconClass],
});
const iconMap: Partial<Record<CopyJobStatusType, string>> = {
[CopyJobStatusType.Pending]: "Clock",
[CopyJobStatusType.Paused]: "CirclePause",
@@ -35,6 +19,17 @@ const iconMap: Partial<Record<CopyJobStatusType, string>> = {
[CopyJobStatusType.Completed]: "CompletedSolid",
};
// Icon colors for different statuses
const statusIconColors: Partial<Record<CopyJobStatusType, string>> = {
[CopyJobStatusType.Failed]: "var(--colorPaletteRedForeground1)",
[CopyJobStatusType.Faulted]: "var(--colorPaletteRedForeground1)",
[CopyJobStatusType.Completed]: "var(--colorSuccessGreen)",
[CopyJobStatusType.InProgress]: "var(--colorBrandForeground1)",
[CopyJobStatusType.Running]: "var(--colorBrandForeground1)",
[CopyJobStatusType.Partitioning]: "var(--colorBrandForeground1)",
[CopyJobStatusType.Paused]: "var(--colorBrandForeground1)",
};
export interface CopyJobStatusWithIconProps {
status: CopyJobStatusType;
}
@@ -47,19 +42,17 @@ const CopyJobStatusWithIcon: React.FC<CopyJobStatusWithIconProps> = React.memo((
CopyJobStatusType.InProgress,
CopyJobStatusType.Partitioning,
].includes(status);
const iconColor = statusIconColors[status] || "var(--colorNeutralForeground2)";
const iconStyle = mergeStyles(iconClass, { color: iconColor });
return (
<Stack horizontal verticalAlign="center">
{isSpinnerStatus ? (
<Spinner size={SpinnerSize.small} style={{ marginRight: "8px" }} />
) : (
<FontIcon
aria-label={status}
iconName={iconMap[status] || "UnknownSolid"}
className={classNames[status] || classNames.unknown}
/>
<FontIcon aria-label={status} iconName={iconMap[status] || "UnknownSolid"} className={iconStyle} />
)}
<Text>{statusText}</Text>
<Text className="themeText">{statusText}</Text>
</Stack>
);
});

View File

@@ -15,6 +15,8 @@ import {
} from "@fluentui/react";
import React, { useEffect } from "react";
import Pager from "../../../../Common/Pager";
import { useThemeStore } from "../../../../hooks/useTheme";
import { getThemeTokens } from "../../../Theme/ThemeUtil";
import { openCopyJobDetailsPanel } from "../../Actions/CopyJobActions";
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
import { getColumns } from "./CopyJobColumns";
@@ -26,13 +28,15 @@ interface CopyJobsListProps {
}
const styles = {
container: { height: "calc(100vh - 25em)" } as React.CSSProperties,
container: { height: "100%" } as React.CSSProperties,
stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties,
};
const PAGE_SIZE = 10;
const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => {
const isDarkMode = useThemeStore((state) => state.isDarkMode);
const themeTokens = getThemeTokens(isDarkMode);
const [startIndex, setStartIndex] = React.useState(0);
const [sortedJobs, setSortedJobs] = React.useState<CopyJobType[]>(jobs);
const [sortedColumnKey, setSortedColumnKey] = React.useState<string | undefined>(undefined);
@@ -88,11 +92,28 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
enableShimmer={false}
constrainMode={ConstrainMode.unconstrained}
layoutMode={DetailsListLayoutMode.justified}
onRenderDetailsHeader={(props, defaultRender) => (
<Sticky stickyPosition={StickyPositionType.Header} isScrollSynced>
{defaultRender({ ...props })}
</Sticky>
)}
onRenderDetailsHeader={(props, defaultRender) => {
const bgColor = themeTokens.colorNeutralBackground3;
const textColor = themeTokens.colorNeutralForeground1;
return (
<Sticky stickyPosition={StickyPositionType.Header} isScrollSynced stickyBackgroundColor={bgColor}>
<div style={{ backgroundColor: bgColor }}>
{defaultRender({
...props,
styles: {
root: {
backgroundColor: bgColor,
selectors: {
".ms-DetailsHeader-cellTitle": { color: textColor },
".ms-DetailsHeader-cellName": { color: textColor },
},
},
},
})}
</div>
</Sticky>
);
}}
/>
</ScrollablePane>
</Stack.Item>

View File

@@ -13,7 +13,7 @@ exports[`CopyJobStatusWithIcon Spinner Status Types renders InProgress with spin
/>
</div>
<span
class="css-112"
class="themeText css-112"
>
Running
</span>
@@ -33,7 +33,7 @@ exports[`CopyJobStatusWithIcon Spinner Status Types renders Partitioning with sp
/>
</div>
<span
class="css-112"
class="themeText css-112"
>
Running
</span>
@@ -53,7 +53,7 @@ exports[`CopyJobStatusWithIcon Spinner Status Types renders Running with spinner
/>
</div>
<span
class="css-112"
class="themeText css-112"
>
Running
</span>
@@ -66,7 +66,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
>
<i
aria-label="Cancelled"
class="ms-Icon root-105 css-118 mocked-style-Cancelled"
class="ms-Icon root-105 css-118 mocked-styles"
data-icon-name="StatusErrorFull"
role="img"
style="font-family: "FabricMDL2Icons-4";"
@@ -74,7 +74,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
</i>
<span
class="css-112"
class="themeText css-112"
>
Cancelled
</span>
@@ -87,7 +87,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
>
<i
aria-label="Completed"
class="ms-Icon root-105 css-120 mocked-style-Completed"
class="ms-Icon root-105 css-120 mocked-styles"
data-icon-name="CompletedSolid"
role="img"
style="font-family: "FabricMDL2Icons-5";"
@@ -95,7 +95,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
</i>
<span
class="css-112"
class="themeText css-112"
>
Completed
</span>
@@ -108,7 +108,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
>
<i
aria-label="Failed"
class="ms-Icon root-105 css-118 mocked-style-Failed"
class="ms-Icon root-105 css-118 mocked-styles"
data-icon-name="StatusErrorFull"
role="img"
style="font-family: "FabricMDL2Icons-4";"
@@ -116,7 +116,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
</i>
<span
class="css-112"
class="themeText css-112"
>
Failed
</span>
@@ -129,7 +129,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
>
<i
aria-label="Faulted"
class="ms-Icon root-105 css-118 mocked-style-Faulted"
class="ms-Icon root-105 css-118 mocked-styles"
data-icon-name="StatusErrorFull"
role="img"
style="font-family: "FabricMDL2Icons-4";"
@@ -137,7 +137,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
</i>
<span
class="css-112"
class="themeText css-112"
>
Failed
</span>
@@ -150,7 +150,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
>
<i
aria-label="Paused"
class="ms-Icon root-105 css-114 mocked-style-Paused"
class="ms-Icon root-105 css-114 mocked-styles"
data-icon-name="CirclePause"
role="img"
style="font-family: "FabricMDL2Icons-11";"
@@ -158,7 +158,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
</i>
<span
class="css-112"
class="themeText css-112"
>
Paused
</span>
@@ -171,7 +171,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
>
<i
aria-label="Pending"
class="ms-Icon root-105 css-111 mocked-style-Pending"
class="ms-Icon root-105 css-111 mocked-styles"
data-icon-name="Clock"
role="img"
style="font-family: "FabricMDL2Icons-2";"
@@ -179,7 +179,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
</i>
<span
class="css-112"
class="themeText css-112"
>
Queued
</span>
@@ -192,7 +192,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
>
<i
aria-label="Skipped"
class="ms-Icon root-105 css-116 mocked-style-Skipped"
class="ms-Icon root-105 css-116 mocked-styles"
data-icon-name="StatusCircleBlock2"
role="img"
style="font-family: "FabricMDL2Icons-9";"
@@ -200,7 +200,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders
</i>
<span
class="css-112"
class="themeText css-112"
>
Cancelled
</span>

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_MS = 30 * 1000;
const FETCH_INTERVAL = 2 * 60 * 1000;
const SHIMMER_INDENT_LEVELS: IndentLevel[] = Array(7).fill({ level: 0, width: "100%" });
interface MonitorCopyJobsProps {
@@ -57,7 +57,7 @@ const MonitorCopyJobs = forwardRef<MonitorCopyJobsRef, MonitorCopyJobsProps>(({
useEffect(() => {
fetchJobs();
const intervalId = setInterval(fetchJobs, FETCH_INTERVAL_MS);
const intervalId = setInterval(fetchJobs, FETCH_INTERVAL);
return () => clearInterval(intervalId);
}, [fetchJobs]);

View File

@@ -1,6 +1,30 @@
@import "../../../less/Common/Constants.less";
// Common theme-aware classes
.themeText {
color: var(--colorNeutralForeground1);
}
.themeTextSecondary {
color: var(--colorNeutralForeground2);
}
.themeLinkText {
color: var(--colorBrandForeground1);
}
.themeBackground {
background-color: var(--colorNeutralBackground1);
}
.themeBackgroundSecondary {
background-color: var(--colorNeutralBackground2);
}
#containerCopyWrapper {
background-color: var(--colorNeutralBackground1);
color: var(--colorNeutralForeground1);
.centerContent {
justify-content: center;
align-items: center;
@@ -9,20 +33,30 @@
.noCopyJobsMessage {
font-weight: 600;
margin: 0 auto;
color: @FocusColor;
color: var(--colorNeutralForeground2);
}
button.createCopyJobButton {
color: @LinkColor;
color: var(--colorBrandForeground1);
}
}
}
.createCopyJobScreensContainer {
height: 100%;
padding: 1em 1.5em;
background-color: var(--colorNeutralBackground1);
color: var(--colorNeutralForeground1);
.pointInTimeRestoreContainer, .onlineCopyContainer {
position: relative;
}
.toggle-label {
color: var(--colorNeutralForeground1);
}
.accordionHeaderText {
color: var(--colorNeutralForeground1);
}
label {
padding: 0;
@@ -71,7 +105,7 @@
}
.foreground {
z-index: 10;
background-color: #f9f9f9;
background-color: var(--colorNeutralBackground2);
padding: 20px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transform: translate(0%, -9%);
@@ -80,14 +114,40 @@
.createCopyJobErrorMessageBar {
margin-bottom: 2em;
}
body.isDarkMode & {
.ms-TooltipHost .ms-Image {
filter: invert(1);
}
.ms-TextField {
.ms-TextField-fieldGroup {
background-color: var(--colorNeutralBackground1);
border-color: var(--colorNeutralStroke1);
}
.ms-TextField-field {
color: var(--colorNeutralForeground1);
background-color: var(--colorNeutralBackground1);
&::placeholder {
color: var(--colorNeutralForeground4);
}
}
.ms-Label {
color: var(--colorNeutralForeground1);
}
}
}
.create-container-link-btn {
padding: 0;
height: 25px;
color: @LinkColor;
color: var(--colorBrandForeground1);
&:focus {
outline: none;
}
}
/* Create collection panel */
@@ -105,7 +165,6 @@
width: 100%;
max-width: 100%;
margin: 0 auto;
.ms-DetailsList {
width: 100%;
@@ -114,33 +173,33 @@
padding: @DefaultSpace 20px;
font-weight: 600;
font-size: @DefaultFontSize;
color: @BaseHigh;
background-color: @BaseLow;
border-bottom: @ButtonBorderWidth solid @BaseMedium;
color: var(--colorNeutralForeground1);
background-color: var(--colorNeutralBackground2);
border-bottom: @ButtonBorderWidth solid var(--colorNeutralStroke1);
&:hover {
background-color: @BaseMediumLow;
background-color: var(--colorNeutralBackground3);
}
}
}
.ms-DetailsRow {
border-bottom: @ButtonBorderWidth solid @BaseMedium;
border-bottom: @ButtonBorderWidth solid var(--colorNeutralStroke1);
&:hover {
background-color: @BaseMediumLow;
background-color: var(--colorNeutralBackground2);
}
.ms-DetailsRow-cell {
padding: @MediumSpace 20px;
font-size: @DefaultFontSize;
color: @BaseHigh;
color: var(--colorNeutralForeground1);
min-height: 48px;
display: flex;
align-items: center;
.jobNameLink {
color: @LinkColor;
color: var(--colorBrandForeground1);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
@@ -168,7 +227,7 @@
}
.ms-DetailsRow-cell {
font-size: @DefaultFontSize;
color: @BaseHigh;
color: var(--colorNeutralForeground1);
}
}
}

View File

@@ -1,5 +1,8 @@
import { IndexingPolicy } from "@azure/cosmos";
import { act } from "@testing-library/react";
import { AuthType } from "AuthType";
import { shallow } from "enzyme";
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
import ko from "knockout";
import React from "react";
import { updateCollection } from "../../../Common/dataAccess/updateCollection";
@@ -444,3 +447,49 @@ describe("SettingsComponent", () => {
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true);
});
});
describe("SettingsComponent - indexing policy subscription", () => {
const baseProps: SettingsComponentProps = {
settingsTab: new CollectionSettingsTabV2({
collection: collection,
tabKind: ViewModels.CollectionTabKind.CollectionSettingsV2,
title: "Scale & Settings",
tabPath: "",
node: undefined,
}),
};
it("subscribes to the correct container's indexing policy and updates state on change", async () => {
const containerId = collection.id();
const mockIndexingPolicy: IndexingPolicy = {
automatic: false,
indexingMode: "lazy",
includedPaths: [{ path: "/foo/*" }],
excludedPaths: [{ path: "/bar/*" }],
compositeIndexes: [],
spatialIndexes: [],
vectorIndexes: [],
fullTextIndexes: [],
};
const wrapper = shallow(<SettingsComponent {...baseProps} />);
const instance = wrapper.instance() as SettingsComponent;
await act(async () => {
useIndexingPolicyStore.setState({
indexingPolicies: {
[containerId]: mockIndexingPolicy,
},
});
// Wait for the async refreshCollectionData to complete
await new Promise((resolve) => setTimeout(resolve, 0));
});
wrapper.update();
expect(wrapper.state("indexingPolicyContent")).toEqual(mockIndexingPolicy);
expect(wrapper.state("indexingPolicyContentBaseline")).toEqual(mockIndexingPolicy);
// @ts-expect-error: rawDataModel is intentionally accessed for test validation
expect(instance.collection.rawDataModel.indexingPolicy).toEqual(mockIndexingPolicy);
});
});

View File

@@ -13,6 +13,7 @@ import {
ThroughputBucketsComponent,
ThroughputBucketsComponentProps,
} from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent";
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
import { useDatabases } from "Explorer/useDatabases";
import { isFabricNative } from "Platform/Fabric/FabricUtil";
import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
@@ -73,7 +74,6 @@ import {
parseConflictResolutionMode,
parseConflictResolutionProcedure,
} from "./SettingsUtils";
interface SettingsV2TabInfo {
tab: SettingsV2TabTypes;
content: JSX.Element;
@@ -182,7 +182,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private totalThroughputUsed: number;
private throughputBucketsEnabled: boolean;
public mongoDBCollectionResource: MongoDBCollectionResource;
private unsubscribe: () => void;
constructor(props: SettingsComponentProps) {
super(props);
@@ -312,6 +312,13 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
if (this.isCollectionSettingsTab) {
this.refreshIndexTransformationProgress();
this.loadMongoIndexes();
this.unsubscribe = useIndexingPolicyStore.subscribe(
() => {
this.refreshCollectionData();
},
(state) => state.indexingPolicies[this.collection?.id()],
);
this.refreshCollectionData();
}
this.setBaseline();
@@ -319,7 +326,11 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
useCommandBar.getState().setContextButtons(this.getTabsButtons());
}
}
componentWillUnmount(): void {
if (this.unsubscribe) {
this.unsubscribe();
}
}
componentDidUpdate(): void {
if (this.props.settingsTab.isActive()) {
useCommandBar.getState().setContextButtons(this.getTabsButtons());
@@ -849,7 +860,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
{ name: "name_of_property", query: "query_to_compute_property" },
] as DataModels.ComputedProperties;
}
const throughputBuckets = this.offer?.throughputBuckets;
return {
@@ -1009,10 +1019,31 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
startKey,
);
};
private refreshCollectionData = async (): Promise<void> => {
const containerId = this.collection.id();
const latestIndexingPolicy = useIndexingPolicyStore.getState().indexingPolicies[containerId];
const rawPolicy = latestIndexingPolicy ?? this.collection.indexingPolicy();
const latestCollection: DataModels.IndexingPolicy = {
automatic: rawPolicy?.automatic ?? true,
indexingMode: rawPolicy?.indexingMode ?? "consistent",
includedPaths: rawPolicy?.includedPaths ?? [],
excludedPaths: rawPolicy?.excludedPaths ?? [],
compositeIndexes: rawPolicy?.compositeIndexes ?? [],
spatialIndexes: rawPolicy?.spatialIndexes ?? [],
vectorIndexes: rawPolicy?.vectorIndexes ?? [],
fullTextIndexes: rawPolicy?.fullTextIndexes ?? [],
};
this.collection.rawDataModel.indexingPolicy = latestCollection;
this.setState({
indexingPolicyContent: latestCollection,
indexingPolicyContentBaseline: latestCollection,
});
};
private saveCollectionSettings = async (startKey: number): Promise<void> => {
const newCollection: DataModels.Collection = { ...this.collection.rawDataModel };
if (
this.state.isSubSettingsSaveable ||
this.state.isContainerPolicyDirty ||
@@ -1252,7 +1283,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
onScaleDiscardableChange: this.onScaleDiscardableChange,
throughputError: this.state.throughputError,
};
if (!this.isCollectionSettingsTab) {
return (
<div className="settingsV2MainContainer">

View File

@@ -155,7 +155,12 @@ 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}></div>
<div
className="settingsV2Editor"
tabIndex={0}
ref={this.computedPropertiesDiv}
data-test="computed-properties-editor"
></div>
</Stack>
);
}

View File

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

View File

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

View File

@@ -103,7 +103,10 @@ export const ThroughputBucketsComponent: FC<ThroughputBucketsComponentProps> = (
offText="Inactive"
checked={bucket.maxThroughputPercentage !== 100}
onChange={(event, checked) => onToggle(bucket.id, checked)}
styles={{ root: { marginBottom: 0 }, text: { fontSize: 12 } }}
styles={{
root: { marginBottom: 0 },
text: { fontSize: 12, color: "var(--colorNeutralForeground1)" },
}}
></Toggle>
</Stack>
))}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -475,6 +475,11 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
className="panelGroupSpacing"
>
<Text
style={
{
"color": "var(--colorNeutralForeground1)",
}
}
variant="small"
>
Azure Synapse Link is required for creating an analytical store

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

View File

@@ -0,0 +1,107 @@
import { CircleFilled } from "@fluentui/react-icons";
import type { IIndexMetric } from "Explorer/Tabs/QueryTab/ResultsView";
import { useIndexAdvisorStyles } from "Explorer/Tabs/QueryTab/StylesAdvisor";
import * as React from "react";
// SDK response format
export interface IndexMetricsResponse {
UtilizedIndexes?: {
SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>;
CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>;
};
PotentialIndexes?: {
SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>;
CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>;
};
}
export function parseIndexMetrics(indexMetrics: IndexMetricsResponse): {
included: IIndexMetric[];
notIncluded: IIndexMetric[];
} {
const included: IIndexMetric[] = [];
const notIncluded: IIndexMetric[] = [];
// Process UtilizedIndexes (Included in Current Policy)
if (indexMetrics.UtilizedIndexes) {
// Single indexes
indexMetrics.UtilizedIndexes.SingleIndexes?.forEach((index) => {
included.push({
index: index.IndexSpec,
impact: index.IndexImpactScore || "Utilized",
section: "Included",
path: index.IndexSpec,
});
});
// Composite indexes
indexMetrics.UtilizedIndexes.CompositeIndexes?.forEach((index) => {
const compositeSpec = index.IndexSpecs.join(", ");
included.push({
index: compositeSpec,
impact: index.IndexImpactScore || "Utilized",
section: "Included",
composite: index.IndexSpecs.map((spec) => {
const [path, order] = spec.trim().split(/\s+/);
return {
path: path.trim(),
order: order?.toLowerCase() === "desc" ? "descending" : "ascending",
};
}),
});
});
}
// Process PotentialIndexes (Not Included in Current Policy)
if (indexMetrics.PotentialIndexes) {
// Single indexes
indexMetrics.PotentialIndexes.SingleIndexes?.forEach((index) => {
notIncluded.push({
index: index.IndexSpec,
impact: index.IndexImpactScore || "Unknown",
section: "Not Included",
path: index.IndexSpec,
});
});
// Composite indexes
indexMetrics.PotentialIndexes.CompositeIndexes?.forEach((index) => {
const compositeSpec = index.IndexSpecs.join(", ");
notIncluded.push({
index: compositeSpec,
impact: index.IndexImpactScore || "Unknown",
section: "Not Included",
composite: index.IndexSpecs.map((spec) => {
const [path, order] = spec.trim().split(/\s+/);
return {
path: path.trim(),
order: order?.toLowerCase() === "desc" ? "descending" : "ascending",
};
}),
});
});
}
return { included, notIncluded };
}
export const renderImpactDots = (impact: string): JSX.Element => {
const style = useIndexAdvisorStyles();
let count = 0;
if (impact === "High") {
count = 3;
} else if (impact === "Medium") {
count = 2;
} else if (impact === "Low") {
count = 1;
}
return (
<div className={style.indexAdvisorImpactDots}>
{Array.from({ length: count }).map((_, i) => (
<CircleFilled key={i} className={style.indexAdvisorImpactDot} />
))}
</div>
);
};

View File

@@ -3,18 +3,21 @@ import QueryError from "Common/QueryError";
import { IndeterminateProgressBar } from "Explorer/Controls/IndeterminateProgressBar";
import { MessageBanner } from "Explorer/Controls/MessageBanner";
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
import useZoomLevel from "hooks/useZoomLevel";
import React from "react";
import { conditionalClass } from "Utils/StyleUtils";
import RunQuery from "../../../../images/RunQuery.png";
import { QueryResults } from "../../../Contracts/ViewModels";
import { ErrorList } from "./ErrorList";
import { ResultsView } from "./ResultsView";
import useZoomLevel from "hooks/useZoomLevel";
import { conditionalClass } from "Utils/StyleUtils";
export interface ResultsViewProps {
isMongoDB: boolean;
queryResults: QueryResults;
executeQueryDocumentsPage: (firstItemIndex: number) => Promise<void>;
queryEditorContent?: string;
databaseId?: string;
containerId?: string;
}
interface QueryResultProps extends ResultsViewProps {
@@ -49,6 +52,8 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
queryResults,
executeQueryDocumentsPage,
isExecuting,
databaseId,
containerId,
}: QueryResultProps): JSX.Element => {
const styles = useQueryTabStyles();
const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent);
@@ -91,6 +96,9 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
queryResults={queryResults}
executeQueryDocumentsPage={executeQueryDocumentsPage}
isMongoDB={isMongoDB}
queryEditorContent={queryEditorContent}
databaseId={databaseId}
containerId={containerId}
/>
) : (
<ExecuteQueryCallToAction />

View File

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

View File

@@ -28,6 +28,7 @@ import { useMonacoTheme } from "hooks/useTheme";
import React, { Fragment, createRef } from "react";
import "react-splitter-layout/lib/index.css";
import { format } from "react-string-format";
import create from "zustand";
//TODO: Uncomment next two lines when query copilot is reinstated in DE
// import QueryCommandIcon from "../../../../images/CopilotCommand.svg";
// import LaunchCopilot from "../../../../images/CopilotTabIcon.svg";
@@ -57,6 +58,20 @@ import { SaveQueryPane } from "../../Panes/SaveQueryPane/SaveQueryPane";
import TabsBase from "../TabsBase";
import "./QueryTabComponent.less";
export interface QueryMetadataStore {
userQuery: string;
databaseId: string;
containerId: string;
setMetadata: (query1: string, db: string, container: string) => void;
}
export const useQueryMetadataStore = create<QueryMetadataStore>((set) => ({
userQuery: "",
databaseId: "",
containerId: "",
setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }),
}));
enum ToggleState {
Result,
QueryMetrics,
@@ -264,6 +279,10 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
}
public onExecuteQueryClick = async (): Promise<void> => {
const query1 = this.state.sqlQueryEditorContent;
const db = this.props.collection.databaseId;
const container = this.props.collection.id();
useQueryMetadataStore.getState().setMetadata(query1, db, container);
this._iterator = undefined;
setTimeout(async () => {
@@ -780,6 +799,8 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
errors={this.props.copilotStore?.errors}
isExecuting={this.props.copilotStore?.isExecuting}
queryResults={this.props.copilotStore?.queryResults}
databaseId={this.props.collection.databaseId}
containerId={this.props.collection.id()}
executeQueryDocumentsPage={(firstItemIndex: number) =>
QueryDocumentsPerPage(
firstItemIndex,
@@ -795,6 +816,8 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
errors={this.state.errors}
isExecuting={this.state.isExecuting}
queryResults={this.state.queryResults}
databaseId={this.props.collection.databaseId}
containerId={this.props.collection.id()}
executeQueryDocumentsPage={(firstItemIndex: number) =>
this._executeQueryDocumentsPage(firstItemIndex)
}

View File

@@ -0,0 +1,170 @@
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import { IndexAdvisorTab } from "Explorer/Tabs/QueryTab/ResultsView";
import React from "react";
const mockReplace = jest.fn();
const mockFetchAll = jest.fn();
const mockRead = jest.fn();
const mockLogConsoleProgress = jest.fn();
const mockHandleError = jest.fn();
const indexMetricsResponse = {
UtilizedIndexes: {
SingleIndexes: [{ IndexSpec: "/foo/?", IndexImpactScore: "High" }],
CompositeIndexes: [{ IndexSpecs: ["/baz/? DESC", "/qux/? ASC"], IndexImpactScore: "Low" }],
},
PotentialIndexes: {
SingleIndexes: [{ IndexSpec: "/bar/?", IndexImpactScore: "Medium" }],
CompositeIndexes: [] as Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>,
},
};
const mockQueryResults = {
documents: [] as unknown[],
hasMoreResults: false,
itemCount: 0,
firstItemIndex: 0,
lastItemIndex: 0,
requestCharge: 0,
activityId: "test-activity-id",
};
mockRead.mockResolvedValue({
resource: {
indexingPolicy: {
automatic: true,
indexingMode: "consistent",
includedPaths: [{ path: "/*" }, { path: "/foo/?" }],
excludedPaths: [],
},
partitionKey: "pk",
},
});
mockReplace.mockResolvedValue({
resource: {
indexingPolicy: {
automatic: true,
indexingMode: "consistent",
includedPaths: [{ path: "/*" }],
excludedPaths: [],
},
},
});
jest.mock("Common/CosmosClient", () => ({
client: () => ({
database: () => ({
container: () => ({
items: {
query: () => ({
fetchAll: mockFetchAll,
}),
},
read: mockRead,
replace: mockReplace,
}),
}),
}),
}));
jest.mock("./StylesAdvisor", () => ({
useIndexAdvisorStyles: () => ({}),
}));
jest.mock("../../../Utils/NotificationConsoleUtils", () => ({
logConsoleProgress: (...args: unknown[]) => {
mockLogConsoleProgress(...args);
return () => {};
},
}));
jest.mock("../../../Common/ErrorHandlingUtils", () => ({
handleError: (...args: unknown[]) => mockHandleError(...args),
}));
beforeEach(() => {
jest.clearAllMocks();
mockFetchAll.mockResolvedValue({ indexMetrics: indexMetricsResponse });
});
describe("IndexAdvisorTab Basic Tests", () => {
test("component renders without crashing", () => {
const { container } = render(
<IndexAdvisorTab queryEditorContent="SELECT * FROM c" databaseId="db1" containerId="col1" />,
);
expect(container).toBeTruthy();
});
test("renders component and handles missing parameters", () => {
const { container } = render(<IndexAdvisorTab />);
expect(container).toBeTruthy();
// Should not crash when parameters are missing
});
test("fetches index metrics with query results", async () => {
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
await waitFor(() => expect(mockFetchAll).toHaveBeenCalled());
});
test("displays content after loading", async () => {
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
// Wait for the component to finish loading
await waitFor(() => expect(mockFetchAll).toHaveBeenCalled());
// Component should have rendered some content
expect(screen.getByText(/Index Advisor/i)).toBeInTheDocument();
});
test("calls log console progress when fetching metrics", async () => {
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
await waitFor(() => expect(mockLogConsoleProgress).toHaveBeenCalled());
});
test("handles error when fetch fails", async () => {
mockFetchAll.mockRejectedValueOnce(new Error("fetch failed"));
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
await waitFor(() => expect(mockHandleError).toHaveBeenCalled(), { timeout: 3000 });
});
test("renders with all required props", () => {
const { container } = render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="testDb"
containerId="testContainer"
/>,
);
expect(container).toBeTruthy();
expect(container.firstChild).toBeTruthy();
});
});

View File

@@ -1,5 +1,8 @@
import type { CompositePath, IndexingPolicy } from "@azure/cosmos";
import { FontIcon } from "@fluentui/react";
import {
Button,
Checkbox,
DataGrid,
DataGridBody,
DataGridCell,
@@ -8,28 +11,45 @@ import {
DataGridRow,
SelectTabData,
SelectTabEvent,
Spinner,
Tab,
TabList,
Table,
TableBody,
TableCell,
TableColumnDefinition,
TableHeader,
TableRow,
createTableColumn,
} from "@fluentui/react-components";
import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons";
import { ArrowDownloadRegular, ChevronDown20Regular, ChevronRight20Regular, CopyRegular } from "@fluentui/react-icons";
import copy from "clipboard-copy";
import { HttpHeaders } from "Common/Constants";
import MongoUtility from "Common/MongoUtility";
import { QueryMetrics } from "Contracts/DataModels";
import { QueryResults } from "Contracts/ViewModels";
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
import {
parseIndexMetrics,
renderImpactDots,
type IndexMetricsResponse,
} from "Explorer/Tabs/QueryTab/IndexAdvisorUtils";
import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent";
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
import React, { useCallback, useEffect, useState } from "react";
import { userContext } from "UserContext";
import copy from "clipboard-copy";
import React, { useCallback, useState } from "react";
import { logConsoleProgress } from "Utils/NotificationConsoleUtils";
import create from "zustand";
import { client } from "../../../Common/CosmosClient";
import { handleError } from "../../../Common/ErrorHandlingUtils";
import { sampleDataClient } from "../../../Common/SampleDataClient";
import { ResultsViewProps } from "./QueryResultSection";
import { useIndexAdvisorStyles } from "./StylesAdvisor";
enum ResultsTabs {
Results = "results",
QueryStats = "queryStats",
IndexAdvisor = "indexadv",
}
const ResultsTab: React.FC<ResultsViewProps> = ({ queryResults, isMongoDB, executeQueryDocumentsPage }) => {
const styles = useQueryTabStyles();
/* eslint-disable react/prop-types */
@@ -523,14 +543,331 @@ const QueryStatsTab: React.FC<Pick<ResultsViewProps, "queryResults">> = ({ query
);
};
export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => {
export interface IIndexMetric {
index: string;
impact: string;
section: "Included" | "Not Included" | "Header";
path?: string;
composite?: { path: string; order: string }[];
}
export const IndexAdvisorTab: React.FC<{
queryResults?: QueryResults;
queryEditorContent?: string;
databaseId?: string;
containerId?: string;
}> = ({ queryResults, queryEditorContent, databaseId, containerId }) => {
const style = useIndexAdvisorStyles();
const [loading, setLoading] = useState(false);
const [indexMetrics, setIndexMetrics] = useState<IndexMetricsResponse | null>(null);
const [showIncluded, setShowIncluded] = useState(true);
const [showNotIncluded, setShowNotIncluded] = useState(true);
const [selectedIndexes, setSelectedIndexes] = useState<IIndexMetric[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [updateMessageShown, setUpdateMessageShown] = useState(false);
const [included, setIncludedIndexes] = useState<IIndexMetric[]>([]);
const [notIncluded, setNotIncludedIndexes] = useState<IIndexMetric[]>([]);
const [isUpdating, setIsUpdating] = useState(false);
const [justUpdatedPolicy, setJustUpdatedPolicy] = useState(false);
const indexingMetricsDocLink = "https://learn.microsoft.com/azure/cosmos-db/nosql/index-metrics";
const fetchIndexMetrics = async () => {
if (!queryEditorContent || !databaseId || !containerId) {
return;
}
setLoading(true);
const clearMessage = logConsoleProgress(`Querying items with IndexMetrics in container ${containerId}`);
try {
const querySpec = {
query: queryEditorContent,
};
// Use sampleDataClient for CopilotSampleDB, regular client for other databases
const cosmosClient = databaseId === "CopilotSampleDB" ? sampleDataClient() : client();
const sdkResponse = await cosmosClient
.database(databaseId)
.container(containerId)
.items.query(querySpec, {
populateIndexMetrics: true,
})
.fetchAll();
const parsedMetrics =
typeof sdkResponse.indexMetrics === "string" ? JSON.parse(sdkResponse.indexMetrics) : sdkResponse.indexMetrics;
setIndexMetrics(parsedMetrics);
} catch (error) {
handleError(error, "queryItemsWithIndexMetrics", `Error querying items from ${containerId}`);
} finally {
clearMessage();
setLoading(false);
}
};
// Fetch index metrics when query results change (i.e., when Execute Query is clicked)
useEffect(() => {
if (queryEditorContent && databaseId && containerId && queryResults) {
fetchIndexMetrics();
}
}, [queryResults]);
useEffect(() => {
if (!indexMetrics) {
return;
}
const { included, notIncluded } = parseIndexMetrics(indexMetrics);
setIncludedIndexes(included);
setNotIncludedIndexes(notIncluded);
if (justUpdatedPolicy) {
setJustUpdatedPolicy(false);
} else {
setUpdateMessageShown(false);
}
}, [indexMetrics]);
useEffect(() => {
const allSelected =
notIncluded.length > 0 && notIncluded.every((item) => selectedIndexes.some((s) => s.index === item.index));
setSelectAll(allSelected);
}, [selectedIndexes, notIncluded]);
const handleCheckboxChange = (indexObj: IIndexMetric, checked: boolean) => {
if (checked) {
setSelectedIndexes((prev) => [...prev, indexObj]);
} else {
setSelectedIndexes((prev) => prev.filter((item) => item.index !== indexObj.index));
}
};
const handleSelectAll = (checked: boolean) => {
setSelectAll(checked);
setSelectedIndexes(checked ? notIncluded : []);
};
const handleUpdatePolicy = async () => {
setIsUpdating(true);
try {
const containerRef = client().database(databaseId).container(containerId);
const { resource: containerDef } = await containerRef.read();
const newIncludedPaths = selectedIndexes
.filter((index) => !index.composite)
.map((index) => {
return {
path: index.path,
};
});
const newCompositeIndexes: CompositePath[][] = selectedIndexes
.filter((index) => Array.isArray(index.composite))
.map(
(index) =>
(index.composite as { path: string; order: string }[]).map((comp) => ({
path: comp.path,
order: comp.order === "descending" ? "descending" : "ascending",
})) as CompositePath[],
);
const updatedPolicy: IndexingPolicy = {
...containerDef.indexingPolicy,
includedPaths: [...(containerDef.indexingPolicy?.includedPaths || []), ...newIncludedPaths],
compositeIndexes: [...(containerDef.indexingPolicy?.compositeIndexes || []), ...newCompositeIndexes],
automatic: containerDef.indexingPolicy?.automatic ?? true,
indexingMode: containerDef.indexingPolicy?.indexingMode ?? "consistent",
excludedPaths: containerDef.indexingPolicy?.excludedPaths ?? [],
};
await containerRef.replace({
id: containerId,
partitionKey: containerDef.partitionKey,
indexingPolicy: updatedPolicy,
});
useIndexingPolicyStore.getState().setIndexingPolicyFor(containerId, updatedPolicy);
const selectedIndexSet = new Set(selectedIndexes.map((s) => s.index));
const updatedNotIncluded: typeof notIncluded = [];
const newlyIncluded: typeof included = [];
for (const item of notIncluded) {
if (selectedIndexSet.has(item.index)) {
newlyIncluded.push(item);
} else {
updatedNotIncluded.push(item);
}
}
const newIncluded = [...included, ...newlyIncluded];
const newNotIncluded = updatedNotIncluded;
setIncludedIndexes(newIncluded);
setNotIncludedIndexes(newNotIncluded);
setSelectedIndexes([]);
setSelectAll(false);
setUpdateMessageShown(true);
setJustUpdatedPolicy(true);
} catch (err) {
console.error("Failed to update indexing policy:", err);
} finally {
setIsUpdating(false);
}
};
const renderRow = (item: IIndexMetric, index: number) => {
const isHeader = item.section === "Header";
const isNotIncluded = item.section === "Not Included";
return (
<TableRow key={index}>
<TableCell colSpan={2}>
<div className={style.indexAdvisorGrid}>
{isNotIncluded ? (
<Checkbox
checked={selectedIndexes.some((selected) => selected.index === item.index)}
onChange={(_, data) => handleCheckboxChange(item, data.checked === true)}
/>
) : isHeader && item.index === "Not Included in Current Policy" && notIncluded.length > 0 ? (
<Checkbox checked={selectAll} onChange={(_, data) => handleSelectAll(data.checked === true)} />
) : (
<div className={style.indexAdvisorCheckboxSpacer}></div>
)}
{isHeader ? (
<span
style={{ cursor: "pointer" }}
onClick={() => {
if (item.index === "Included in Current Policy") {
setShowIncluded(!showIncluded);
} else if (item.index === "Not Included in Current Policy") {
setShowNotIncluded(!showNotIncluded);
}
}}
>
{item.index === "Included in Current Policy" ? (
showIncluded ? (
<ChevronDown20Regular />
) : (
<ChevronRight20Regular />
)
) : showNotIncluded ? (
<ChevronDown20Regular />
) : (
<ChevronRight20Regular />
)}
</span>
) : (
<div className={style.indexAdvisorChevronSpacer}></div>
)}
<div className={isHeader ? style.indexAdvisorRowBold : style.indexAdvisorRowNormal}>{item.index}</div>
<div className={isHeader ? style.indexAdvisorRowImpactHeader : style.indexAdvisorRowImpact}>
{!isHeader && item.impact}
</div>
<div>{!isHeader && renderImpactDots(item.impact)}</div>
</div>
</TableCell>
</TableRow>
);
};
const indexMetricItems = React.useMemo(() => {
const items: IIndexMetric[] = [];
items.push({ index: "Not Included in Current Policy", impact: "", section: "Header" });
if (showNotIncluded) {
notIncluded.forEach((item) => items.push({ ...item, section: "Not Included" }));
}
items.push({ index: "Included in Current Policy", impact: "", section: "Header" });
if (showIncluded) {
included.forEach((item) => items.push({ ...item, section: "Included" }));
}
return items;
}, [included, notIncluded, showIncluded, showNotIncluded]);
if (loading) {
return (
<div>
<Spinner
size="small"
style={
{
"--spinner-size": "16px",
"--spinner-thickness": "2px",
"--spinner-color": "#0078D4",
} as React.CSSProperties
}
/>
</div>
);
}
return (
<div>
<div className={style.indexAdvisorMessage}>
{updateMessageShown ? (
<>
<span className={style.indexAdvisorSuccessIcon}>
<FontIcon iconName="CheckMark" style={{ color: "white", fontSize: 12 }} />
</span>
<span>
Your indexing policy has been updated with the new included paths. You may review the changes in Scale &
Settings.
</span>
</>
) : (
<>
<span>
Index Advisor uses Indexing Metrics to suggest query paths that, when included in your indexing policy,
can improve the performance of this query by reducing RU costs and lowering latency.{" "}
<a href={indexingMetricsDocLink} target="_blank" rel="noopener noreferrer">
Learn more about Indexing Metrics
</a>
.{" "}
</span>
</>
)}
</div>
<div className={style.indexAdvisorTitle}>Indexes analysis</div>
<Table className={style.indexAdvisorTable}>
<TableHeader>
<TableRow>
<TableCell colSpan={2}>
<div className={style.indexAdvisorGrid}>
<div className={style.indexAdvisorCheckboxSpacer}></div>
<div className={style.indexAdvisorChevronSpacer}></div>
<div>Index</div>
<div>
<span style={{ whiteSpace: "nowrap" }}>Estimated Impact</span>
</div>
</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>{indexMetricItems.map(renderRow)}</TableBody>
</Table>
{selectedIndexes.length > 0 && (
<div className={style.indexAdvisorButtonBar}>
{isUpdating ? (
<div className={style.indexAdvisorButtonSpinner}>
<Spinner size="tiny" />{" "}
</div>
) : (
<button onClick={handleUpdatePolicy} className={style.indexAdvisorButton}>
Update Indexing Policy with selected index(es)
</button>
)}
</div>
)}
</div>
);
};
export const ResultsView: React.FC<ResultsViewProps> = ({
isMongoDB,
queryResults,
executeQueryDocumentsPage,
queryEditorContent,
databaseId,
containerId,
}) => {
const styles = useQueryTabStyles();
const [activeTab, setActiveTab] = useState<ResultsTabs>(ResultsTabs.Results);
const onTabSelect = useCallback((event: SelectTabEvent, data: SelectTabData) => {
setActiveTab(data.value as ResultsTabs);
}, []);
return (
<div data-test="QueryTab/ResultsPane/ResultsView" className={styles.queryResultsTabPanel}>
<TabList selectedValue={activeTab} onTabSelect={onTabSelect}>
@@ -548,6 +885,13 @@ export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResult
>
Query Stats
</Tab>
<Tab
data-test="QueryTab/ResultsPane/ResultsView/IndexAdvisorTab"
id={ResultsTabs.IndexAdvisor}
value={ResultsTabs.IndexAdvisor}
>
Index Advisor
</Tab>
</TabList>
<div className={styles.queryResultsTabContentContainer}>
{activeTab === ResultsTabs.Results && (
@@ -558,7 +902,30 @@ export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResult
/>
)}
{activeTab === ResultsTabs.QueryStats && <QueryStatsTab queryResults={queryResults} />}
{activeTab === ResultsTabs.IndexAdvisor && (
<IndexAdvisorTab
queryResults={queryResults}
queryEditorContent={queryEditorContent}
databaseId={databaseId}
containerId={containerId}
/>
)}
</div>
</div>
);
};
export interface IndexingPolicyStore {
indexingPolicies: { [containerId: string]: IndexingPolicy };
setIndexingPolicyFor: (containerId: string, indexingPolicy: IndexingPolicy) => void;
}
export const useIndexingPolicyStore = create<IndexingPolicyStore>((set) => ({
indexingPolicies: {},
setIndexingPolicyFor: (containerId, indexingPolicy) =>
set((state) => ({
indexingPolicies: {
...state.indexingPolicies,
[containerId]: { ...indexingPolicy },
},
})),
}));

View File

@@ -0,0 +1,95 @@
import { makeStyles } from "@fluentui/react-components";
export type IndexAdvisorStyles = ReturnType<typeof useIndexAdvisorStyles>;
export const useIndexAdvisorStyles = makeStyles({
indexAdvisorMessage: {
padding: "1rem",
fontSize: "1.2rem",
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
indexAdvisorSuccessIcon: {
width: "18px",
height: "18px",
borderRadius: "50%",
backgroundColor: "#107C10",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
indexAdvisorTitle: {
padding: "1rem",
fontSize: "1.3rem",
fontWeight: "bold",
},
indexAdvisorTable: {
display: "block",
alignItems: "center",
marginBottom: "7rem",
},
indexAdvisorGrid: {
display: "grid",
gridTemplateColumns: "30px 30px 1fr 50px 120px",
alignItems: "center",
gap: "15px",
fontWeight: "bold",
},
indexAdvisorCheckboxSpacer: {
width: "18px",
height: "18px",
},
indexAdvisorChevronSpacer: {
width: "24px",
},
indexAdvisorRowBold: {
fontWeight: "bold",
},
indexAdvisorRowNormal: {
fontWeight: "normal",
},
indexAdvisorRowImpactHeader: {
fontSize: 0,
},
indexAdvisorRowImpact: {
fontWeight: "normal",
},
indexAdvisorImpactDot: {
color: "#0078D4",
fontSize: "12px",
display: "inline-flex",
},
indexAdvisorImpactDots: {
display: "flex",
alignItems: "center",
gap: "4px",
},
indexAdvisorButtonBar: {
padding: "1rem",
marginTop: "-7rem",
flexWrap: "wrap",
},
indexAdvisorButtonSpinner: {
marginTop: "1rem",
minWidth: "320px",
minHeight: "40px",
display: "flex",
alignItems: "left",
justifyContent: "left",
marginLeft: "10rem",
},
indexAdvisorButton: {
backgroundColor: "#0078D4",
color: "white",
padding: "8px 16px",
border: "none",
borderRadius: "4px",
cursor: "pointer",
marginTop: "1rem",
fontSize: "1rem",
fontWeight: 500,
transition: "background 0.2s",
":hover": {
backgroundColor: "#005a9e",
},
},
});

View File

@@ -0,0 +1,15 @@
import create from "zustand";
interface QueryMetadataStore {
userQuery: string;
databaseId: string;
containerId: string;
setMetadata: (query1: string, db: string, container: string) => void;
}
export const useQueryMetadataStore = create<QueryMetadataStore>((set) => ({
userQuery: "",
databaseId: "",
containerId: "",
setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }),
}));

View File

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

View File

@@ -1,6 +1,6 @@
import { Dropdown } from "@fluentui/react";
import * as React from "react";
import { FunctionComponent } from "react";
import { SearchableDropdown } from "../../../Common/SearchableDropdown";
import { DatabaseAccount } from "../../../Contracts/DataModels";
interface Props {
@@ -17,18 +17,23 @@ export const SwitchAccount: FunctionComponent<Props> = ({
dismissMenu,
}: Props) => {
return (
<SearchableDropdown<DatabaseAccount>
<Dropdown
label="Cosmos DB Account Name"
items={accounts}
selectedItem={selectedAccount}
onSelect={(account) => setSelectedAccountName(account.name)}
getKey={(account) => account.name}
getDisplayText={(account) => account.name}
placeholder="Select an Account"
filterPlaceholder="Filter accounts"
className="accountSwitchAccountDropdown"
disabled={!accounts || accounts.length === 0}
onDismiss={dismissMenu}
options={accounts?.map((account) => ({
key: account.name,
text: account.name,
data: account,
}))}
onChange={(_, option) => {
setSelectedAccountName(String(option?.key));
dismissMenu();
}}
defaultSelectedKey={selectedAccount?.name}
placeholder={accounts && accounts.length === 0 ? "No Accounts Found" : "Select an Account"}
styles={{
callout: "accountSwitchAccountDropdownMenu",
}}
/>
);
};

View File

@@ -1,6 +1,6 @@
import { Dropdown } from "@fluentui/react";
import * as React from "react";
import { FunctionComponent } from "react";
import { SearchableDropdown } from "../../../Common/SearchableDropdown";
import { Subscription } from "../../../Contracts/DataModels";
interface Props {
@@ -15,16 +15,24 @@ export const SwitchSubscription: FunctionComponent<Props> = ({
selectedSubscription,
}: Props) => {
return (
<SearchableDropdown<Subscription>
<Dropdown
label="Subscription"
items={subscriptions}
selectedItem={selectedSubscription}
onSelect={(sub) => setSelectedSubscriptionId(sub.subscriptionId)}
getKey={(sub) => sub.subscriptionId}
getDisplayText={(sub) => sub.displayName}
placeholder="Select a Subscription"
filterPlaceholder="Filter subscriptions"
className="accountSwitchSubscriptionDropdown"
options={subscriptions?.map((sub) => {
return {
key: sub.subscriptionId,
text: sub.displayName,
data: sub,
};
})}
onChange={(_, option) => {
setSelectedSubscriptionId(String(option?.key));
}}
defaultSelectedKey={selectedSubscription?.subscriptionId}
placeholder={subscriptions && subscriptions.length === 0 ? "No Subscriptions Found" : "Select a Subscription"}
styles={{
callout: "accountSwitchSubscriptionDropdownMenu",
}}
/>
);
};

View File

@@ -316,11 +316,6 @@ body.isDarkMode {
background-color: transparent;
}
// High specificity override for any nested elements
* {
color: var(--colorNeutralForeground1);
}
// Ensure links maintain proper colors
.ms-Link {
color: var(--colorBrandForeground1);
@@ -438,7 +433,6 @@ body.isDarkMode {
button {
&:not(.ms-Button):not(.ms-IconButton) {
background-color: var(--colorNeutralBackground1);
color: var(--colorNeutralForeground1);
&:hover {

View File

@@ -12,6 +12,7 @@
--colorCompoundBrandStroke1: @SelectionColor;
--colorBrandForeground1: @LinkColor;
--colorPaletteRedForeground1: @ErrorColor;
--colorSuccessGreen: #107c10;
--overlayBackground: rgba(0, 0, 0, 0.4);
--colorBrandBackground: @SelectionColor;
--colorBrandBackgroundHover: @AccentMediumHigh;
@@ -32,6 +33,7 @@ body.isDarkMode {
--colorCompoundBrandStroke1: #4db6e8;
--colorBrandForeground1: #4db6e8;
--colorPaletteRedForeground1: #f25d5d;
--colorSuccessGreen: #107c10;
--overlayBackground: rgba(0, 0, 0, 0.4);
--colorBrandBackground: #0078d4;
--colorBrandBackgroundHover: #106ebe;

View File

@@ -164,6 +164,9 @@ $ENV:NOSQL_TESTACCOUNT_TOKEN=az account get-access-token --scope "https://<accou
# NoSQL API (Readonly)
$ENV:NOSQL_READONLY_TESTACCOUNT_TOKEN=az account get-access-token --scope "https://<account name>.documents.azure.com/.default" -o tsv --query accessToken
# NoSQL API (Container Copy)
$ENV:NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=az account get-access-token --scope "https://<account name>.documents.azure.com/.default" -o tsv --query accessToken
# Tables API
$ENV:TABLE_TESTACCOUNT_TOKEN=az account get-access-token --scope "https://<account name>.documents.azure.com/.default" -o tsv --query accessToken

View File

@@ -11,7 +11,7 @@ export interface TestNameOptions {
prefixed?: boolean;
}
export function generateUniqueName(baseName, options?: TestNameOptions): string {
export function generateUniqueName(baseName: string, options?: TestNameOptions): string {
const length = options?.length ?? 1;
const timestamp = options?.timestampped === undefined ? true : options.timestampped;
const prefixed = options?.prefixed === undefined ? true : options.prefixed;
@@ -40,6 +40,7 @@ export enum TestAccount {
Mongo32 = "Mongo32",
SQL = "SQL",
SQLReadOnly = "SQLReadOnly",
SQLContainerCopyOnly = "SQLContainerCopyOnly",
}
export const defaultAccounts: Record<TestAccount, string> = {
@@ -51,6 +52,7 @@ export const defaultAccounts: Record<TestAccount, string> = {
[TestAccount.Mongo32]: "github-e2etests-mongo32",
[TestAccount.SQL]: "github-e2etests-sql",
[TestAccount.SQLReadOnly]: "github-e2etests-sql-readonly",
[TestAccount.SQLContainerCopyOnly]: "github-e2etests-sql-containercopyonly",
};
export const resourceGroupName = process.env.DE_TEST_RESOURCE_GROUP ?? "de-e2e-tests";
@@ -77,7 +79,14 @@ export function getAccountName(accountType: TestAccount) {
);
}
export async function getTestExplorerUrl(accountType: TestAccount, iframeSrc?: string): Promise<string> {
type TestExplorerUrlOptions = {
iframeSrc?: string;
enablecontainercopy?: boolean;
};
export async function getTestExplorerUrl(accountType: TestAccount, options?: TestExplorerUrlOptions): Promise<string> {
const { iframeSrc, enablecontainercopy } = options ?? {};
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
const accountName = getAccountName(accountType);
@@ -93,6 +102,7 @@ export async function getTestExplorerUrl(accountType: TestAccount, iframeSrc?: s
const nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN;
const nosqlReadOnlyRbacToken = process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN;
const nosqlContainerCopyRbacToken = process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN;
const tableRbacToken = process.env.TABLE_TESTACCOUNT_TOKEN;
const gremlinRbacToken = process.env.GREMLIN_TESTACCOUNT_TOKEN;
const cassandraRbacToken = process.env.CASSANDRA_TESTACCOUNT_TOKEN;
@@ -108,6 +118,16 @@ export async function getTestExplorerUrl(accountType: TestAccount, iframeSrc?: s
}
break;
case TestAccount.SQLContainerCopyOnly:
if (nosqlContainerCopyRbacToken) {
params.set("nosqlRbacToken", nosqlContainerCopyRbacToken);
params.set("enableaaddataplane", "true");
}
if (enablecontainercopy) {
params.set("enablecontainercopy", "true");
}
break;
case TestAccount.SQLReadOnly:
if (nosqlReadOnlyRbacToken) {
params.set("nosqlReadOnlyRbacToken", nosqlReadOnlyRbacToken);
@@ -165,6 +185,39 @@ export async function getTestExplorerUrl(accountType: TestAccount, iframeSrc?: s
return `https://localhost:1234/testExplorer.html?${params.toString()}`;
}
type DropdownItemExpectations = {
ariaLabel?: string;
itemCount?: number;
};
type DropdownItemMatcher = {
name?: string;
position?: number;
};
export async function getDropdownItemByNameOrPosition(
frame: Frame,
matcher?: DropdownItemMatcher,
expectedOptions?: DropdownItemExpectations,
): Promise<Locator> {
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
if (expectedOptions?.ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(expectedOptions.ariaLabel);
}
if (expectedOptions?.itemCount) {
const items = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
await expect(items).toHaveCount(expectedOptions.itemCount);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
if (matcher?.name) {
return containerDropdownItems.filter({ hasText: matcher.name });
} else if (matcher?.position !== undefined) {
return containerDropdownItems.nth(matcher.position);
}
// Return first item if no matcher is provided
return containerDropdownItems.first();
}
/** Helper class that provides locator methods for TreeNode elements, on top of a Locator */
class TreeNode {
constructor(
@@ -325,7 +378,9 @@ type PanelOpenOptions = {
export enum CommandBarButton {
Save = "Save",
Execute = "Execute",
ExecuteQuery = "Execute Query",
UploadItem = "Upload Item",
}
/** Helper class that provides locator methods for DataExplorer components, on top of a Frame */
@@ -515,7 +570,7 @@ export class DataExplorer {
}
/** Waits for the Data Explorer app to load */
static async waitForExplorer(page: Page) {
static async waitForExplorer(page: Page, options?: TestExplorerUrlOptions): Promise<DataExplorer> {
const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle();
if (iframeElement === null) {
throw new Error("Explorer iframe not found");
@@ -527,15 +582,126 @@ export class DataExplorer {
throw new Error("Explorer frame not found");
}
await explorerFrame?.getByTestId("DataExplorerRoot").waitFor();
if (!options?.enablecontainercopy) {
await explorerFrame?.getByTestId("DataExplorerRoot").waitFor();
}
return new DataExplorer(explorerFrame);
}
/** Opens the Data Explorer app using the specified test account (and optionally, the provided IFRAME src url). */
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<DataExplorer> {
const url = await getTestExplorerUrl(testAccount, iframeSrc);
const url = await getTestExplorerUrl(testAccount, { iframeSrc });
await page.goto(url);
return DataExplorer.waitForExplorer(page);
}
}
export async function waitForApiResponse(
page: Page,
urlPattern: string,
method?: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payloadValidator?: (payload: any) => boolean,
) {
try {
// Check if page is still valid before waiting
if (page.isClosed()) {
throw new Error(`Page is closed, cannot wait for API response: ${urlPattern}`);
}
return page.waitForResponse(
async (response) => {
const request = response.request();
if (!request.url().includes(urlPattern)) {
return false;
}
if (method && request.method() !== method) {
return false;
}
if (payloadValidator && (request.method() === "POST" || request.method() === "PUT")) {
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
return payloadValidator(payload);
} catch {
return false;
}
}
}
return true;
},
{ timeout: 60 * 1000 },
);
} catch (error) {
if (error instanceof Error && error.message.includes("Target page, context or browser has been closed")) {
console.warn("Page was closed while waiting for API response:", urlPattern);
throw new Error(`Page closed while waiting for API response: ${urlPattern}`);
}
throw error;
}
}
export async function interceptAndInspectApiRequest(
page: Page,
urlPattern: string,
method: string = "POST",
error: Error,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
errorValidator: (url?: string, payload?: any) => boolean,
): Promise<void> {
await page.route(
(url) => url.pathname.includes(urlPattern),
async (route, request) => {
if (request.method() !== method) {
await route.continue();
return;
}
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
if (errorValidator && errorValidator(request.url(), payload)) {
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
code: "Conflict",
message: error.message,
}),
});
return;
}
} catch (err) {
if (err instanceof Error && err.message.includes("not allowed")) {
throw err;
}
}
}
await route.continue();
},
);
}
export class ContainerCopy {
constructor(
public frame: Frame,
public wrapper: Locator,
) {}
static async waitForContainerCopy(page: Page): Promise<ContainerCopy> {
const explorerFrame = await DataExplorer.waitForExplorer(page, { enablecontainercopy: true });
const containerCopyWrapper = explorerFrame.frame.locator("div#containerCopyWrapper");
return new ContainerCopy(explorerFrame.frame, containerCopyWrapper);
}
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<ContainerCopy> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true });
await page.goto(url);
return ContainerCopy.waitForContainerCopy(page);
}
}

View File

@@ -0,0 +1,493 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { expect, Frame, Locator, Page, test } from "@playwright/test";
import { set } from "lodash";
import { truncateName } from "../../src/Explorer/ContainerCopy/CopyJobUtils";
import {
ContainerCopy,
getAccountName,
getDropdownItemByNameOrPosition,
interceptAndInspectApiRequest,
TestAccount,
waitForApiResponse,
} from "../fx";
import { createMultipleTestContainers } from "../testData";
let page: Page;
let wrapper: Locator = null!;
let panel: Locator = null!;
let frame: Frame = null!;
let expectedCopyJobNameInitial: string = null!;
let expectedJobName: string = "";
let targetAccountName: string = "";
let expectedSourceAccountName: string = "";
let expectedSubscriptionName: string = "";
const VISIBLE_TIMEOUT_MS = 30 * 1000;
test.describe.configure({ mode: "serial" });
test.describe("Container Copy", () => {
test.beforeAll("Container Copy - Before All", async ({ browser }) => {
await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 3 });
page = await browser.newPage();
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
expectedJobName = `test_job_${Date.now()}`;
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
});
test.afterEach("Container Copy - After Each", async () => {
await page.unroute(/.*/, (route) => route.continue());
});
test("Loading and verifying the content of the page", async () => {
expect(wrapper).not.toBeNull();
await expect(wrapper.getByTestId("CommandBar/Button:Create Copy Job")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
await expect(wrapper.getByTestId("CommandBar/Button:Refresh")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
await expect(wrapper.getByTestId("CommandBar/Button:Feedback")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
});
test("Successfully create a copy job for offline migration", async () => {
expect(wrapper).not.toBeNull();
// Loading and verifying subscription & account dropdown
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
await createCopyJobButton.click();
panel = frame.getByTestId("Panel:Create copy job");
await expect(panel).toBeVisible();
await page.waitForTimeout(10 * 1000);
const subscriptionDropdown = panel.getByTestId("subscription-dropdown");
const expectedAccountName = targetAccountName;
expectedSubscriptionName = await subscriptionDropdown.locator("span.ms-Dropdown-title").innerText();
await subscriptionDropdown.click();
const subscriptionItem = await getDropdownItemByNameOrPosition(
frame,
{ name: expectedSubscriptionName },
{ ariaLabel: "Subscription" },
);
await subscriptionItem.click();
// Load account dropdown based on selected subscription
const accountDropdown = panel.getByTestId("account-dropdown");
await expect(accountDropdown).toHaveText(new RegExp(expectedAccountName));
await accountDropdown.click();
const accountItem = await getDropdownItemByNameOrPosition(
frame,
{ name: expectedAccountName },
{ ariaLabel: "Account" },
);
await accountItem.click();
// Verifying online or offline checkbox functionality
/**
* This test verifies the functionality of the migration type checkbox that toggles between
* online and offline container copy modes. It ensures that:
* 1. When online mode is selected, the user is directed to a permissions screen
* 2. When offline mode is selected, the user bypasses the permissions screen
* 3. The UI correctly reflects the selected migration type throughout the workflow
*/
const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox");
await fluentUiCheckboxContainer.click();
await panel.getByRole("button", { name: "Next" }).click();
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).toBeVisible();
await expect(panel.getByText("Online container copy", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "Previous" }).click();
await fluentUiCheckboxContainer.click();
await panel.getByRole("button", { name: "Next" }).click();
await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible();
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible();
// Verifying source and target container selection
const sourceContainerDropdown = panel.getByTestId("source-containerDropdown");
expect(sourceContainerDropdown).toBeVisible();
await expect(sourceContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown");
await sourceDatabaseDropdown.click();
const sourceDbDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Database" },
);
await sourceDbDropdownItem.click();
await expect(sourceContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await sourceContainerDropdown.click();
const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Container" },
);
await sourceContainerDropdownItem.click();
const targetContainerDropdown = panel.getByTestId("target-containerDropdown");
expect(targetContainerDropdown).toBeVisible();
await expect(targetContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown");
await targetDatabaseDropdown.click();
const targetDbDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Database" },
);
await targetDbDropdownItem.click();
await expect(targetContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await targetContainerDropdown.click();
const targetContainerDropdownItem1 = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Container" },
);
await targetContainerDropdownItem1.click();
await panel.getByRole("button", { name: "Next" }).click();
const errorContainer = panel.getByTestId("Panel:ErrorContainer");
await expect(errorContainer).toBeVisible();
await expect(errorContainer).toHaveText(/Source and destination containers cannot be the same/i);
// Reselect target container to be different from source container
await targetContainerDropdown.click();
const targetContainerDropdownItem2 = await getDropdownItemByNameOrPosition(
frame,
{ position: 1 },
{ ariaLabel: "Container" },
);
await targetContainerDropdownItem2.click();
const selectedSourceDatabase = await sourceDatabaseDropdown.innerText();
const selectedSourceContainer = await sourceContainerDropdown.innerText();
const selectedTargetDatabase = await targetDatabaseDropdown.innerText();
const selectedTargetContainer = await targetContainerDropdown.innerText();
expectedCopyJobNameInitial = `${truncateName(selectedSourceDatabase)}.${truncateName(
selectedSourceContainer,
)}_${truncateName(selectedTargetDatabase)}.${truncateName(selectedTargetContainer)}`;
await panel.getByRole("button", { name: "Next" }).click();
await expect(errorContainer).not.toBeVisible();
await expect(panel.getByTestId("Panel:PreviewCopyJob")).toBeVisible();
// Verifying the preview of the copy job
const previewContainer = panel.getByTestId("Panel:PreviewCopyJob");
await expect(previewContainer).toBeVisible();
await expect(previewContainer.getByTestId("source-subscription-name")).toHaveText(expectedSubscriptionName);
await expect(previewContainer.getByTestId("source-account-name")).toHaveText(expectedAccountName);
const jobNameInput = previewContainer.getByTestId("job-name-textfield");
await expect(jobNameInput).toHaveValue(new RegExp(expectedCopyJobNameInitial));
const primaryBtn = panel.getByRole("button", { name: "Copy", exact: true });
await expect(primaryBtn).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await jobNameInput.fill("test job name");
await expect(primaryBtn).toHaveClass(/(^|\s)is-disabled(\s|$)/);
// Testing API request interception with duplicate job name
const duplicateJobName = "test-job-name-1";
await jobNameInput.fill(duplicateJobName);
const copyButton = panel.getByRole("button", { name: "Copy", exact: true });
const expectedErrorMessage = `Duplicate job name '${duplicateJobName}'`;
await interceptAndInspectApiRequest(
page,
`${expectedAccountName}/dataTransferJobs/${duplicateJobName}`,
"PUT",
new Error(expectedErrorMessage),
(url?: string) => url?.includes(duplicateJobName) ?? false,
);
let errorThrown = false;
try {
await copyButton.click();
await page.waitForTimeout(2000);
} catch (error: any) {
errorThrown = true;
expect(error.message).toContain("not allowed");
}
if (!errorThrown) {
const errorContainer = panel.getByTestId("Panel:ErrorContainer");
await expect(errorContainer).toBeVisible();
await expect(errorContainer).toHaveText(new RegExp(expectedErrorMessage, "i"));
}
await expect(panel).toBeVisible();
// Testing API request success with valid job name and verifying copy job creation
const validJobName = expectedJobName;
const copyJobCreationPromise = waitForApiResponse(
page,
`${expectedAccountName}/dataTransferJobs/${validJobName}`,
"PUT",
);
await jobNameInput.fill(validJobName);
await expect(copyButton).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await copyButton.click();
const response = await copyJobCreationPromise;
expect(response.ok()).toBe(true);
await expect(panel).not.toBeVisible({ timeout: 10000 });
const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page");
await jobsListContainer.waitFor({ state: "visible" });
const jobItem = jobsListContainer.getByText(validJobName);
await jobItem.waitFor({ state: "visible" });
await expect(jobItem).toBeVisible();
});
test("Verify Online or Offline Container Copy Permissions Panel", async () => {
expect(wrapper).not.toBeNull();
// Opening the Create Copy Job panel again to verify initial state
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
await createCopyJobButton.click();
panel = frame.getByTestId("Panel:Create copy job");
await expect(panel).toBeVisible();
await expect(panel.getByRole("heading", { name: "Create copy job" })).toBeVisible();
// select different account dropdown
const accountDropdown = panel.getByTestId("account-dropdown");
await accountDropdown.click();
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Account");
const allDropdownItems = await dropdownItemsWrapper.locator(`button.ms-Dropdown-item[role='option']`).all();
const filteredItems = [];
for (const item of allDropdownItems) {
const testContent = (await item.textContent()) ?? "";
if (testContent.trim() !== targetAccountName.trim()) {
filteredItems.push(item);
}
}
if (filteredItems.length > 0) {
const firstDropdownItem = filteredItems[0];
expectedSourceAccountName = (await firstDropdownItem.textContent()) ?? "";
await firstDropdownItem.click();
} else {
throw new Error("No dropdown items available after filtering");
}
const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox");
await fluentUiCheckboxContainer.click();
await panel.getByRole("button", { name: "Next" }).click();
// Verifying Assign Permissions panel for online copy
const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer");
await expect(permissionScreen).toBeVisible();
await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible();
await expect(permissionScreen.getByText("Cross-account container copy", { exact: true })).toBeVisible();
// Verify Point-in-Time Restore timer and refresh button workflow
await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}**`, async (route) => {
const mockData = {
identity: {
type: "SystemAssigned",
principalId: "00-11-22-33",
},
properties: {
defaultIdentity: "SystemAssignedIdentity",
backupPolicy: {
type: "Continuous",
},
capabilities: [{ name: "EnableOnlineContainerCopy" }],
},
};
if (route.request().method() === "GET") {
const response = await route.fetch();
const actualData = await response.json();
const mergedData = { ...actualData };
set(mergedData, "identity", mockData.identity);
set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
set(mergedData, "properties.capabilities", mockData.properties.capabilities);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mergedData),
});
} else {
await route.continue();
}
});
await expect(permissionScreen).toBeVisible();
const expandedOnlineAccordionHeader = permissionScreen
.getByTestId("permission-group-container-onlineConfigs")
.locator("button[aria-expanded='true']");
await expect(expandedOnlineAccordionHeader).toBeVisible();
const accordionItem = expandedOnlineAccordionHeader
.locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
.first();
const accordionPanel = accordionItem
.locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
.first();
await page.clock.install({ time: new Date("2024-01-01T10:00:00Z") });
const pitrBtn = accordionPanel.getByTestId("pointInTimeRestore:PrimaryBtn");
await expect(pitrBtn).toBeVisible();
await pitrBtn.click();
page.context().on("page", async (newPage) => {
const expectedUrlEndPattern = new RegExp(
`/providers/Microsoft.(DocumentDB|DocumentDb)/databaseAccounts/${expectedSourceAccountName}/backupRestore`,
);
expect(newPage.url()).toMatch(expectedUrlEndPattern);
await newPage.close();
});
const loadingOverlay = frame.locator("[data-test='loading-overlay']");
await expect(loadingOverlay).toBeVisible();
const refreshBtn = accordionPanel.getByTestId("pointInTimeRestore:RefreshBtn");
await expect(refreshBtn).not.toBeVisible();
// Fast forward time by 11 minutes (11 * 60 * 1000ms = 660000ms)
await page.clock.fastForward(11 * 60 * 1000);
await expect(refreshBtn).toBeVisible();
await expect(pitrBtn).not.toBeVisible();
// Veify Popover & Loading Overlay on permission screen with API mocks and accordion interactions
await page.route(
`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/sqlRoleAssignments*`,
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
value: [
{
principalId: "00-11-22-33",
roleDefinitionId: `Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/77-88-99`,
},
],
}),
});
},
);
await page.route("**/Microsoft.DocumentDB/databaseAccounts/*/77-88-99**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
value: [
{
name: "00000000-0000-0000-0000-000000000001",
},
],
}),
});
});
await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${targetAccountName}**`, async (route) => {
const mockData = {
identity: {
type: "SystemAssigned",
principalId: "00-11-22-33",
},
properties: {
defaultIdentity: "SystemAssignedIdentity",
backupPolicy: {
type: "Continuous",
},
capabilities: [{ name: "EnableOnlineContainerCopy" }],
},
};
if (route.request().method() === "PATCH") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ status: "Succeeded" }),
});
} else if (route.request().method() === "GET") {
// Get the actual response and merge with mock data
const response = await route.fetch();
const actualData = await response.json();
const mergedData = { ...actualData };
set(mergedData, "identity", mockData.identity);
set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
set(mergedData, "properties.capabilities", mockData.properties.capabilities);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mergedData),
});
} else {
await route.continue();
}
});
await expect(permissionScreen).toBeVisible();
const expandedCrossAccordionHeader = permissionScreen
.getByTestId("permission-group-container-crossAccountConfigs")
.locator("button[aria-expanded='true']");
await expect(expandedCrossAccordionHeader).toBeVisible();
const crossAccordionItem = expandedCrossAccordionHeader
.locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
.first();
const crossAccordionPanel = crossAccordionItem
.locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
.first();
const toggleButton = crossAccordionPanel.getByTestId("btn-toggle");
await expect(toggleButton).toBeVisible();
await toggleButton.click();
const popover = frame.locator("[data-test='popover-container']");
await expect(popover).toBeVisible();
const yesButton = popover.getByRole("button", { name: /Yes/i });
const noButton = popover.getByRole("button", { name: /No/i });
await expect(yesButton).toBeVisible();
await expect(noButton).toBeVisible();
await yesButton.click();
await expect(loadingOverlay).toBeVisible();
await expect(loadingOverlay).toBeHidden({ timeout: 10 * 1000 });
await expect(popover).toBeHidden({ timeout: 10 * 1000 });
await panel.getByRole("button", { name: "Cancel" }).click();
});
test.afterAll("Container Copy - After All", async () => {
await page.unroute(/.*/, (route) => route.continue());
await page.close();
});
});

View File

@@ -1,7 +1,18 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, DocumentsTab, TestAccount } from "../fx";
import { retry, setPartitionKeys } from "../testData";
import { existsSync, mkdtempSync, rmdirSync, unlinkSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import { CommandBarButton, DataExplorer, DocumentsTab, ONE_MINUTE_MS, TestAccount } from "../fx";
import {
createTestSQLContainer,
itemsPerPartition,
partitionCount,
retry,
setPartitionKeys,
TestContainerContext,
TestData,
} from "../testData";
import { documentTestCases } from "./testCases";
let explorer: DataExplorer = null!;
@@ -95,3 +106,105 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) {
}
});
}
test.describe.serial("Upload Item", () => {
let context: TestContainerContext = null!;
let uploadDocumentDirPath: string = null!;
let uploadDocumentFilePath: string = null!;
test.beforeAll("Create Test database and open documents tab", async ({ browser }) => {
uploadDocumentDirPath = mkdtempSync(path.join(tmpdir(), "upload-document-"));
uploadDocumentFilePath = path.join(uploadDocumentDirPath, "uploadDocument.json");
const page = await browser.newPage();
context = await createTestSQLContainer();
explorer = await DataExplorer.open(page, TestAccount.SQL);
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
await containerNode.expand();
const containerMenuNode = await explorer.waitForContainerItemsNode(context.database.id, context.container.id);
await containerMenuNode.element.click();
// We need to click twice in order to remove a tooltip
await containerMenuNode.element.click();
});
test.afterAll("Delete Test Database and uploadDocument temp folder", async () => {
if (existsSync(uploadDocumentFilePath)) {
unlinkSync(uploadDocumentFilePath);
}
if (existsSync(uploadDocumentDirPath)) {
rmdirSync(uploadDocumentDirPath);
}
if (!process.env.CI) {
await context?.dispose();
}
});
test.afterEach("Close Upload Items panel if still open", async () => {
const closeUploadItemsPanelButton = explorer.frame.getByLabel("Close Upload Items");
if (await closeUploadItemsPanelButton.isVisible()) {
await closeUploadItemsPanelButton.click();
}
});
test("upload document", async () => {
// Create file to upload
const TestDataJsonString: string = JSON.stringify(TestData, null, 2);
writeFileSync(uploadDocumentFilePath, TestDataJsonString);
const uploadItemCommandBar = explorer.commandBarButton(CommandBarButton.UploadItem);
await uploadItemCommandBar.click();
// Select file to upload
await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath);
const uploadButton = explorer.frame.getByTestId("Panel/OkButton");
await uploadButton.click();
// Verify upload success message
const fileUploadStatusExpected: string = `${partitionCount * itemsPerPartition} created, 0 throttled, 0 errors`;
const fileUploadStatus = explorer.frame.getByTestId("file-upload-status");
await expect(fileUploadStatus).toContainText(fileUploadStatusExpected, {
timeout: ONE_MINUTE_MS,
});
// Select file to upload again
await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath);
await uploadButton.click();
// Verify upload failure message
const errorIcon = explorer.frame.getByRole("img", { name: "error" });
await expect(errorIcon).toBeVisible({ timeout: ONE_MINUTE_MS });
await expect(fileUploadStatus).toContainText(
`0 created, 0 throttled, ${partitionCount * itemsPerPartition} errors`,
{
timeout: ONE_MINUTE_MS,
},
);
});
test("upload invalid json", async () => {
// Create file to upload
let TestDataJsonString: string = JSON.stringify(TestData, null, 2);
// Remove the first '[' so that it becomes invalid json
TestDataJsonString = TestDataJsonString.substring(1);
writeFileSync(uploadDocumentFilePath, TestDataJsonString);
const uploadItemCommandBar = explorer.commandBarButton(CommandBarButton.UploadItem);
await uploadItemCommandBar.click();
// Select file to upload
await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath);
const uploadButton = explorer.frame.getByTestId("Panel/OkButton");
await uploadButton.click();
// Verify upload failure message
const fileUploadErrorList = explorer.frame.getByLabel("error list");
// The parsing error will show up differently in different browsers so just check for the word "JSON"
await expect(fileUploadErrorList).toContainText("JSON", {
timeout: ONE_MINUTE_MS,
});
});
});

View File

@@ -0,0 +1,145 @@
import { expect, test, type Page } from "@playwright/test";
import { CommandBarButton, DataExplorer, TestAccount } from "../fx";
import { createTestSQLContainer, TestContainerContext } from "../testData";
// Test container context for setup and cleanup
let testContainer: TestContainerContext;
let DATABASE_ID: string;
let CONTAINER_ID: string;
// Set up test database and container with data before all tests
test.beforeAll(async () => {
testContainer = await createTestSQLContainer(true);
DATABASE_ID = testContainer.database.id;
CONTAINER_ID = testContainer.container.id;
});
// Clean up test database after all tests
test.afterAll(async () => {
if (testContainer) {
await testContainer.dispose();
}
});
// Helper function to set up query tab and navigate to Index Advisor
async function setupIndexAdvisorTab(page: Page, customQuery?: string) {
const explorer = await DataExplorer.open(page, TestAccount.SQL);
const databaseNode = await explorer.waitForNode(DATABASE_ID);
await databaseNode.expand();
await page.waitForTimeout(2000);
const containerNode = await explorer.waitForNode(`${DATABASE_ID}/${CONTAINER_ID}`);
await containerNode.openContextMenu();
await containerNode.contextMenuItem("New SQL Query").click();
await page.waitForTimeout(2000);
const queryTab = explorer.queryTab("tab0");
const queryEditor = queryTab.editor();
await queryEditor.locator.waitFor({ timeout: 30 * 1000 });
await queryTab.executeCTA.waitFor();
if (customQuery) {
await queryEditor.locator.click();
await queryEditor.setText(customQuery);
}
const executeQueryButton = explorer.commandBarButton(CommandBarButton.ExecuteQuery);
await executeQueryButton.click();
await expect(queryTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
const indexAdvisorTab = queryTab.resultsView.getByTestId("QueryTab/ResultsPane/ResultsView/IndexAdvisorTab");
await indexAdvisorTab.click();
await page.waitForTimeout(2000);
return { explorer, queryTab, indexAdvisorTab };
}
test("Index Advisor tab loads without errors", async ({ page }) => {
const { indexAdvisorTab } = await setupIndexAdvisorTab(page);
await expect(indexAdvisorTab).toHaveAttribute("aria-selected", "true");
});
test("Verify UI sections are collapsible", async ({ page }) => {
const { explorer } = await setupIndexAdvisorTab(page);
// Verify both section headers exist
const includedHeader = explorer.frame.getByText("Included in Current Policy", { exact: true });
const notIncludedHeader = explorer.frame.getByText("Not Included in Current Policy", { exact: true });
await expect(includedHeader).toBeVisible();
await expect(notIncludedHeader).toBeVisible();
// Test collapsibility by checking if chevron/arrow icon changes state
// Both sections should be expandable/collapsible regardless of content
await includedHeader.click();
await page.waitForTimeout(300);
await includedHeader.click();
await page.waitForTimeout(300);
await notIncludedHeader.click();
await page.waitForTimeout(300);
await notIncludedHeader.click();
await page.waitForTimeout(300);
});
test("Verify SDK response structure - Case 1: Empty response", async ({ page }) => {
const { explorer } = await setupIndexAdvisorTab(page);
// Verify both section headers still exist even with no data
await expect(explorer.frame.getByText("Included in Current Policy", { exact: true })).toBeVisible();
await expect(explorer.frame.getByText("Not Included in Current Policy", { exact: true })).toBeVisible();
// Verify table headers
const table = explorer.frame.locator("table");
await expect(table.getByText("Index", { exact: true })).toBeVisible();
await expect(table.getByText("Estimated Impact", { exact: true })).toBeVisible();
// Verify "Update Indexing Policy" button is NOT visible when there are no potential indexes
const updateButton = explorer.frame.getByRole("button", { name: /Update Indexing Policy/i });
await expect(updateButton).not.toBeVisible();
});
test("Verify index suggestions and apply potential index", async ({ page }) => {
const customQuery = 'SELECT * FROM c WHERE c.partitionKey = "partition_1" ORDER BY c.randomData';
const { explorer } = await setupIndexAdvisorTab(page, customQuery);
// Wait for Index Advisor to process the query
await page.waitForTimeout(2000);
// Verify "Not Included in Current Policy" section has suggestions
const notIncludedHeader = explorer.frame.getByText("Not Included in Current Policy", { exact: true });
await expect(notIncludedHeader).toBeVisible();
// Find the checkbox for the suggested composite index
// The composite index should be /partitionKey ASC, /randomData ASC
const checkboxes = explorer.frame.locator('input[type="checkbox"]');
const checkboxCount = await checkboxes.count();
// Should have at least one checkbox for the potential index
expect(checkboxCount).toBeGreaterThan(0);
// Select the first checkbox (the high-impact composite index)
await checkboxes.first().check();
await page.waitForTimeout(500);
// Verify "Update Indexing Policy" button becomes visible
const updateButton = explorer.frame.getByRole("button", { name: /Update Indexing Policy/i });
await expect(updateButton).toBeVisible();
// Click the "Update Indexing Policy" button
await updateButton.click();
await page.waitForTimeout(1000);
// Verify success message appears
const successMessage = explorer.frame.getByText(/Your indexing policy has been updated with the new included paths/i);
await expect(successMessage).toBeVisible();
// Verify the message mentions reviewing changes in Scale & Settings
const reviewMessage = explorer.frame.getByText(/You may review the changes in Scale & Settings/i);
await expect(reviewMessage).toBeVisible();
// Verify the checkmark icon is shown
const checkmarkIcon = explorer.frame.locator('[data-icon-name="CheckMark"]');
await expect(checkmarkIcon).toBeVisible();
});

View File

@@ -1,100 +1,142 @@
// import { expect, test } from "@playwright/test";
// import { DataExplorer, TestAccount } from "../../fx";
// import { createTestSQLContainer, TestContainerContext } from "../../testData";
import { expect, test } from "@playwright/test";
import { DataExplorer, TestAccount } from "../../fx";
import { createTestSQLContainer, TestContainerContext } from "../../testData";
// test.describe("Change Partition Key", () => {
// let context: TestContainerContext = null!;
// let explorer: DataExplorer = null!;
// const newPartitionKeyPath = "newPartitionKey";
// const newContainerId = "testcontainer_1";
test.describe("Change Partition Key", () => {
let context: TestContainerContext = null!;
let explorer: DataExplorer = null!;
const newPartitionKeyPath = "newPartitionKey";
const newContainerId = "testcontainer_1";
let previousJobName: string | undefined;
// test.beforeAll("Create Test Database", async () => {
// context = await createTestSQLContainer();
// });
test.beforeAll("Create Test Database", async () => {
context = await createTestSQLContainer();
});
// test.beforeEach("Open container settings", async ({ page }) => {
// explorer = await DataExplorer.open(page, TestAccount.SQL);
test.beforeEach("Open container settings", async ({ page }) => {
explorer = await DataExplorer.open(page, TestAccount.SQL);
// // Click Scale & Settings and open Partition Key tab
// await explorer.openScaleAndSettings(context);
// const PartitionKeyTab = explorer.frame.getByTestId("settings-tab-header/PartitionKeyTab");
// await expect(PartitionKeyTab).toBeVisible();
// await PartitionKeyTab.click();
// });
// Click Scale & Settings and open Partition Key tab
await explorer.openScaleAndSettings(context);
const PartitionKeyTab = explorer.frame.getByTestId("settings-tab-header/PartitionKeyTab");
await expect(PartitionKeyTab).toBeVisible();
await PartitionKeyTab.click();
});
// // Delete database only if not running in CI
// if (!process.env.CI) {
// test.afterEach("Delete Test Database", async () => {
// await context?.dispose();
// });
// }
// Delete database only if not running in CI
if (!process.env.CI) {
test.afterEach("Delete Test Database", async () => {
await context?.dispose();
});
}
// test("Change partition key path", async () => {
// await expect(explorer.frame.getByText("/partitionKey")).toBeVisible();
// await expect(explorer.frame.getByText("Change partition key")).toBeVisible();
// await expect(explorer.frame.getByText(/To safeguard the integrity of/)).toBeVisible();
// await expect(explorer.frame.getByText(/To change the partition key/)).toBeVisible();
test("Change partition key path", async ({ page }) => {
await expect(explorer.frame.getByText("/partitionKey")).toBeVisible();
await expect(explorer.frame.getByText("Change partition key")).toBeVisible();
await expect(explorer.frame.getByText(/To safeguard the integrity of/)).toBeVisible();
await expect(explorer.frame.getByText(/To change the partition key/)).toBeVisible();
// const changePartitionKeyButton = explorer.frame.getByTestId("change-partition-key-button");
// expect(changePartitionKeyButton).toBeVisible();
// await changePartitionKeyButton.click();
const changePartitionKeyButton = explorer.frame.getByTestId("change-partition-key-button");
expect(changePartitionKeyButton).toBeVisible();
await changePartitionKeyButton.click();
// // Fill out new partition key form in the panel
// const changePkPanel = explorer.frame.getByTestId(`Panel:Change partition key`);
// await expect(changePkPanel.getByText(context.database.id)).toBeVisible();
// await expect(explorer.frame.getByRole("heading", { name: "Change partition key" })).toBeVisible();
// await expect(explorer.frame.getByText(/When changing a container/)).toBeVisible();
// Fill out new partition key form in the panel
const changePkPanel = explorer.frame.getByTestId(`Panel:Change partition key`);
await expect(changePkPanel.getByText(context.database.id)).toBeVisible();
await expect(explorer.frame.getByRole("heading", { name: "Change partition key" })).toBeVisible();
await expect(explorer.frame.getByText(/When changing a container/)).toBeVisible();
// // Try to switch to new container
// await expect(changePkPanel.getByText("New container")).toBeVisible();
// await expect(changePkPanel.getByText("Existing container")).toBeVisible();
// await expect(changePkPanel.getByTestId("new-container-id-input")).toBeVisible();
// Try to switch to new container
await expect(changePkPanel.getByText("New container")).toBeVisible();
await expect(changePkPanel.getByText("Existing container")).toBeVisible();
await expect(changePkPanel.getByTestId("new-container-id-input")).toBeVisible();
// changePkPanel.getByTestId("new-container-id-input").fill(newContainerId);
// await expect(changePkPanel.getByTestId("new-container-partition-key-input")).toBeVisible();
// changePkPanel.getByTestId("new-container-partition-key-input").fill(newPartitionKeyPath);
changePkPanel.getByTestId("new-container-id-input").fill(newContainerId);
await expect(changePkPanel.getByTestId("new-container-partition-key-input")).toBeVisible();
changePkPanel.getByTestId("new-container-partition-key-input").fill(newPartitionKeyPath);
// await expect(changePkPanel.getByTestId("add-sub-partition-key-button")).toBeVisible();
// changePkPanel.getByTestId("add-sub-partition-key-button").click();
// await expect(changePkPanel.getByTestId("new-container-sub-partition-key-input-0")).toBeVisible();
// await expect(changePkPanel.getByTestId("remove-sub-partition-key-button-0")).toBeVisible();
// await expect(changePkPanel.getByTestId("hierarchical-partitioning-info-text")).toBeVisible();
// await changePkPanel.getByTestId("remove-sub-partition-key-button-0").click();
await expect(changePkPanel.getByTestId("add-sub-partition-key-button")).toBeVisible();
changePkPanel.getByTestId("add-sub-partition-key-button").click();
await expect(changePkPanel.getByTestId("new-container-sub-partition-key-input-0")).toBeVisible();
await expect(changePkPanel.getByTestId("remove-sub-partition-key-button-0")).toBeVisible();
await expect(changePkPanel.getByTestId("hierarchical-partitioning-info-text")).toBeVisible();
await changePkPanel.getByTestId("remove-sub-partition-key-button-0").click();
// await changePkPanel.getByTestId("Panel/OkButton").click();
await changePkPanel.getByTestId("Panel/OkButton").click();
// await expect(changePkPanel).not.toBeVisible({ timeout: 5 * 60 * 1000 });
let jobName: string | undefined;
await page.waitForRequest(
(req) => {
const requestUrl = req.url();
if (requestUrl.includes("/dataTransferJobs") && req.method() === "PUT") {
jobName = new URL(requestUrl).pathname.split("/").pop();
return true;
}
return false;
},
{ timeout: 120000 },
);
// // Verify partition key change job
// const jobText = explorer.frame.getByText(/Partition key change job/);
// await expect(jobText).toBeVisible();
// await expect(explorer.frame.locator(".ms-ProgressIndicator-itemName")).toContainText("Portal_testcontainer_1");
await expect(changePkPanel).not.toBeVisible({ timeout: 5 * 60 * 1000 });
// const jobRow = explorer.frame.locator(".ms-ProgressIndicator-itemDescription");
// // await expect(jobRow.getByText("Pending")).toBeVisible({ timeout: 30 * 1000 });
// await expect(jobRow.getByText("Completed")).toBeVisible({ timeout: 5 * 60 * 1000 });
// Verify partition key change job
const jobText = explorer.frame.getByText(/Partition key change job/);
await expect(jobText).toBeVisible();
// await expect(explorer.frame.locator(".ms-ProgressIndicator-itemName")).toContainText("Portal_testcontainer_1");
await expect(explorer.frame.locator(".ms-ProgressIndicator-itemName")).toContainText(jobName!);
// const newContainerNode = await explorer.waitForContainerNode(context.database.id, newContainerId);
// expect(newContainerNode).not.toBeNull();
const jobRow = explorer.frame.locator(".ms-ProgressIndicator-itemDescription");
// await expect(jobRow.getByText("Pending")).toBeVisible({ timeout: 30 * 1000 });
await expect(jobRow.getByText("Completed")).toBeVisible({ timeout: 5 * 60 * 1000 });
// // Now try to switch to existing container
// await changePartitionKeyButton.click();
// await changePkPanel.getByText("Existing container").click();
// await changePkPanel.getByLabel("Use existing container").check();
// await changePkPanel.getByText("Choose an existing container").click();
const newContainerNode = await explorer.waitForContainerNode(context.database.id, newContainerId);
expect(newContainerNode).not.toBeNull();
// const containerDropdownItem = await explorer.getDropdownItemByName(newContainerId, "Existing Containers");
// await containerDropdownItem.click();
// Now try to switch to existing container
// Ensure this job name is different from the previously processed job name
previousJobName = jobName;
// await changePkPanel.getByTestId("Panel/OkButton").click();
// await explorer.frame.getByRole("button", { name: "Cancel" }).click();
await changePartitionKeyButton.click();
await changePkPanel.getByText("Existing container").click();
await changePkPanel.getByLabel("Use existing container").check();
await changePkPanel.getByText("Choose an existing container").click();
// // Dismiss overlay if it appears
// const overlayFrame = explorer.frame.locator("#webpack-dev-server-client-overlay").first();
// if (await overlayFrame.count()) {
// await overlayFrame.contentFrame().getByLabel("Dismiss").click();
// }
// const cancelledJobRow = explorer.frame.getByTestId("Tab:tab0");
// await expect(cancelledJobRow.getByText("Cancelled")).toBeVisible({ timeout: 30 * 1000 });
// });
// });
const containerDropdownItem = await explorer.getDropdownItemByName(newContainerId, "Existing Containers");
await containerDropdownItem.click();
let secondJobName: string | undefined;
await Promise.all([
page.waitForRequest(
(req) => {
const requestUrl = req.url();
if (requestUrl.includes("/dataTransferJobs") && req.method() === "PUT") {
secondJobName = new URL(requestUrl).pathname.split("/").pop();
return true;
}
return false;
},
{ timeout: 120000 },
),
changePkPanel.getByTestId("Panel/OkButton").click(),
]);
const cancelButton = explorer.frame.getByRole("button", { name: "Cancel" });
const isCancelButtonVisible = await cancelButton.isVisible().catch(() => false);
if (isCancelButtonVisible) {
await cancelButton.click();
// Dismiss overlay if it appears
const overlayFrame = explorer.frame.locator("#webpack-dev-server-client-overlay").first();
if (await overlayFrame.count()) {
await overlayFrame.contentFrame().getByLabel("Dismiss").click();
}
const cancelledJobRow = explorer.frame.getByTestId("Tab:tab0");
await expect(cancelledJobRow.getByText("Cancelled")).toBeVisible({ timeout: 30 * 1000 });
} else {
const jobRow = explorer.frame.locator(".ms-ProgressIndicator-itemDescription");
await expect(jobRow.getByText("Completed")).toBeVisible({ timeout: 5 * 60 * 1000 });
expect(secondJobName).not.toBe(previousJobName);
}
});
});

View File

@@ -0,0 +1,108 @@
import { expect, Page, test } from "@playwright/test";
import * as DataModels from "../../../src/Contracts/DataModels";
import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx";
import { createTestSQLContainer, TestContainerContext } from "../../testData";
test.describe("Computed Properties", () => {
let context: TestContainerContext = null!;
let explorer: DataExplorer = null!;
test.beforeAll("Create Test Database", async () => {
context = await createTestSQLContainer();
});
test.beforeEach("Open Settings tab under Scale & Settings", async ({ page }) => {
explorer = await DataExplorer.open(page, TestAccount.SQL);
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
await containerNode.expand();
// Click Scale & Settings and open Settings tab
await explorer.openScaleAndSettings(context);
const computedPropertiesTab = explorer.frame.getByTestId("settings-tab-header/ComputedPropertiesTab");
await computedPropertiesTab.click();
});
test.afterAll("Delete Test Database", async () => {
await context?.dispose();
});
test("Add valid computed property", async ({ page }) => {
await clearComputedPropertiesTextBoxContent({ page });
// Create computed property
const computedProperties: DataModels.ComputedProperties = [
{
name: "cp_lowerName",
query: "SELECT VALUE LOWER(c.name) FROM c",
},
];
const computedPropertiesString: string = JSON.stringify(computedProperties);
await page.keyboard.type(computedPropertiesString);
// Save changes
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
await expect(saveButton).toBeEnabled();
await saveButton.click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(
`Successfully updated container ${context.container.id}`,
{
timeout: ONE_MINUTE_MS,
},
);
});
test("Add computed property with invalid query", async ({ page }) => {
await clearComputedPropertiesTextBoxContent({ page });
// Create computed property with no VALUE keyword in query
const computedProperties: DataModels.ComputedProperties = [
{
name: "cp_lowerName",
query: "SELECT LOWER(c.name) FROM c",
},
];
const computedPropertiesString: string = JSON.stringify(computedProperties);
await page.keyboard.type(computedPropertiesString);
// Save changes
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
await expect(saveButton).toBeEnabled();
await saveButton.click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(
`Failed to update container ${context.container.id}`,
{
timeout: ONE_MINUTE_MS,
},
);
});
test("Add computed property with invalid json", async ({ page }) => {
await clearComputedPropertiesTextBoxContent({ page });
// Create computed property with no VALUE keyword in query
const computedProperties: DataModels.ComputedProperties = [
{
name: "cp_lowerName",
query: "SELECT LOWER(c.name) FROM c",
},
];
const computedPropertiesString: string = JSON.stringify(computedProperties);
await page.keyboard.type(computedPropertiesString + "]");
// Save button should remain disabled due to invalid json
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
await expect(saveButton).toBeDisabled();
});
const clearComputedPropertiesTextBoxContent = async ({ page }: { page: Page }): Promise<void> => {
// Get computed properties text box
await explorer.frame.waitForSelector(".monaco-scrollable-element", { state: "visible" });
const computedPropertiesEditor = explorer.frame.getByTestId("computed-properties-editor");
await computedPropertiesEditor.click();
// Clear existing content (Ctrl+A + Backspace does not work with webkit)
for (let i = 0; i < 100; i++) {
await page.keyboard.press("Backspace");
}
};
});

View File

@@ -11,7 +11,7 @@ test.describe("Settings under Scale & Settings", () => {
const page = await browser.newPage();
explorer = await DataExplorer.open(page, TestAccount.SQL);
// Click Scale & Settings and open Scale tab
// Click Scale & Settings and open Settings tab
await explorer.openScaleAndSettings(context);
const settingsTab = explorer.frame.getByTestId("settings-tab-header/SubSettingsTab");
await settingsTab.click();
@@ -53,4 +53,28 @@ test.describe("Settings under Scale & Settings", () => {
},
);
});
test("Set Geospatial Config to Geometry then Geography", async () => {
const geometryRadioButton = explorer.frame.getByRole("radio", { name: "geometry-option" });
await geometryRadioButton.click();
await explorer.commandBarButton(CommandBarButton.Save).click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(
`Successfully updated container ${context.container.id}`,
{
timeout: ONE_MINUTE_MS,
},
);
const geographyRadioButton = explorer.frame.getByRole("radio", { name: "geography-option" });
await geographyRadioButton.click();
await explorer.commandBarButton(CommandBarButton.Save).click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(
`Successfully updated container ${context.container.id}`,
{
timeout: ONE_MINUTE_MS,
},
);
});
});

View File

@@ -0,0 +1,78 @@
import { expect, test } from "@playwright/test";
import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx";
import { createTestSQLContainer, TestContainerContext } from "../../testData";
test.describe("Stored Procedures", () => {
let context: TestContainerContext = null!;
let explorer: DataExplorer = null!;
test.beforeAll("Create Test Database", async () => {
context = await createTestSQLContainer();
});
test.beforeEach("Open container", async ({ page }) => {
explorer = await DataExplorer.open(page, TestAccount.SQL);
});
test.afterAll("Delete Test Database", async () => {
await context?.dispose();
});
test("Add, execute, and delete stored procedure", async ({ page }, testInfo) => {
void page;
// Open container context menu and click New Stored Procedure
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
await containerNode.openContextMenu();
await containerNode.contextMenuItem("New Stored Procedure").click();
// Type stored procedure id and use stock procedure
const storedProcedureIdTextBox = explorer.frame.getByLabel("Stored procedure id");
await storedProcedureIdTextBox.isVisible();
const storedProcedureName = `stored-procedure-${testInfo.testId}`;
await storedProcedureIdTextBox.fill(storedProcedureName);
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
await expect(saveButton).toBeEnabled();
await saveButton.click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(
`Successfully created stored procedure ${storedProcedureName}`,
{
timeout: 2 * ONE_MINUTE_MS,
},
);
// Execute stored procedure
const executeButton = explorer.commandBarButton(CommandBarButton.Execute);
await executeButton.click();
const executeSidePanelButton = explorer.frame.getByTestId("Panel/OkButton");
await executeSidePanelButton.click();
const executeStoredProcedureResult = explorer.frame.getByLabel("Execute stored procedure result");
await expect(executeStoredProcedureResult).toBeAttached({
timeout: ONE_MINUTE_MS,
});
// Delete stored procedure
await containerNode.expand();
const storedProceduresNode = await explorer.waitForNode(
`${context.database.id}/${context.container.id}/Stored Procedures`,
);
await storedProceduresNode.expand();
const storedProcedureNode = await explorer.waitForNode(
`${context.database.id}/${context.container.id}/Stored Procedures/${storedProcedureName}`,
);
await storedProcedureNode.openContextMenu();
await storedProcedureNode.contextMenuItem("Delete Stored Procedure").click();
const deleteStoredProcedureButton = explorer.frame.getByTestId("DialogButton:Delete");
await deleteStoredProcedureButton.click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(
`Successfully deleted stored procedure ${storedProcedureName}`,
{
timeout: ONE_MINUTE_MS,
},
);
});
});

View File

@@ -0,0 +1,80 @@
import { expect, test } from "@playwright/test";
import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx";
import { createTestSQLContainer, TestContainerContext } from "../../testData";
test.describe("Triggers", () => {
let context: TestContainerContext = null!;
let explorer: DataExplorer = null!;
const triggerBody = `function validateToDoItemTimestamp() {
var context = getContext();
var request = context.getRequest();
var itemToCreate = request.getBody();
if (!("timestamp" in itemToCreate)) {
var ts = new Date();
itemToCreate["timestamp"] = ts.getTime();
}
request.setBody(itemToCreate);
}`;
test.beforeAll("Create Test Database", async () => {
context = await createTestSQLContainer();
});
test.beforeEach("Open container", async ({ page }) => {
explorer = await DataExplorer.open(page, TestAccount.SQL);
});
if (!process.env.CI) {
test.afterAll("Delete Test Database", async () => {
await context?.dispose();
});
}
test("Add and delete trigger", async ({ page }, testInfo) => {
// Open container context menu and click New Trigger
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
await containerNode.openContextMenu();
await containerNode.contextMenuItem("New Trigger").click();
// Assign Trigger id
const triggerIdTextBox = explorer.frame.getByLabel("Trigger Id");
const triggerId: string = `validateItemTimestamp-${testInfo.testId}`;
await triggerIdTextBox.fill(triggerId);
// Create Trigger body that validates item timestamp
const triggerBodyTextArea = explorer.frame.getByTestId("EditorReact/Host/Loaded");
await triggerBodyTextArea.click();
// Clear existing content
const isMac: boolean = process.platform === "darwin";
await page.keyboard.press(isMac ? "Meta+A" : "Control+A");
await page.keyboard.press("Backspace");
await page.keyboard.type(triggerBody);
// Save changes
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
await saveButton.click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(`Successfully created trigger ${triggerId}`, {
timeout: 2 * ONE_MINUTE_MS,
});
// Delete Trigger
await containerNode.expand();
const triggersNode = await explorer.waitForNode(`${context.database.id}/${context.container.id}/Triggers`);
await triggersNode.expand();
const triggerNode = await explorer.waitForNode(
`${context.database.id}/${context.container.id}/Triggers/${triggerId}`,
);
await triggerNode.openContextMenu();
await triggerNode.contextMenuItem("Delete Trigger").click();
const deleteTriggerButton = explorer.frame.getByTestId("DialogButton:Delete");
await deleteTriggerButton.click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(`Successfully deleted trigger ${triggerId}`, {
timeout: ONE_MINUTE_MS,
});
});
});

View File

@@ -0,0 +1,82 @@
import { expect, test } from "@playwright/test";
import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx";
import { createTestSQLContainer, TestContainerContext } from "../../testData";
test.describe("User Defined Functions", () => {
let context: TestContainerContext = null!;
let explorer: DataExplorer = null!;
const udfBody: string = `function extractDocumentId(doc) {
return {
id: doc.id
};
}`;
test.beforeAll("Create Test Database", async () => {
context = await createTestSQLContainer();
});
test.beforeEach("Open container", async ({ page }) => {
explorer = await DataExplorer.open(page, TestAccount.SQL);
});
if (!process.env.CI) {
test.afterAll("Delete Test Database", async () => {
await context?.dispose();
});
}
test("Add, execute, and delete user defined function", async ({ page }, testInfo) => {
// Open container context menu and click New UDF
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
await containerNode.openContextMenu();
await containerNode.contextMenuItem("New UDF").click();
// Assign UDF id
const udfIdTextBox = explorer.frame.getByLabel("User Defined Function Id");
const udfId: string = `extractDocumentId-${testInfo.testId}`;
await udfIdTextBox.fill(udfId);
// Create UDF body that extracts the document id from a document
const udfBodyTextArea = explorer.frame.getByTestId("EditorReact/Host/Loaded");
await udfBodyTextArea.click();
// Clear existing content
const isMac: boolean = process.platform === "darwin";
await page.keyboard.press(isMac ? "Meta+A" : "Control+A");
await page.keyboard.press("Backspace");
await page.keyboard.type(udfBody);
// Save changes
const saveButton = explorer.commandBarButton(CommandBarButton.Save);
await expect(saveButton).toBeEnabled();
await saveButton.click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(
`Successfully created user defined function ${udfId}`,
{
timeout: 2 * ONE_MINUTE_MS,
},
);
// Delete UDF
await containerNode.expand();
const udfsNode = await explorer.waitForNode(
`${context.database.id}/${context.container.id}/User Defined Functions`,
);
await udfsNode.expand();
const udfNode = await explorer.waitForNode(
`${context.database.id}/${context.container.id}/User Defined Functions/${udfId}`,
);
await udfNode.openContextMenu();
await udfNode.contextMenuItem("Delete User Defined Function").click();
const deleteUserDefinedFunctionButton = explorer.frame.getByTestId("DialogButton:Delete");
await deleteUserDefinedFunctionButton.click();
await expect(explorer.getConsoleHeaderStatus()).toContainText(
`Successfully deleted user defined function ${udfId}`,
{
timeout: ONE_MINUTE_MS,
},
);
});
});

View File

@@ -37,27 +37,35 @@ export interface PartitionKey {
value: string | null;
}
const partitionCount = 4;
export const partitionCount = 4;
// If we increase this number, we need to split bulk creates into multiple batches.
// Bulk operations are limited to 100 items per partition.
const itemsPerPartition = 100;
export const itemsPerPartition = 100;
function createTestItems(): TestItem[] {
const items: TestItem[] = [];
for (let i = 0; i < partitionCount; i++) {
for (let j = 0; j < itemsPerPartition; j++) {
const id = crypto.randomBytes(32).toString("base64");
const id = createSafeRandomString(32);
items.push({
id,
partitionKey: `partition_${i}`,
randomData: crypto.randomBytes(32).toString("base64"),
randomData: createSafeRandomString(32),
});
}
}
return items;
}
// Document IDs cannot contain '/', '\', or '#'
function createSafeRandomString(byteLength: number): string {
return crypto
.randomBytes(byteLength)
.toString("base64")
.replace(/[/\\#]/g, "_");
}
export const TestData: TestItem[] = createTestItems();
export class TestContainerContext {
@@ -80,6 +88,69 @@ type createTestSqlContainerConfig = {
databaseName?: string;
};
type createMultipleTestSqlContainerConfig = {
containerCount?: number;
partitionKey?: string;
databaseName?: string;
accountType: TestAccount.SQLContainerCopyOnly | TestAccount.SQL;
};
export async function createMultipleTestContainers({
partitionKey = "/partitionKey",
databaseName = "",
containerCount = 1,
accountType = TestAccount.SQL,
}: createMultipleTestSqlContainerConfig): Promise<TestContainerContext[]> {
const creationPromises: Promise<TestContainerContext>[] = [];
const databaseId = databaseName ? databaseName : generateUniqueName("db");
const credentials = getAzureCLICredentials();
const adaptedCredentials = new AzureIdentityCredentialAdapter(credentials);
const armClient = new CosmosDBManagementClient(adaptedCredentials, subscriptionId);
const accountName = getAccountName(accountType);
const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
const clientOptions: CosmosClientOptions = {
endpoint: account.documentEndpoint!,
};
const rbacToken =
accountType === TestAccount.SQL
? process.env.NOSQL_TESTACCOUNT_TOKEN
: accountType === TestAccount.SQLContainerCopyOnly
? process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN
: "";
if (rbacToken) {
clientOptions.tokenProvider = async (): Promise<string> => {
const AUTH_PREFIX = `type=aad&ver=1.0&sig=`;
const authorizationToken = `${AUTH_PREFIX}${rbacToken}`;
return authorizationToken;
};
} else {
const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);
clientOptions.key = keys.primaryMasterKey;
}
const client = new CosmosClient(clientOptions);
const { database } = await client.databases.createIfNotExists({ id: databaseId });
try {
for (let i = 0; i < containerCount; i++) {
const containerId = `testcontainer_${Date.now()}_${Math.random().toString(36).substring(6)}_${i}`;
creationPromises.push(
database.containers.createIfNotExists({ id: containerId, partitionKey }).then(({ container }) => {
return new TestContainerContext(armClient, client, database, container, new Map<string, TestItem>());
}),
);
}
const contexts = await Promise.all(creationPromises);
return contexts;
} catch (e) {
await database.delete();
throw e;
}
}
export async function createTestSQLContainer({
includeTestData = false,
partitionKey = "/partitionKey",

View File

@@ -11,8 +11,12 @@ const accountName = urlSearchParams.get("accountName") || "portal-sql-runner-wes
const selfServeType = urlSearchParams.get("selfServeType") || "example";
const iframeSrc = urlSearchParams.get("iframeSrc") || "explorer.html?platform=Portal&disablePortalInitCache";
const authToken = urlSearchParams.get("token");
const enablecontainercopy = urlSearchParams.get("enablecontainercopy");
const nosqlRbacToken = urlSearchParams.get("nosqlRbacToken") || process.env.NOSQL_TESTACCOUNT_TOKEN || "";
const nosqlRbacToken =
urlSearchParams.get("nosqlRbacToken") ||
(enablecontainercopy ? process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN : process.env.NOSQL_TESTACCOUNT_TOKEN) ||
"";
const nosqlReadOnlyRbacToken =
urlSearchParams.get("nosqlReadOnlyRbacToken") || process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN || "";
const tableRbacToken = urlSearchParams.get("tableRbacToken") || process.env.TABLE_TESTACCOUNT_TOKEN || "";
@@ -83,6 +87,7 @@ const initTestExplorer = async (): Promise<void> => {
authorizationToken: `Bearer ${authToken}`,
aadToken: rbacToken,
features: {},
containerCopyEnabled: enablecontainercopy === "true",
hasWriteAccess: true,
csmEndpoint: "https://management.azure.com",
dnsSuffix: "documents.azure.com",