mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-01-07 19:46:53 +00:00
Compare commits
49 Commits
user/bchou
...
refresh-ar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f39a5cf8eb | ||
|
|
60987c963c | ||
|
|
faee622bef | ||
|
|
4a7c8a7c69 | ||
|
|
c41ecb97c2 | ||
|
|
36b6db15e8 | ||
|
|
86e6780560 | ||
|
|
1f54577201 | ||
|
|
a37772a794 | ||
|
|
b0a35be391 | ||
|
|
6286d3cb35 | ||
|
|
129ffc57d8 | ||
|
|
f77666b86c | ||
|
|
312bcb8e04 | ||
|
|
163d25dfd9 | ||
|
|
a3a2bf2e3a | ||
|
|
6830081a3a | ||
|
|
55ec2f8f46 | ||
|
|
d85bdad152 | ||
|
|
d1ba9b7bce | ||
|
|
d510ce408e | ||
|
|
3949a0ecce | ||
|
|
f2044f2054 | ||
|
|
dbc8dd7cd7 | ||
|
|
6bafdaf54d | ||
|
|
5a80826aca | ||
|
|
fa3391bde2 | ||
|
|
9c868b29c9 | ||
|
|
e164568aef | ||
|
|
77668896d4 | ||
|
|
c2b3330e4f | ||
|
|
253a85efea | ||
|
|
f8a69aaea4 | ||
|
|
3dd958982a | ||
|
|
920adb4197 | ||
|
|
fe56da0cd2 | ||
|
|
867d9d5df9 | ||
|
|
c0b5bf4fd4 | ||
|
|
dfc0b5ff99 | ||
|
|
5ff3c9109c | ||
|
|
c7454d406e | ||
|
|
baedb86665 | ||
|
|
9008dd2ce4 | ||
|
|
03eea3b0c2 | ||
|
|
c9cd5ffdde | ||
|
|
ab1f515613 | ||
|
|
d7130b4332 | ||
|
|
88bcde8f2a | ||
|
|
dbae89c7e5 |
@@ -128,7 +128,7 @@
|
||||
@provisionDatabaseThroughputInfo: 200px;
|
||||
|
||||
//tabs container
|
||||
@ActiveTabHeight: 32px;
|
||||
@ActiveTabHeight: 31px;
|
||||
@ActiveTabWidth: 141px;
|
||||
@TabsHeight: 30px;
|
||||
@TabsWidth: 140px;
|
||||
|
||||
@@ -2643,7 +2643,7 @@ a:link {
|
||||
|
||||
.tabPanesContainer {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
overflow-y: scroll;
|
||||
background-color: var(--colorNeutralBackground1);
|
||||
color: var(--colorNeutralForeground1);
|
||||
}
|
||||
@@ -2651,7 +2651,6 @@ a:link {
|
||||
.tabs-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.paddingspan4 {
|
||||
@@ -2678,7 +2677,7 @@ a:link {
|
||||
width: @ActiveTabWidth;
|
||||
}
|
||||
|
||||
.nav-tabs > li.active .contentWrapper .tabNavText {
|
||||
.nav-tabs > li.active .contentWrapper {
|
||||
border-bottom: 2px solid var(--colorCompoundBrandBackground);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ html {
|
||||
body {
|
||||
font-family: @FabricFont;
|
||||
background-color: #f5f5f5;
|
||||
--colorCompoundBrandBackground: @FabricAccentMedium;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -42,7 +41,7 @@ a:focus {
|
||||
}
|
||||
|
||||
.nav-tabs-margin {
|
||||
padding-top: 0px;
|
||||
padding-top: 5px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
@@ -69,20 +68,17 @@ a:focus {
|
||||
}
|
||||
|
||||
.nav-tabs > li > .tabNavContentContainer > .tab_Content:hover {
|
||||
border-bottom: none;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.nav-tabs > li.active > .tabNavContentContainer > .tab_Content,
|
||||
.nav-tabs > li.active > .tabNavContentContainer > .tab_Content:hover {
|
||||
border-bottom: none;
|
||||
border-bottom: 2px solid @FabricAccentMedium;
|
||||
}
|
||||
|
||||
.nav-tabs > li.active > .tabNavContentContainer > .tab_Content > .contentWrapper > .tabNavText {
|
||||
border-bottom: 0px none transparent;
|
||||
}
|
||||
.nav-tabs > li.active .contentWrapper .tabNavText {
|
||||
border-bottom: 2px solid @FabricAccentMedium;
|
||||
}
|
||||
|
||||
.tabNavContentContainer {
|
||||
padding: @SmallSpace 0px @SmallSpace 0px;
|
||||
@@ -218,7 +214,6 @@ a:focus {
|
||||
|
||||
.tabPanesContainer {
|
||||
overflow: auto !important;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
|
||||
@@ -13,7 +13,6 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading, label }) =>
|
||||
|
||||
return (
|
||||
<Overlay
|
||||
data-test="loading-overlay"
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: "rgba(255,255,255,0.9)",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
exports[`LoadingOverlay should handle long labels properly 1`] = `
|
||||
<div
|
||||
class="ms-Overlay root-109"
|
||||
data-test="loading-overlay"
|
||||
>
|
||||
<div
|
||||
class="ms-Spinner root-111"
|
||||
@@ -23,7 +22,6 @@ exports[`LoadingOverlay should handle long labels properly 1`] = `
|
||||
exports[`LoadingOverlay should render loading overlay when isLoading is true 1`] = `
|
||||
<div
|
||||
class="ms-Overlay root-109"
|
||||
data-test="loading-overlay"
|
||||
>
|
||||
<div
|
||||
class="ms-Spinner root-111"
|
||||
@@ -43,7 +41,6 @@ exports[`LoadingOverlay should render loading overlay when isLoading is true 1`]
|
||||
exports[`LoadingOverlay should render loading overlay with custom label 1`] = `
|
||||
<div
|
||||
class="ms-Overlay root-109"
|
||||
data-test="loading-overlay"
|
||||
>
|
||||
<div
|
||||
class="ms-Spinner root-111"
|
||||
@@ -63,7 +60,6 @@ exports[`LoadingOverlay should render loading overlay with custom label 1`] = `
|
||||
exports[`LoadingOverlay should render loading overlay with empty label 1`] = `
|
||||
<div
|
||||
class="ms-Overlay root-109"
|
||||
data-test="loading-overlay"
|
||||
>
|
||||
<div
|
||||
class="ms-Spinner root-111"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { configContext } from "ConfigContext";
|
||||
import { ApiType, userContext } from "UserContext";
|
||||
import * as NotificationConsoleUtils from "Utils/NotificationConsoleUtils";
|
||||
import {
|
||||
@@ -15,12 +14,9 @@ import {
|
||||
DataTransferJobFeedResults,
|
||||
DataTransferJobGetResults,
|
||||
} from "Utils/arm/generatedClients/dataTransferService/types";
|
||||
import { armRequest } from "Utils/arm/request";
|
||||
import { addToPolling, removeFromPolling, updateDataTransferJob, useDataTransferJobs } from "hooks/useDataTransferJobs";
|
||||
import promiseRetry, { AbortError, FailedAttemptError } from "p-retry";
|
||||
|
||||
export const DATA_TRANSFER_JOB_API_VERSION = "2025-05-01-preview";
|
||||
|
||||
export interface DataTransferParams {
|
||||
jobName: string;
|
||||
apiType: ApiType;
|
||||
@@ -37,34 +33,26 @@ export const getDataTransferJobs = async (
|
||||
subscriptionId: string,
|
||||
resourceGroup: string,
|
||||
accountName: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DataTransferJobGetResults[]> => {
|
||||
let dataTransferJobs: DataTransferJobGetResults[] = [];
|
||||
let dataTransferFeeds: DataTransferJobFeedResults = await listByDatabaseAccount(
|
||||
subscriptionId,
|
||||
resourceGroup,
|
||||
accountName,
|
||||
signal,
|
||||
);
|
||||
dataTransferJobs = [...dataTransferJobs, ...(dataTransferFeeds?.value || [])];
|
||||
while (dataTransferFeeds?.nextLink) {
|
||||
/**
|
||||
* The `nextLink` URL returned by the Cosmos DB SQL API pointed to an incorrect endpoint, causing timeouts.
|
||||
* (i.e: https://cdbmgmtprodby.documents.azure.com:450/subscriptions/{subId}/resourceGroups/{rg}/providers/Microsoft.DocumentDB/databaseAccounts/{account}/sql/dataTransferJobs?$top=100&$skiptoken=...)
|
||||
* We manipulate the URL by parsing it to extract the path and query parameters,
|
||||
* then construct the correct URL for the Azure Resource Manager (ARM) API.
|
||||
* This ensures that the request is made to the correct base URL (`configContext.ARM_ENDPOINT`),
|
||||
* which is required for ARM operations.
|
||||
*/
|
||||
const parsedUrl = new URL(dataTransferFeeds.nextLink);
|
||||
const nextUrlPath = parsedUrl.pathname + parsedUrl.search;
|
||||
dataTransferFeeds = await armRequest({
|
||||
host: configContext.ARM_ENDPOINT,
|
||||
path: nextUrlPath,
|
||||
method: "GET",
|
||||
apiVersion: DATA_TRANSFER_JOB_API_VERSION,
|
||||
const nextResponse = await window.fetch(dataTransferFeeds.nextLink, {
|
||||
headers: {
|
||||
Authorization: userContext.authorizationToken,
|
||||
},
|
||||
});
|
||||
dataTransferJobs.push(...(dataTransferFeeds?.value || []));
|
||||
if (nextResponse.ok) {
|
||||
dataTransferFeeds = await nextResponse.json();
|
||||
dataTransferJobs = [...dataTransferJobs, ...(dataTransferFeeds?.value || [])];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return dataTransferJobs;
|
||||
};
|
||||
|
||||
@@ -25,18 +25,7 @@ export default {
|
||||
subscriptionDropdownPlaceholder: "Select a subscription",
|
||||
sourceAccountDropdownLabel: "Account",
|
||||
sourceAccountDropdownPlaceholder: "Select an account",
|
||||
migrationTypeOptions: {
|
||||
offline: {
|
||||
title: "Offline mode",
|
||||
description:
|
||||
"Offline container copy jobs let you copy data from a source container to a destination Cosmos DB container for supported APIs. To ensure data integrity between the source and destination, we recommend stopping updates on the source container before creating the copy job. Learn more about [offline copy jobs](https://learn.microsoft.com/azure/cosmos-db/how-to-container-copy?tabs=offline-copy&pivots=api-nosql).",
|
||||
},
|
||||
online: {
|
||||
title: "Online mode",
|
||||
description:
|
||||
"Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the [All Versions and Delete](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about [online copy jobs](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started).",
|
||||
},
|
||||
},
|
||||
migrationTypeCheckboxLabel: "Copy container in offline mode",
|
||||
|
||||
// Select Source and Target Containers Screen
|
||||
selectSourceAndTargetContainersDescription:
|
||||
|
||||
@@ -35,7 +35,6 @@ const AddManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
|
||||
<InfoTooltip content={managedIdentityTooltip} />
|
||||
</Text>
|
||||
<Toggle
|
||||
data-test="btn-toggle"
|
||||
checked={systemAssigned}
|
||||
onText={ContainerCopyMessages.toggleBtn.onText}
|
||||
offText={ContainerCopyMessages.toggleBtn.offText}
|
||||
|
||||
@@ -65,7 +65,6 @@ const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIde
|
||||
<InfoTooltip content={TooltipContent} />
|
||||
</Text>
|
||||
<Toggle
|
||||
data-test="btn-toggle"
|
||||
checked={readPermissionAssigned}
|
||||
onText={ContainerCopyMessages.toggleBtn.onText}
|
||||
offText={ContainerCopyMessages.toggleBtn.offText}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useCopyJobPrerequisitesCache } from "../../Utils/useCopyJobPrerequisite
|
||||
import usePermissionSections, { PermissionGroupConfig, PermissionSectionConfig } from "./hooks/usePermissionsSection";
|
||||
|
||||
const PermissionSection: React.FC<PermissionSectionConfig> = ({ id, title, Component, completed, disabled }) => (
|
||||
<AccordionItem key={id} value={id} disabled={disabled} data-test="accordion-item">
|
||||
<AccordionItem key={id} value={id} disabled={disabled}>
|
||||
<AccordionHeader className="accordionHeader">
|
||||
<Text className="accordionHeaderText" variant="medium">
|
||||
{title}
|
||||
@@ -25,13 +25,13 @@ const PermissionSection: React.FC<PermissionSectionConfig> = ({ id, title, Compo
|
||||
height={completed ? 20 : 24}
|
||||
/>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel aria-disabled={disabled} className="accordionPanel" data-test="accordion-panel">
|
||||
<AccordionPanel aria-disabled={disabled} className="accordionPanel">
|
||||
<Component />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
);
|
||||
|
||||
const PermissionGroup: React.FC<PermissionGroupConfig> = ({ id, title, description, sections }) => {
|
||||
const PermissionGroup: React.FC<PermissionGroupConfig> = ({ title, description, sections }) => {
|
||||
const [openItems, setOpenItems] = React.useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,7 +44,6 @@ const PermissionGroup: React.FC<PermissionGroupConfig> = ({ id, title, descripti
|
||||
|
||||
return (
|
||||
<Stack
|
||||
data-test={`permission-group-container-${id}`}
|
||||
tokens={{ childrenGap: 15 }}
|
||||
styles={{
|
||||
root: {
|
||||
@@ -100,11 +99,7 @@ const AssignPermissions = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
data-test="Panel:AssignPermissionsContainer"
|
||||
className="assignPermissionsContainer"
|
||||
tokens={{ childrenGap: 20 }}
|
||||
>
|
||||
<Stack className="assignPermissionsContainer" tokens={{ childrenGap: 20 }}>
|
||||
<Text variant="medium">
|
||||
{isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online
|
||||
? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription(
|
||||
|
||||
@@ -31,7 +31,6 @@ const DefaultManagedIdentity: React.FC<AddManagedIdentityProps> = () => {
|
||||
<InfoTooltip content={managedIdentityTooltip} />
|
||||
</div>
|
||||
<Toggle
|
||||
data-test="btn-toggle"
|
||||
checked={defaultSystemAssigned}
|
||||
onText={ContainerCopyMessages.toggleBtn.onText}
|
||||
offText={ContainerCopyMessages.toggleBtn.offText}
|
||||
|
||||
@@ -127,7 +127,6 @@ const PointInTimeRestore: React.FC = () => {
|
||||
<Stack.Item>
|
||||
{showRefreshButton ? (
|
||||
<PrimaryButton
|
||||
data-test="pointInTimeRestore:RefreshBtn"
|
||||
className="fullWidth"
|
||||
text={ContainerCopyMessages.refreshButtonLabel}
|
||||
iconProps={{ iconName: "Refresh" }}
|
||||
@@ -135,7 +134,6 @@ const PointInTimeRestore: React.FC = () => {
|
||||
/>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
data-test="pointInTimeRestore:PrimaryBtn"
|
||||
className="fullWidth"
|
||||
text={loading ? "" : ContainerCopyMessages.pointInTimeRestore.buttonText}
|
||||
{...(loading ? { iconProps: { iconName: "SyncStatusSolid" } } : {})}
|
||||
|
||||
@@ -67,7 +67,6 @@ exports[`AddManagedIdentity Snapshot Tests renders initial state correctly 1`] =
|
||||
class="ms-Toggle-background pill-117"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="btn-toggle"
|
||||
id="Toggle1"
|
||||
role="switch"
|
||||
type="button"
|
||||
@@ -155,7 +154,6 @@ exports[`AddManagedIdentity Snapshot Tests renders loading state 1`] = `
|
||||
class="ms-Toggle-background pill-121"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="btn-toggle"
|
||||
id="Toggle11"
|
||||
role="switch"
|
||||
type="button"
|
||||
@@ -175,12 +173,10 @@ exports[`AddManagedIdentity Snapshot Tests renders loading state 1`] = `
|
||||
</div>
|
||||
<div
|
||||
class="ms-Stack popover-container foreground loading css-123"
|
||||
data-test="popover-container"
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<div
|
||||
class="ms-Overlay root-135"
|
||||
data-test="loading-overlay"
|
||||
>
|
||||
<div
|
||||
class="ms-Spinner root-137"
|
||||
@@ -327,7 +323,6 @@ exports[`AddManagedIdentity Snapshot Tests renders with toggle on and popover vi
|
||||
class="ms-Toggle-background pill-121"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="btn-toggle"
|
||||
id="Toggle3"
|
||||
role="switch"
|
||||
type="button"
|
||||
@@ -347,7 +342,6 @@ exports[`AddManagedIdentity Snapshot Tests renders with toggle on and popover vi
|
||||
</div>
|
||||
<div
|
||||
class="ms-Stack popover-container foreground css-123"
|
||||
data-test="popover-container"
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -41,7 +41,6 @@ exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle m
|
||||
class="ms-Toggle-background pill-115"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="btn-toggle"
|
||||
id="Toggle17"
|
||||
role="switch"
|
||||
type="button"
|
||||
@@ -104,7 +103,6 @@ exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle m
|
||||
class="ms-Toggle-background pill-115"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="btn-toggle"
|
||||
id="Toggle16"
|
||||
role="switch"
|
||||
type="button"
|
||||
@@ -167,7 +165,6 @@ exports[`AddReadPermissionToDefaultIdentity Component Rendering should render co
|
||||
class="ms-Toggle-background pill-115"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="btn-toggle"
|
||||
id="Toggle3"
|
||||
role="switch"
|
||||
type="button"
|
||||
@@ -230,7 +227,6 @@ exports[`AddReadPermissionToDefaultIdentity Component Rendering should render co
|
||||
class="ms-Toggle-background pill-119"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="btn-toggle"
|
||||
id="Toggle1"
|
||||
role="switch"
|
||||
type="button"
|
||||
@@ -318,7 +314,6 @@ exports[`AddReadPermissionToDefaultIdentity Component Rendering should render co
|
||||
class="ms-Toggle-background pill-115"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="btn-toggle"
|
||||
id="Toggle0"
|
||||
role="switch"
|
||||
type="button"
|
||||
@@ -381,7 +376,6 @@ exports[`AddReadPermissionToDefaultIdentity Component Rendering should render co
|
||||
class="ms-Toggle-background pill-115"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="btn-toggle"
|
||||
id="Toggle2"
|
||||
role="switch"
|
||||
type="button"
|
||||
|
||||
@@ -4,7 +4,6 @@ exports[`AssignPermissions Component Accordion Behavior should render accordion
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack assignPermissionsContainer css-109"
|
||||
data-test="Panel:AssignPermissionsContainer"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
@@ -16,7 +15,6 @@ exports[`AssignPermissions Component Accordion Behavior should render accordion
|
||||
>
|
||||
<div
|
||||
class="ms-Stack css-112"
|
||||
data-test="permission-group-container-testGroup"
|
||||
>
|
||||
<div
|
||||
class="ms-Stack css-113"
|
||||
@@ -38,7 +36,6 @@ exports[`AssignPermissions Component Accordion Behavior should render accordion
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionItem"
|
||||
data-test="accordion-item"
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionHeader accordionHeader ___kex8dp0_1udlp87 f19n0e5 f1c21dwh f1s184ao ft85np5"
|
||||
@@ -88,7 +85,6 @@ exports[`AssignPermissions Component Accordion Behavior should render accordion
|
||||
</div>
|
||||
<div
|
||||
class="fui-AccordionItem"
|
||||
data-test="accordion-item"
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionHeader accordionHeader ___kex8dp0_1udlp87 f19n0e5 f1c21dwh f1s184ao ft85np5"
|
||||
@@ -138,7 +134,6 @@ exports[`AssignPermissions Component Accordion Behavior should render accordion
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class="fui-AccordionPanel accordionPanel ___1rufncu_1hx1scr f1axvtxu"
|
||||
data-test="accordion-panel"
|
||||
>
|
||||
<div>
|
||||
Incomplete Component
|
||||
@@ -147,7 +142,6 @@ exports[`AssignPermissions Component Accordion Behavior should render accordion
|
||||
</div>
|
||||
<div
|
||||
class="fui-AccordionItem"
|
||||
data-test="accordion-item"
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionHeader accordionHeader ___lyghz50_53x5ri0 f1s2aq7o f1c21dwh f1s184ao ft85np5 fwrgwhw"
|
||||
@@ -207,7 +201,6 @@ exports[`AssignPermissions Component Edge Cases should calculate correct indent
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack assignPermissionsContainer css-109"
|
||||
data-test="Panel:AssignPermissionsContainer"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
@@ -219,7 +212,6 @@ exports[`AssignPermissions Component Edge Cases should calculate correct indent
|
||||
>
|
||||
<div
|
||||
class="ms-Stack css-112"
|
||||
data-test="permission-group-container-testGroup"
|
||||
>
|
||||
<div
|
||||
class="ms-Stack css-113"
|
||||
@@ -241,7 +233,6 @@ exports[`AssignPermissions Component Edge Cases should calculate correct indent
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionItem"
|
||||
data-test="accordion-item"
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionHeader accordionHeader ___kex8dp0_1udlp87 f19n0e5 f1c21dwh f1s184ao ft85np5"
|
||||
@@ -291,7 +282,6 @@ exports[`AssignPermissions Component Edge Cases should calculate correct indent
|
||||
</div>
|
||||
<div
|
||||
class="fui-AccordionItem"
|
||||
data-test="accordion-item"
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionHeader accordionHeader ___kex8dp0_1udlp87 f19n0e5 f1c21dwh f1s184ao ft85np5"
|
||||
@@ -341,7 +331,6 @@ exports[`AssignPermissions Component Edge Cases should calculate correct indent
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class="fui-AccordionPanel accordionPanel ___1rufncu_1hx1scr f1axvtxu"
|
||||
data-test="accordion-panel"
|
||||
>
|
||||
<div>
|
||||
Incomplete Component
|
||||
@@ -350,7 +339,6 @@ exports[`AssignPermissions Component Edge Cases should calculate correct indent
|
||||
</div>
|
||||
<div
|
||||
class="fui-AccordionItem"
|
||||
data-test="accordion-item"
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionHeader accordionHeader ___lyghz50_53x5ri0 f1s2aq7o f1c21dwh f1s184ao ft85np5 fwrgwhw"
|
||||
@@ -410,7 +398,6 @@ exports[`AssignPermissions Component Edge Cases should calculate correct indent
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack assignPermissionsContainer css-109"
|
||||
data-test="Panel:AssignPermissionsContainer"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
@@ -422,7 +409,6 @@ exports[`AssignPermissions Component Edge Cases should calculate correct indent
|
||||
>
|
||||
<div
|
||||
class="ms-Stack css-112"
|
||||
data-test="permission-group-container-testGroup"
|
||||
>
|
||||
<div
|
||||
class="ms-Stack css-113"
|
||||
@@ -444,7 +430,6 @@ exports[`AssignPermissions Component Edge Cases should calculate correct indent
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionItem"
|
||||
data-test="accordion-item"
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionHeader accordionHeader ___kex8dp0_1udlp87 f19n0e5 f1c21dwh f1s184ao ft85np5"
|
||||
@@ -494,7 +479,6 @@ exports[`AssignPermissions Component Edge Cases should calculate correct indent
|
||||
</div>
|
||||
<div
|
||||
class="fui-AccordionItem"
|
||||
data-test="accordion-item"
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionHeader accordionHeader ___kex8dp0_1udlp87 f19n0e5 f1c21dwh f1s184ao ft85np5"
|
||||
@@ -544,7 +528,6 @@ exports[`AssignPermissions Component Edge Cases should calculate correct indent
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class="fui-AccordionPanel accordionPanel ___1rufncu_1hx1scr f1axvtxu"
|
||||
data-test="accordion-panel"
|
||||
>
|
||||
<div>
|
||||
Incomplete Component
|
||||
@@ -553,7 +536,6 @@ exports[`AssignPermissions Component Edge Cases should calculate correct indent
|
||||
</div>
|
||||
<div
|
||||
class="fui-AccordionItem"
|
||||
data-test="accordion-item"
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionHeader accordionHeader ___lyghz50_53x5ri0 f1s2aq7o f1c21dwh f1s184ao ft85np5 fwrgwhw"
|
||||
@@ -613,7 +595,6 @@ exports[`AssignPermissions Component Edge Cases should handle missing account na
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack assignPermissionsContainer css-109"
|
||||
data-test="Panel:AssignPermissionsContainer"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
@@ -625,7 +606,6 @@ exports[`AssignPermissions Component Edge Cases should handle missing account na
|
||||
>
|
||||
<div
|
||||
class="ms-Stack css-112"
|
||||
data-test="permission-group-container-testGroup"
|
||||
>
|
||||
<div
|
||||
class="ms-Stack css-113"
|
||||
@@ -647,7 +627,6 @@ exports[`AssignPermissions Component Edge Cases should handle missing account na
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionItem"
|
||||
data-test="accordion-item"
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionHeader accordionHeader ___kex8dp0_1udlp87 f19n0e5 f1c21dwh f1s184ao ft85np5"
|
||||
@@ -697,7 +676,6 @@ exports[`AssignPermissions Component Edge Cases should handle missing account na
|
||||
</div>
|
||||
<div
|
||||
class="fui-AccordionItem"
|
||||
data-test="accordion-item"
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionHeader accordionHeader ___kex8dp0_1udlp87 f19n0e5 f1c21dwh f1s184ao ft85np5"
|
||||
@@ -747,7 +725,6 @@ exports[`AssignPermissions Component Edge Cases should handle missing account na
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class="fui-AccordionPanel accordionPanel ___1rufncu_1hx1scr f1axvtxu"
|
||||
data-test="accordion-panel"
|
||||
>
|
||||
<div>
|
||||
Incomplete Component
|
||||
@@ -756,7 +733,6 @@ exports[`AssignPermissions Component Edge Cases should handle missing account na
|
||||
</div>
|
||||
<div
|
||||
class="fui-AccordionItem"
|
||||
data-test="accordion-item"
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionHeader accordionHeader ___lyghz50_53x5ri0 f1s2aq7o f1c21dwh f1s184ao ft85np5 fwrgwhw"
|
||||
@@ -816,7 +792,6 @@ exports[`AssignPermissions Component Permission Groups should render multiple pe
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack assignPermissionsContainer css-109"
|
||||
data-test="Panel:AssignPermissionsContainer"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
@@ -828,7 +803,6 @@ exports[`AssignPermissions Component Permission Groups should render multiple pe
|
||||
>
|
||||
<div
|
||||
class="ms-Stack css-112"
|
||||
data-test="permission-group-container-crossAccountConfigs"
|
||||
>
|
||||
<div
|
||||
class="ms-Stack css-113"
|
||||
@@ -850,7 +824,6 @@ exports[`AssignPermissions Component Permission Groups should render multiple pe
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionItem"
|
||||
data-test="accordion-item"
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionHeader accordionHeader ___kex8dp0_1udlp87 f19n0e5 f1c21dwh f1s184ao ft85np5"
|
||||
@@ -902,7 +875,6 @@ exports[`AssignPermissions Component Permission Groups should render multiple pe
|
||||
</div>
|
||||
<div
|
||||
class="ms-Stack css-112"
|
||||
data-test="permission-group-container-onlineConfigs"
|
||||
>
|
||||
<div
|
||||
class="ms-Stack css-113"
|
||||
@@ -924,7 +896,6 @@ exports[`AssignPermissions Component Permission Groups should render multiple pe
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionItem"
|
||||
data-test="accordion-item"
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionHeader accordionHeader ___kex8dp0_1udlp87 f19n0e5 f1c21dwh f1s184ao ft85np5"
|
||||
@@ -974,7 +945,6 @@ exports[`AssignPermissions Component Permission Groups should render multiple pe
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class="fui-AccordionPanel accordionPanel ___1rufncu_1hx1scr f1axvtxu"
|
||||
data-test="accordion-panel"
|
||||
>
|
||||
<div
|
||||
data-testid="online-copy-enabled"
|
||||
@@ -994,7 +964,6 @@ exports[`AssignPermissions Component Permission Groups should render online migr
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack assignPermissionsContainer css-109"
|
||||
data-test="Panel:AssignPermissionsContainer"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
@@ -1006,7 +975,6 @@ exports[`AssignPermissions Component Permission Groups should render online migr
|
||||
>
|
||||
<div
|
||||
class="ms-Stack css-112"
|
||||
data-test="permission-group-container-onlineConfigs"
|
||||
>
|
||||
<div
|
||||
class="ms-Stack css-113"
|
||||
@@ -1028,7 +996,6 @@ exports[`AssignPermissions Component Permission Groups should render online migr
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionItem"
|
||||
data-test="accordion-item"
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionHeader accordionHeader ___kex8dp0_1udlp87 f19n0e5 f1c21dwh f1s184ao ft85np5"
|
||||
@@ -1078,7 +1045,6 @@ exports[`AssignPermissions Component Permission Groups should render online migr
|
||||
</div>
|
||||
<div
|
||||
class="fui-AccordionItem"
|
||||
data-test="accordion-item"
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionHeader accordionHeader ___kex8dp0_1udlp87 f19n0e5 f1c21dwh f1s184ao ft85np5"
|
||||
@@ -1128,7 +1094,6 @@ exports[`AssignPermissions Component Permission Groups should render online migr
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class="fui-AccordionPanel accordionPanel ___1rufncu_1hx1scr f1axvtxu"
|
||||
data-test="accordion-panel"
|
||||
>
|
||||
<div
|
||||
data-testid="online-copy-enabled"
|
||||
@@ -1148,7 +1113,6 @@ exports[`AssignPermissions Component Permission Groups should render permission
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack assignPermissionsContainer css-109"
|
||||
data-test="Panel:AssignPermissionsContainer"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
@@ -1160,7 +1124,6 @@ exports[`AssignPermissions Component Permission Groups should render permission
|
||||
>
|
||||
<div
|
||||
class="ms-Stack css-112"
|
||||
data-test="permission-group-container-crossAccountConfigs"
|
||||
>
|
||||
<div
|
||||
class="ms-Stack css-113"
|
||||
@@ -1182,7 +1145,6 @@ exports[`AssignPermissions Component Permission Groups should render permission
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionItem"
|
||||
data-test="accordion-item"
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionHeader accordionHeader ___kex8dp0_1udlp87 f19n0e5 f1c21dwh f1s184ao ft85np5"
|
||||
@@ -1232,7 +1194,6 @@ exports[`AssignPermissions Component Permission Groups should render permission
|
||||
</div>
|
||||
<div
|
||||
class="fui-AccordionItem"
|
||||
data-test="accordion-item"
|
||||
>
|
||||
<div
|
||||
class="fui-AccordionHeader accordionHeader ___kex8dp0_1udlp87 f19n0e5 f1c21dwh f1s184ao ft85np5"
|
||||
@@ -1282,7 +1243,6 @@ exports[`AssignPermissions Component Permission Groups should render permission
|
||||
<div
|
||||
aria-disabled="false"
|
||||
class="fui-AccordionPanel accordionPanel ___1rufncu_1hx1scr f1axvtxu"
|
||||
data-test="accordion-panel"
|
||||
>
|
||||
<div
|
||||
data-testid="add-read-permission"
|
||||
@@ -1302,7 +1262,6 @@ exports[`AssignPermissions Component Rendering should render without crashing wi
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack assignPermissionsContainer css-109"
|
||||
data-test="Panel:AssignPermissionsContainer"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
@@ -1324,7 +1283,6 @@ exports[`AssignPermissions Component Rendering should render without crashing wi
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack assignPermissionsContainer css-109"
|
||||
data-test="Panel:AssignPermissionsContainer"
|
||||
>
|
||||
<span
|
||||
class="css-110"
|
||||
|
||||
@@ -41,7 +41,6 @@ exports[`DefaultManagedIdentity Edge Cases should handle missing account name gr
|
||||
class="ms-Toggle-background pill-115"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="btn-toggle"
|
||||
id="Toggle14"
|
||||
role="switch"
|
||||
type="button"
|
||||
@@ -104,7 +103,6 @@ exports[`DefaultManagedIdentity Edge Cases should handle null account 1`] = `
|
||||
class="ms-Toggle-background pill-115"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="btn-toggle"
|
||||
id="Toggle15"
|
||||
role="switch"
|
||||
type="button"
|
||||
@@ -167,7 +165,6 @@ exports[`DefaultManagedIdentity Loading States should render loading state snaps
|
||||
class="ms-Toggle-background pill-119"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="btn-toggle"
|
||||
id="Toggle10"
|
||||
role="switch"
|
||||
type="button"
|
||||
@@ -259,7 +256,6 @@ exports[`DefaultManagedIdentity Rendering should render correctly with default s
|
||||
class="ms-Toggle-background pill-115"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="btn-toggle"
|
||||
id="Toggle0"
|
||||
role="switch"
|
||||
type="button"
|
||||
@@ -322,7 +318,6 @@ exports[`DefaultManagedIdentity Toggle Interactions should render toggle with ch
|
||||
class="ms-Toggle-background pill-119"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="btn-toggle"
|
||||
id="Toggle7"
|
||||
role="switch"
|
||||
type="button"
|
||||
|
||||
@@ -56,7 +56,6 @@ exports[`PointInTimeRestore Initial Render should render correctly with default
|
||||
<button
|
||||
class="ms-Button ms-Button--primary fullWidth root-115"
|
||||
data-is-focusable="true"
|
||||
data-test="pointInTimeRestore:PrimaryBtn"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
@@ -132,7 +131,6 @@ exports[`PointInTimeRestore Initial Render should render with empty account name
|
||||
<button
|
||||
class="ms-Button ms-Button--primary fullWidth root-115"
|
||||
data-is-focusable="true"
|
||||
data-test="pointInTimeRestore:PrimaryBtn"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
@@ -163,7 +161,6 @@ exports[`PointInTimeRestore Snapshots should match snapshot in loading state 1`]
|
||||
>
|
||||
<div
|
||||
class="ms-Overlay root-123"
|
||||
data-test="loading-overlay"
|
||||
>
|
||||
<div
|
||||
class="ms-Spinner root-125"
|
||||
@@ -226,7 +223,6 @@ exports[`PointInTimeRestore Snapshots should match snapshot in loading state 1`]
|
||||
aria-disabled="true"
|
||||
class="ms-Button ms-Button--primary is-disabled fullWidth root-128"
|
||||
data-is-focusable="false"
|
||||
data-test="pointInTimeRestore:PrimaryBtn"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
@@ -305,7 +301,6 @@ exports[`PointInTimeRestore Snapshots should match snapshot with refresh button
|
||||
<button
|
||||
class="ms-Button ms-Button--primary fullWidth root-115"
|
||||
data-is-focusable="true"
|
||||
data-test="pointInTimeRestore:RefreshBtn"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -19,21 +19,9 @@ const NavigationControls: React.FC<NavigationControlsProps> = ({
|
||||
isPreviousDisabled,
|
||||
}) => (
|
||||
<Stack horizontal tokens={{ childrenGap: 20 }}>
|
||||
<PrimaryButton
|
||||
data-test="copy-job-primary"
|
||||
text={primaryBtnText}
|
||||
onClick={onPrimary}
|
||||
allowDisabledFocus
|
||||
disabled={isPrimaryDisabled}
|
||||
/>
|
||||
<DefaultButton
|
||||
data-test="copy-job-previous"
|
||||
text="Previous"
|
||||
onClick={onPrevious}
|
||||
allowDisabledFocus
|
||||
disabled={isPreviousDisabled}
|
||||
/>
|
||||
<DefaultButton data-test="copy-job-cancel" text="Cancel" onClick={onCancel} />
|
||||
<PrimaryButton text={primaryBtnText} onClick={onPrimary} allowDisabledFocus disabled={isPrimaryDisabled} />
|
||||
<DefaultButton text="Previous" onClick={onPrevious} allowDisabledFocus disabled={isPreviousDisabled} />
|
||||
<DefaultButton text="Cancel" onClick={onCancel} />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ const PopoverContainer: React.FC<PopoverContainerProps> = React.memo(
|
||||
({ isLoading = false, title, children, onPrimary, onCancel }) => {
|
||||
return (
|
||||
<Stack
|
||||
data-test="popover-container"
|
||||
className={`popover-container foreground ${isLoading ? "loading" : ""}`}
|
||||
tokens={{ childrenGap: 20 }}
|
||||
style={{ maxWidth: 450 }}
|
||||
|
||||
@@ -4,7 +4,6 @@ exports[`PopoverMessage Component Edge Cases should handle empty string title 1`
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack popover-container foreground css-109"
|
||||
data-test="popover-container"
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<span
|
||||
@@ -72,7 +71,6 @@ exports[`PopoverMessage Component Edge Cases should handle null children 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack popover-container foreground css-109"
|
||||
data-test="popover-container"
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<span
|
||||
@@ -135,7 +133,6 @@ exports[`PopoverMessage Component Edge Cases should handle undefined children 1`
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack popover-container foreground css-109"
|
||||
data-test="popover-container"
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<span
|
||||
@@ -198,7 +195,6 @@ exports[`PopoverMessage Component Edge Cases should handle very long title 1`] =
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack popover-container foreground css-109"
|
||||
data-test="popover-container"
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<span
|
||||
@@ -270,7 +266,6 @@ exports[`PopoverMessage Component Rendering should render correctly when visible
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack popover-container foreground css-109"
|
||||
data-test="popover-container"
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<span
|
||||
@@ -340,7 +335,6 @@ exports[`PopoverMessage Component Rendering should render correctly with differe
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack popover-container foreground css-109"
|
||||
data-test="popover-container"
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<span
|
||||
@@ -415,7 +409,6 @@ exports[`PopoverMessage Component Rendering should render correctly with differe
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack popover-container foreground css-109"
|
||||
data-test="popover-container"
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<span
|
||||
@@ -485,7 +478,6 @@ exports[`PopoverMessage Component Rendering should render correctly with loading
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack popover-container foreground loading css-109"
|
||||
data-test="popover-container"
|
||||
style="max-width: 450px;"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -22,7 +22,6 @@ const CreateCopyJobScreens: React.FC = () => {
|
||||
<Stack.Item className="createCopyJobScreensContent">
|
||||
{contextError && (
|
||||
<MessageBar
|
||||
data-test="Panel:ErrorContainer"
|
||||
className="createCopyJobErrorMessageBar"
|
||||
messageBarType={MessageBarType.blocked}
|
||||
isMultiline={false}
|
||||
|
||||
@@ -31,17 +31,17 @@ const PreviewCopyJob: React.FC = () => {
|
||||
}));
|
||||
};
|
||||
return (
|
||||
<Stack tokens={{ childrenGap: 20 }} className="previewCopyJobContainer" data-test="Panel:PreviewCopyJob">
|
||||
<Stack tokens={{ childrenGap: 20 }} className="previewCopyJobContainer">
|
||||
<FieldRow label={ContainerCopyMessages.jobNameLabel}>
|
||||
<TextField data-test="job-name-textfield" value={jobName} onChange={onJobNameChange} />
|
||||
<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>{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>{copyJobState.source?.account?.name}</Text>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<DetailsList
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
exports[`PreviewCopyJob should handle special characters in database and container names 1`] = `
|
||||
<div
|
||||
class="ms-Stack previewCopyJobContainer css-109"
|
||||
data-test="Panel:PreviewCopyJob"
|
||||
>
|
||||
<div
|
||||
class="ms-Stack flex-row css-110"
|
||||
@@ -33,7 +32,6 @@ exports[`PreviewCopyJob should handle special characters in database and contain
|
||||
<input
|
||||
aria-invalid="false"
|
||||
class="ms-TextField-field field-115"
|
||||
data-test="job-name-textfield"
|
||||
id="TextField84"
|
||||
type="text"
|
||||
value="job-with@special#chars_123"
|
||||
@@ -53,7 +51,6 @@ exports[`PreviewCopyJob should handle special characters in database and contain
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
data-test="source-subscription-name"
|
||||
>
|
||||
Test Subscription
|
||||
</span>
|
||||
@@ -68,7 +65,6 @@ exports[`PreviewCopyJob should handle special characters in database and contain
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
data-test="source-account-name"
|
||||
>
|
||||
test-account
|
||||
</span>
|
||||
@@ -325,7 +321,6 @@ exports[`PreviewCopyJob should handle special characters in database and contain
|
||||
exports[`PreviewCopyJob should render component with cross-subscription setup 1`] = `
|
||||
<div
|
||||
class="ms-Stack previewCopyJobContainer css-109"
|
||||
data-test="Panel:PreviewCopyJob"
|
||||
>
|
||||
<div
|
||||
class="ms-Stack flex-row css-110"
|
||||
@@ -355,7 +350,6 @@ exports[`PreviewCopyJob should render component with cross-subscription setup 1`
|
||||
<input
|
||||
aria-invalid="false"
|
||||
class="ms-TextField-field field-115"
|
||||
data-test="job-name-textfield"
|
||||
id="TextField96"
|
||||
type="text"
|
||||
value=""
|
||||
@@ -375,7 +369,6 @@ exports[`PreviewCopyJob should render component with cross-subscription setup 1`
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
data-test="source-subscription-name"
|
||||
>
|
||||
Test Subscription
|
||||
</span>
|
||||
@@ -390,7 +383,6 @@ exports[`PreviewCopyJob should render component with cross-subscription setup 1`
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
data-test="source-account-name"
|
||||
>
|
||||
test-account
|
||||
</span>
|
||||
@@ -647,7 +639,6 @@ exports[`PreviewCopyJob should render component with cross-subscription setup 1`
|
||||
exports[`PreviewCopyJob should render with default state and empty job name 1`] = `
|
||||
<div
|
||||
class="ms-Stack previewCopyJobContainer css-109"
|
||||
data-test="Panel:PreviewCopyJob"
|
||||
>
|
||||
<div
|
||||
class="ms-Stack flex-row css-110"
|
||||
@@ -677,7 +668,6 @@ exports[`PreviewCopyJob should render with default state and empty job name 1`]
|
||||
<input
|
||||
aria-invalid="false"
|
||||
class="ms-TextField-field field-115"
|
||||
data-test="job-name-textfield"
|
||||
id="TextField0"
|
||||
type="text"
|
||||
value=""
|
||||
@@ -697,7 +687,6 @@ exports[`PreviewCopyJob should render with default state and empty job name 1`]
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
data-test="source-subscription-name"
|
||||
>
|
||||
Test Subscription
|
||||
</span>
|
||||
@@ -712,7 +701,6 @@ exports[`PreviewCopyJob should render with default state and empty job name 1`]
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
data-test="source-account-name"
|
||||
>
|
||||
test-account
|
||||
</span>
|
||||
@@ -969,7 +957,6 @@ exports[`PreviewCopyJob should render with default state and empty job name 1`]
|
||||
exports[`PreviewCopyJob should render with long subscription and account names 1`] = `
|
||||
<div
|
||||
class="ms-Stack previewCopyJobContainer css-109"
|
||||
data-test="Panel:PreviewCopyJob"
|
||||
>
|
||||
<div
|
||||
class="ms-Stack flex-row css-110"
|
||||
@@ -999,7 +986,6 @@ exports[`PreviewCopyJob should render with long subscription and account names 1
|
||||
<input
|
||||
aria-invalid="false"
|
||||
class="ms-TextField-field field-115"
|
||||
data-test="job-name-textfield"
|
||||
id="TextField60"
|
||||
type="text"
|
||||
value=""
|
||||
@@ -1019,7 +1005,6 @@ exports[`PreviewCopyJob should render with long subscription and account names 1
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
data-test="source-subscription-name"
|
||||
>
|
||||
This is a very long subscription name that might cause display issues if not handled properly
|
||||
</span>
|
||||
@@ -1034,7 +1019,6 @@ exports[`PreviewCopyJob should render with long subscription and account names 1
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
data-test="source-account-name"
|
||||
>
|
||||
this-is-a-very-long-database-account-name-that-might-cause-display-issues
|
||||
</span>
|
||||
@@ -1291,7 +1275,6 @@ exports[`PreviewCopyJob should render with long subscription and account names 1
|
||||
exports[`PreviewCopyJob should render with missing source account information 1`] = `
|
||||
<div
|
||||
class="ms-Stack previewCopyJobContainer css-109"
|
||||
data-test="Panel:PreviewCopyJob"
|
||||
>
|
||||
<div
|
||||
class="ms-Stack flex-row css-110"
|
||||
@@ -1321,7 +1304,6 @@ exports[`PreviewCopyJob should render with missing source account information 1`
|
||||
<input
|
||||
aria-invalid="false"
|
||||
class="ms-TextField-field field-115"
|
||||
data-test="job-name-textfield"
|
||||
id="TextField36"
|
||||
type="text"
|
||||
value=""
|
||||
@@ -1341,7 +1323,6 @@ exports[`PreviewCopyJob should render with missing source account information 1`
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
data-test="source-subscription-name"
|
||||
>
|
||||
Test Subscription
|
||||
</span>
|
||||
@@ -1607,7 +1588,6 @@ exports[`PreviewCopyJob should render with missing source account information 1`
|
||||
exports[`PreviewCopyJob should render with missing source subscription information 1`] = `
|
||||
<div
|
||||
class="ms-Stack previewCopyJobContainer css-109"
|
||||
data-test="Panel:PreviewCopyJob"
|
||||
>
|
||||
<div
|
||||
class="ms-Stack flex-row css-110"
|
||||
@@ -1637,7 +1617,6 @@ exports[`PreviewCopyJob should render with missing source subscription informati
|
||||
<input
|
||||
aria-invalid="false"
|
||||
class="ms-TextField-field field-115"
|
||||
data-test="job-name-textfield"
|
||||
id="TextField24"
|
||||
type="text"
|
||||
value=""
|
||||
@@ -1666,7 +1645,6 @@ exports[`PreviewCopyJob should render with missing source subscription informati
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
data-test="source-account-name"
|
||||
>
|
||||
test-account
|
||||
</span>
|
||||
@@ -1923,7 +1901,6 @@ exports[`PreviewCopyJob should render with missing source subscription informati
|
||||
exports[`PreviewCopyJob should render with online migration type 1`] = `
|
||||
<div
|
||||
class="ms-Stack previewCopyJobContainer css-109"
|
||||
data-test="Panel:PreviewCopyJob"
|
||||
>
|
||||
<div
|
||||
class="ms-Stack flex-row css-110"
|
||||
@@ -1953,7 +1930,6 @@ exports[`PreviewCopyJob should render with online migration type 1`] = `
|
||||
<input
|
||||
aria-invalid="false"
|
||||
class="ms-TextField-field field-115"
|
||||
data-test="job-name-textfield"
|
||||
id="TextField72"
|
||||
type="text"
|
||||
value="online-migration-job"
|
||||
@@ -1973,7 +1949,6 @@ exports[`PreviewCopyJob should render with online migration type 1`] = `
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
data-test="source-subscription-name"
|
||||
>
|
||||
Test Subscription
|
||||
</span>
|
||||
@@ -1988,7 +1963,6 @@ exports[`PreviewCopyJob should render with online migration type 1`] = `
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
data-test="source-account-name"
|
||||
>
|
||||
test-account
|
||||
</span>
|
||||
@@ -2245,7 +2219,6 @@ exports[`PreviewCopyJob should render with online migration type 1`] = `
|
||||
exports[`PreviewCopyJob should render with pre-filled job name 1`] = `
|
||||
<div
|
||||
class="ms-Stack previewCopyJobContainer css-109"
|
||||
data-test="Panel:PreviewCopyJob"
|
||||
>
|
||||
<div
|
||||
class="ms-Stack flex-row css-110"
|
||||
@@ -2275,7 +2248,6 @@ exports[`PreviewCopyJob should render with pre-filled job name 1`] = `
|
||||
<input
|
||||
aria-invalid="false"
|
||||
class="ms-TextField-field field-115"
|
||||
data-test="job-name-textfield"
|
||||
id="TextField12"
|
||||
type="text"
|
||||
value="custom-job-name-123"
|
||||
@@ -2295,7 +2267,6 @@ exports[`PreviewCopyJob should render with pre-filled job name 1`] = `
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
data-test="source-subscription-name"
|
||||
>
|
||||
Test Subscription
|
||||
</span>
|
||||
@@ -2310,7 +2281,6 @@ exports[`PreviewCopyJob should render with pre-filled job name 1`] = `
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
data-test="source-account-name"
|
||||
>
|
||||
test-account
|
||||
</span>
|
||||
@@ -2567,7 +2537,6 @@ exports[`PreviewCopyJob should render with pre-filled job name 1`] = `
|
||||
exports[`PreviewCopyJob should render with undefined database and container names 1`] = `
|
||||
<div
|
||||
class="ms-Stack previewCopyJobContainer css-109"
|
||||
data-test="Panel:PreviewCopyJob"
|
||||
>
|
||||
<div
|
||||
class="ms-Stack flex-row css-110"
|
||||
@@ -2597,7 +2566,6 @@ exports[`PreviewCopyJob should render with undefined database and container name
|
||||
<input
|
||||
aria-invalid="false"
|
||||
class="ms-TextField-field field-115"
|
||||
data-test="job-name-textfield"
|
||||
id="TextField48"
|
||||
type="text"
|
||||
value=""
|
||||
@@ -2617,7 +2585,6 @@ exports[`PreviewCopyJob should render with undefined database and container name
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
data-test="source-subscription-name"
|
||||
>
|
||||
Test Subscription
|
||||
</span>
|
||||
@@ -2632,7 +2599,6 @@ exports[`PreviewCopyJob should render with undefined database and container name
|
||||
</span>
|
||||
<span
|
||||
class="css-125"
|
||||
data-test="source-account-name"
|
||||
>
|
||||
test-account
|
||||
</span>
|
||||
|
||||
@@ -9,7 +9,7 @@ import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { CopyJobContext } from "../../../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
|
||||
import { CopyJobContextProviderType, CopyJobContextState } from "../../../../Types/CopyJobTypes";
|
||||
import { AccountDropdown, normalizeAccountId } from "./AccountDropdown";
|
||||
import { AccountDropdown } from "./AccountDropdown";
|
||||
|
||||
jest.mock("../../../../../../hooks/useDatabaseAccounts");
|
||||
jest.mock("../../../../../../UserContext", () => ({
|
||||
@@ -202,16 +202,13 @@ describe("AccountDropdown", () => {
|
||||
|
||||
const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0];
|
||||
const newState = stateUpdateFunction(mockCopyJobState);
|
||||
expect(newState.source.account).toEqual({
|
||||
...mockDatabaseAccount1,
|
||||
id: normalizeAccountId(mockDatabaseAccount1.id),
|
||||
});
|
||||
expect(newState.source.account).toBe(mockDatabaseAccount1);
|
||||
});
|
||||
|
||||
it("should auto-select predefined account from userContext if available", async () => {
|
||||
const userContextAccount = {
|
||||
...mockDatabaseAccount2,
|
||||
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/account2",
|
||||
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDb/databaseAccounts/account2",
|
||||
};
|
||||
|
||||
(userContext as any).databaseAccount = userContextAccount;
|
||||
@@ -226,10 +223,7 @@ describe("AccountDropdown", () => {
|
||||
|
||||
const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0];
|
||||
const newState = stateUpdateFunction(mockCopyJobState);
|
||||
expect(newState.source.account).toEqual({
|
||||
...mockDatabaseAccount2,
|
||||
id: normalizeAccountId(mockDatabaseAccount2.id),
|
||||
});
|
||||
expect(newState.source.account).toBe(mockDatabaseAccount2);
|
||||
});
|
||||
|
||||
it("should keep current account if it exists in the filtered list", async () => {
|
||||
@@ -254,16 +248,7 @@ describe("AccountDropdown", () => {
|
||||
|
||||
const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0];
|
||||
const newState = stateUpdateFunction(contextWithSelectedAccount.copyJobState);
|
||||
expect(newState).toEqual({
|
||||
...contextWithSelectedAccount.copyJobState,
|
||||
source: {
|
||||
...contextWithSelectedAccount.copyJobState.source,
|
||||
account: {
|
||||
...mockDatabaseAccount1,
|
||||
id: normalizeAccountId(mockDatabaseAccount1.id),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(newState).toBe(contextWithSelectedAccount.copyJobState);
|
||||
});
|
||||
|
||||
it("should handle account change when user selects different account", async () => {
|
||||
@@ -287,7 +272,7 @@ describe("AccountDropdown", () => {
|
||||
it("should normalize account ID for Portal platform", () => {
|
||||
const portalAccount = {
|
||||
...mockDatabaseAccount1,
|
||||
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/account1",
|
||||
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDb/databaseAccounts/account1",
|
||||
};
|
||||
|
||||
(configContext as any).platform = Platform.Portal;
|
||||
|
||||
@@ -12,7 +12,7 @@ import FieldRow from "../../Components/FieldRow";
|
||||
|
||||
interface AccountDropdownProps {}
|
||||
|
||||
export const normalizeAccountId = (id: string = "") => {
|
||||
const normalizeAccountId = (id: string) => {
|
||||
if (configContext.platform === Platform.Portal) {
|
||||
return id.replace("/Microsoft.DocumentDb/", "/Microsoft.DocumentDB/");
|
||||
} else if (configContext.platform === Platform.Hosted) {
|
||||
@@ -27,12 +27,7 @@ export const AccountDropdown: React.FC<AccountDropdownProps> = () => {
|
||||
|
||||
const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId;
|
||||
const allAccounts: DatabaseAccount[] = useDatabaseAccounts(selectedSubscriptionId);
|
||||
const sqlApiOnlyAccounts = (allAccounts || [])
|
||||
.filter((account) => apiType(account) === "SQL")
|
||||
.map((account) => ({
|
||||
...account,
|
||||
id: normalizeAccountId(account.id),
|
||||
}));
|
||||
const sqlApiOnlyAccounts: DatabaseAccount[] = (allAccounts || []).filter((account) => apiType(account) === "SQL");
|
||||
|
||||
const updateCopyJobState = (newAccount: DatabaseAccount) => {
|
||||
setCopyJobState((prevState) => {
|
||||
@@ -52,7 +47,7 @@ export const AccountDropdown: React.FC<AccountDropdownProps> = () => {
|
||||
useEffect(() => {
|
||||
if (sqlApiOnlyAccounts && sqlApiOnlyAccounts.length > 0 && selectedSubscriptionId) {
|
||||
const currentAccountId = copyJobState?.source?.account?.id;
|
||||
const predefinedAccountId = normalizeAccountId(userContext.databaseAccount?.id);
|
||||
const predefinedAccountId = userContext.databaseAccount?.id;
|
||||
const selectedAccountId = currentAccountId || predefinedAccountId;
|
||||
|
||||
const targetAccount: DatabaseAccount | null =
|
||||
@@ -63,7 +58,7 @@ export const AccountDropdown: React.FC<AccountDropdownProps> = () => {
|
||||
|
||||
const accountOptions =
|
||||
sqlApiOnlyAccounts?.map((account) => ({
|
||||
key: account.id,
|
||||
key: normalizeAccountId(account.id),
|
||||
text: account.name,
|
||||
data: account,
|
||||
})) || [];
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
|
||||
import { MigrationType } from "./MigrationType";
|
||||
|
||||
jest.mock("../../../../Context/CopyJobContext", () => ({
|
||||
useCopyJobContext: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("MigrationType", () => {
|
||||
const mockSetCopyJobState = jest.fn();
|
||||
|
||||
const defaultContextValue = {
|
||||
copyJobState: {
|
||||
jobName: "",
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
source: {
|
||||
subscription: null as any,
|
||||
account: null as any,
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
},
|
||||
target: {
|
||||
subscriptionId: "",
|
||||
account: null as any,
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
},
|
||||
sourceReadAccessFromTarget: false,
|
||||
},
|
||||
setCopyJobState: mockSetCopyJobState,
|
||||
flow: { currentScreen: "selectAccount" },
|
||||
setFlow: jest.fn(),
|
||||
contextError: "",
|
||||
setContextError: jest.fn(),
|
||||
explorer: {} as any,
|
||||
resetCopyJobState: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue(defaultContextValue);
|
||||
});
|
||||
|
||||
describe("Component Rendering", () => {
|
||||
it("should render migration type component with radio buttons", () => {
|
||||
const { container } = render(<MigrationType />);
|
||||
|
||||
expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
|
||||
expect(screen.getByRole("radiogroup")).toBeInTheDocument();
|
||||
|
||||
const offlineRadio = screen.getByRole("radio", {
|
||||
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
});
|
||||
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
|
||||
|
||||
expect(offlineRadio).toBeInTheDocument();
|
||||
expect(onlineRadio).toBeInTheDocument();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render with online mode selected by default", () => {
|
||||
render(<MigrationType />);
|
||||
|
||||
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
|
||||
const offlineRadio = screen.getByRole("radio", {
|
||||
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
});
|
||||
|
||||
expect(onlineRadio).toBeChecked();
|
||||
expect(offlineRadio).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should render with offline mode selected when state is offline", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
render(<MigrationType />);
|
||||
|
||||
const offlineRadio = screen.getByRole("radio", {
|
||||
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
});
|
||||
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
|
||||
|
||||
expect(offlineRadio).toBeChecked();
|
||||
expect(onlineRadio).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Descriptions and Learn More Links", () => {
|
||||
it("should render online description and learn more link when online is selected", () => {
|
||||
render(<MigrationType />);
|
||||
|
||||
expect(screen.getByTestId("migration-type-description-online")).toBeInTheDocument();
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "online copy jobs",
|
||||
});
|
||||
expect(learnMoreLink).toBeInTheDocument();
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started",
|
||||
);
|
||||
expect(learnMoreLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
it("should render offline description and learn more link when offline is selected", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
render(<MigrationType />);
|
||||
|
||||
expect(screen.getByTestId("migration-type-description-offline")).toBeInTheDocument();
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "offline copy jobs",
|
||||
});
|
||||
expect(learnMoreLink).toBeInTheDocument();
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://learn.microsoft.com/azure/cosmos-db/how-to-container-copy?tabs=offline-copy&pivots=api-nosql",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Interactions", () => {
|
||||
it("should call setCopyJobState when offline radio button is clicked", () => {
|
||||
render(<MigrationType />);
|
||||
|
||||
const offlineRadio = screen.getByRole("radio", {
|
||||
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
});
|
||||
fireEvent.click(offlineRadio);
|
||||
|
||||
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
const updateFunction = mockSetCopyJobState.mock.calls[0][0];
|
||||
const result = updateFunction(defaultContextValue.copyJobState);
|
||||
|
||||
expect(result).toEqual({
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
});
|
||||
});
|
||||
|
||||
it("should call setCopyJobState when online radio button is clicked", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
render(<MigrationType />);
|
||||
|
||||
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
|
||||
fireEvent.click(onlineRadio);
|
||||
|
||||
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
const updateFunction = mockSetCopyJobState.mock.calls[0][0];
|
||||
const result = updateFunction({
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accessibility", () => {
|
||||
it("should have proper ARIA attributes", () => {
|
||||
render(<MigrationType />);
|
||||
|
||||
const choiceGroup = screen.getByRole("radiogroup");
|
||||
expect(choiceGroup).toBeInTheDocument();
|
||||
expect(choiceGroup).toHaveAttribute("aria-labelledby", "migrationTypeChoiceGroup");
|
||||
});
|
||||
|
||||
it("should have proper radio button labels", () => {
|
||||
render(<MigrationType />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.offline.title }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle undefined migration type gracefully", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(<MigrationType />);
|
||||
|
||||
expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.offline.title }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle null copyJobState gracefully", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: null,
|
||||
});
|
||||
|
||||
const { container } = render(<MigrationType />);
|
||||
|
||||
expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable react/display-name */
|
||||
import { ChoiceGroup, IChoiceGroupOption, Stack, Text } from "@fluentui/react";
|
||||
import MarkdownRender from "@nteract/markdown";
|
||||
import { useCopyJobContext } from "Explorer/ContainerCopy/Context/CopyJobContext";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
|
||||
|
||||
interface MigrationTypeProps {}
|
||||
const options: IChoiceGroupOption[] = [
|
||||
{
|
||||
key: CopyJobMigrationType.Offline,
|
||||
text: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
styles: { root: { width: "33%" } },
|
||||
},
|
||||
{
|
||||
key: CopyJobMigrationType.Online,
|
||||
text: ContainerCopyMessages.migrationTypeOptions.online.title,
|
||||
styles: { root: { width: "33%" } },
|
||||
},
|
||||
];
|
||||
|
||||
export const MigrationType: React.FC<MigrationTypeProps> = React.memo(() => {
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
const handleChange = (_ev?: React.FormEvent, option?: IChoiceGroupOption) => {
|
||||
if (option) {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
migrationType: option.key as CopyJobMigrationType,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const selectedKey = copyJobState?.migrationType ?? "";
|
||||
const selectedKeyLowercase = selectedKey.toLowerCase() as keyof typeof ContainerCopyMessages.migrationTypeOptions;
|
||||
const selectedKeyContent = ContainerCopyMessages.migrationTypeOptions[selectedKeyLowercase];
|
||||
|
||||
return (
|
||||
<Stack data-test="migration-type" className="migrationTypeContainer">
|
||||
<Stack.Item>
|
||||
<ChoiceGroup
|
||||
selectedKey={selectedKey}
|
||||
options={options}
|
||||
onChange={handleChange}
|
||||
ariaLabelledBy="migrationTypeChoiceGroup"
|
||||
styles={{ flexContainer: { display: "flex" } }}
|
||||
/>
|
||||
</Stack.Item>
|
||||
{selectedKeyContent && (
|
||||
<Stack.Item styles={{ root: { marginTop: 10 } }}>
|
||||
<Text
|
||||
variant="small"
|
||||
className="migrationTypeDescription"
|
||||
data-testid={`migration-type-description-${selectedKeyLowercase}`}
|
||||
>
|
||||
<MarkdownRender source={selectedKeyContent.description} linkTarget="_blank" />
|
||||
</Text>
|
||||
</Stack.Item>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { MigrationTypeCheckbox } from "./MigrationTypeCheckbox";
|
||||
|
||||
describe("MigrationTypeCheckbox", () => {
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Component Rendering", () => {
|
||||
it("should render with default props (unchecked state)", () => {
|
||||
const { container } = render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render in checked state", () => {
|
||||
const { container } = render(<MigrationTypeCheckbox checked={true} onChange={mockOnChange} />);
|
||||
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should display the correct label text", () => {
|
||||
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
|
||||
const label = screen.getByText("Copy container in offline mode");
|
||||
expect(label).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have correct accessibility attributes when checked", () => {
|
||||
render(<MigrationTypeCheckbox checked={true} onChange={mockOnChange} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).toBeChecked();
|
||||
expect(checkbox).toHaveAttribute("checked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FluentUI Integration", () => {
|
||||
it("should render FluentUI Checkbox component correctly", () => {
|
||||
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
expect(checkbox).toHaveAttribute("type", "checkbox");
|
||||
});
|
||||
|
||||
it("should render FluentUI Stack component correctly", () => {
|
||||
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
const stackContainer = document.querySelector(".migrationTypeRow");
|
||||
expect(stackContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply FluentUI Stack horizontal alignment correctly", () => {
|
||||
const { container } = render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
const stackContainer = container.querySelector(".migrationTypeRow");
|
||||
expect(stackContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable react/display-name */
|
||||
import { Checkbox, Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
|
||||
interface MigrationTypeCheckboxProps {
|
||||
checked: boolean;
|
||||
onChange: (_ev?: React.FormEvent, checked?: boolean) => void;
|
||||
}
|
||||
|
||||
export const MigrationTypeCheckbox: React.FC<MigrationTypeCheckboxProps> = React.memo(({ checked, onChange }) => (
|
||||
<Stack horizontal horizontalAlign="space-between" className="migrationTypeRow">
|
||||
<Checkbox label={ContainerCopyMessages.migrationTypeCheckboxLabel} checked={checked} onChange={onChange} />
|
||||
</Stack>
|
||||
));
|
||||
@@ -1,109 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`MigrationType Component Rendering should render migration type component with radio buttons 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack migrationTypeContainer css-109"
|
||||
data-test="migration-type"
|
||||
>
|
||||
<div
|
||||
class="ms-StackItem css-110"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceFieldGroup root-111"
|
||||
>
|
||||
<div
|
||||
aria-labelledby="migrationTypeChoiceGroup"
|
||||
role="radiogroup"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceFieldGroup-flexContainer flexContainer-112"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceField root-113"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceField-wrapper"
|
||||
>
|
||||
<input
|
||||
class="ms-ChoiceField-input input-114"
|
||||
id="ChoiceGroup0-offline"
|
||||
name="ChoiceGroup0"
|
||||
type="radio"
|
||||
/>
|
||||
<label
|
||||
class="ms-ChoiceField-field field-115"
|
||||
for="ChoiceGroup0-offline"
|
||||
>
|
||||
<span
|
||||
class="ms-ChoiceFieldLabel"
|
||||
id="ChoiceGroupLabel1-offline"
|
||||
>
|
||||
Offline mode
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ms-ChoiceField root-113"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceField-wrapper"
|
||||
>
|
||||
<input
|
||||
checked=""
|
||||
class="ms-ChoiceField-input input-114"
|
||||
id="ChoiceGroup0-online"
|
||||
name="ChoiceGroup0"
|
||||
type="radio"
|
||||
/>
|
||||
<label
|
||||
class="ms-ChoiceField-field is-checked field-120"
|
||||
for="ChoiceGroup0-online"
|
||||
>
|
||||
<span
|
||||
class="ms-ChoiceFieldLabel"
|
||||
id="ChoiceGroupLabel1-online"
|
||||
>
|
||||
Online mode
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ms-StackItem css-123"
|
||||
>
|
||||
<span
|
||||
class="migrationTypeDescription css-124"
|
||||
data-testid="migration-type-description-online"
|
||||
>
|
||||
<div
|
||||
class="markdown-body "
|
||||
>
|
||||
<p>
|
||||
Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the
|
||||
<a
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview"
|
||||
target="_blank"
|
||||
>
|
||||
All Versions and Delete
|
||||
</a>
|
||||
change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about
|
||||
<a
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started"
|
||||
target="_blank"
|
||||
>
|
||||
online copy jobs
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,80 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`MigrationTypeCheckbox Component Rendering should render in checked state 1`] = `
|
||||
<div
|
||||
class="ms-Stack migrationTypeRow css-109"
|
||||
>
|
||||
<div
|
||||
class="ms-Checkbox is-checked is-enabled root-119"
|
||||
>
|
||||
<input
|
||||
checked=""
|
||||
class="input-111"
|
||||
data-ktp-execute-target="true"
|
||||
id="checkbox-1"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
class="ms-Checkbox-label label-112"
|
||||
for="checkbox-1"
|
||||
>
|
||||
<div
|
||||
class="ms-Checkbox-checkbox checkbox-120"
|
||||
data-ktp-target="true"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="ms-Checkbox-checkmark checkmark-122"
|
||||
data-icon-name="CheckMark"
|
||||
>
|
||||
|
||||
</i>
|
||||
</div>
|
||||
<span
|
||||
class="ms-Checkbox-text text-115"
|
||||
>
|
||||
Copy container in offline mode
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`MigrationTypeCheckbox Component Rendering should render with default props (unchecked state) 1`] = `
|
||||
<div
|
||||
class="ms-Stack migrationTypeRow css-109"
|
||||
>
|
||||
<div
|
||||
class="ms-Checkbox is-enabled root-110"
|
||||
>
|
||||
<input
|
||||
class="input-111"
|
||||
data-ktp-execute-target="true"
|
||||
id="checkbox-0"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
class="ms-Checkbox-label label-112"
|
||||
for="checkbox-0"
|
||||
>
|
||||
<div
|
||||
class="ms-Checkbox-checkbox checkbox-113"
|
||||
data-ktp-target="true"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="ms-Checkbox-checkmark checkmark-118"
|
||||
data-icon-name="CheckMark"
|
||||
>
|
||||
|
||||
</i>
|
||||
</div>
|
||||
<span
|
||||
class="ms-Checkbox-text text-115"
|
||||
>
|
||||
Copy container in offline mode
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,5 +1,5 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
|
||||
@@ -18,8 +18,19 @@ jest.mock("./Components/AccountDropdown", () => ({
|
||||
AccountDropdown: jest.fn(() => <div data-testid="account-dropdown">Account Dropdown</div>),
|
||||
}));
|
||||
|
||||
jest.mock("./Components/MigrationType", () => ({
|
||||
MigrationType: jest.fn(() => <div data-testid="migration-type">Migration Type</div>),
|
||||
jest.mock("./Components/MigrationTypeCheckbox", () => ({
|
||||
MigrationTypeCheckbox: jest.fn(({ checked, onChange }: { checked: boolean; onChange: () => void }) => (
|
||||
<div data-testid="migration-type-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
data-testid="migration-checkbox-input"
|
||||
aria-label="Migration Type Checkbox"
|
||||
/>
|
||||
Copy container in offline mode
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
describe("SelectAccount", () => {
|
||||
@@ -72,7 +83,7 @@ describe("SelectAccount", () => {
|
||||
|
||||
expect(screen.getByTestId("subscription-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("account-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("migration-type")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("migration-type-checkbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render correctly with snapshot", () => {
|
||||
@@ -82,20 +93,78 @@ describe("SelectAccount", () => {
|
||||
});
|
||||
|
||||
describe("Migration Type Functionality", () => {
|
||||
it("should render migration type component", () => {
|
||||
it("should display migration type checkbox as unchecked when migrationType is Online", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
},
|
||||
});
|
||||
|
||||
render(<SelectAccount />);
|
||||
|
||||
const migrationTypeComponent = screen.getByTestId("migration-type");
|
||||
expect(migrationTypeComponent).toBeInTheDocument();
|
||||
const checkbox = screen.getByTestId("migration-checkbox-input");
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should display migration type checkbox as checked when migrationType is Offline", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
render(<SelectAccount />);
|
||||
|
||||
const checkbox = screen.getByTestId("migration-checkbox-input");
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it("should call setCopyJobState with Online migration type when checkbox is unchecked", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
render(<SelectAccount />);
|
||||
|
||||
const checkbox = screen.getByTestId("migration-checkbox-input");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
const updateFunction = mockSetCopyJobState.mock.calls[0][0];
|
||||
const previousState = {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
};
|
||||
const result = updateFunction(previousState);
|
||||
|
||||
expect(result).toEqual({
|
||||
...previousState,
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance and Optimization", () => {
|
||||
it("should render without performance issues", () => {
|
||||
it("should maintain referential equality of handler functions between renders", async () => {
|
||||
const { rerender } = render(<SelectAccount />);
|
||||
|
||||
const migrationCheckbox = (await import("./Components/MigrationTypeCheckbox")).MigrationTypeCheckbox as jest.Mock;
|
||||
const firstRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange;
|
||||
|
||||
rerender(<SelectAccount />);
|
||||
|
||||
expect(screen.getByTestId("migration-type")).toBeInTheDocument();
|
||||
const secondRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange;
|
||||
|
||||
expect(firstRenderHandler).toBe(secondRenderHandler);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { Stack, Text } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
|
||||
import { AccountDropdown } from "./Components/AccountDropdown";
|
||||
import { MigrationType } from "./Components/MigrationType";
|
||||
import { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox";
|
||||
import { SubscriptionDropdown } from "./Components/SubscriptionDropdown";
|
||||
|
||||
const SelectAccount = React.memo(() => {
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
|
||||
const handleMigrationTypeChange = (_ev?: React.FormEvent<HTMLElement>, checked?: boolean) => {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
|
||||
}));
|
||||
};
|
||||
|
||||
const migrationTypeChecked = copyJobState?.migrationType === CopyJobMigrationType.Offline;
|
||||
|
||||
return (
|
||||
<Stack data-test="Panel:SelectAccountContainer" className="selectAccountContainer" tokens={{ childrenGap: 15 }}>
|
||||
<Text>{ContainerCopyMessages.selectAccountDescription}</Text>
|
||||
@@ -14,7 +27,7 @@ const SelectAccount = React.memo(() => {
|
||||
|
||||
<AccountDropdown />
|
||||
|
||||
<MigrationType />
|
||||
<MigrationTypeCheckbox checked={migrationTypeChecked} onChange={handleMigrationTypeChange} />
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -21,9 +21,14 @@ exports[`SelectAccount Component Rendering should render correctly with snapshot
|
||||
Account Dropdown
|
||||
</div>
|
||||
<div
|
||||
data-testid="migration-type"
|
||||
data-testid="migration-type-checkbox"
|
||||
>
|
||||
Migration Type
|
||||
<input
|
||||
aria-label="Migration Type Checkbox"
|
||||
data-testid="migration-checkbox-input"
|
||||
type="checkbox"
|
||||
/>
|
||||
Copy container in offline mode
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -47,11 +47,7 @@ const SelectSourceAndTargetContainers = ({ showAddCollectionPanel }: SelectSourc
|
||||
const onDropdownChange = dropDownChangeHandler(setCopyJobState);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
data-test="Panel:SelectSourceAndTargetContainers"
|
||||
className="selectSourceAndTargetContainers"
|
||||
tokens={{ childrenGap: 25 }}
|
||||
>
|
||||
<Stack className="selectSourceAndTargetContainers" tokens={{ childrenGap: 25 }}>
|
||||
<span>{ContainerCopyMessages.selectSourceAndTargetContainersDescription}</span>
|
||||
<DatabaseContainerSection
|
||||
heading={ContainerCopyMessages.sourceContainerSubHeading}
|
||||
@@ -63,7 +59,6 @@ const SelectSourceAndTargetContainers = ({ showAddCollectionPanel }: SelectSourc
|
||||
selectedContainer={source?.containerId}
|
||||
containerDisabled={!source?.databaseId}
|
||||
containerOnChange={onDropdownChange("sourceContainer")}
|
||||
sectionType="source"
|
||||
/>
|
||||
<DatabaseContainerSection
|
||||
heading={ContainerCopyMessages.targetContainerSubHeading}
|
||||
@@ -76,7 +71,6 @@ const SelectSourceAndTargetContainers = ({ showAddCollectionPanel }: SelectSourc
|
||||
containerDisabled={!target?.databaseId}
|
||||
containerOnChange={onDropdownChange("targetContainer")}
|
||||
handleOnDemandCreateContainer={showAddCollectionPanel}
|
||||
sectionType="target"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,6 @@ describe("DatabaseContainerSection", () => {
|
||||
selectedContainer: "container1",
|
||||
containerDisabled: false,
|
||||
containerOnChange: mockContainerOnChange,
|
||||
sectionType: "source",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -293,7 +292,6 @@ describe("DatabaseContainerSection", () => {
|
||||
containerOptions: mockContainerOptions,
|
||||
selectedContainer: "container1",
|
||||
containerOnChange: mockContainerOnChange,
|
||||
sectionType: "source",
|
||||
};
|
||||
|
||||
render(<DatabaseContainerSection {...minimalProps} />);
|
||||
@@ -395,7 +393,6 @@ describe("DatabaseContainerSection", () => {
|
||||
containerOptions: [{ key: "c1", text: "Container 1", data: { id: "c1" } }],
|
||||
selectedContainer: "c1",
|
||||
containerOnChange: jest.fn(),
|
||||
sectionType: "source",
|
||||
};
|
||||
|
||||
const { container } = render(<DatabaseContainerSection {...minimalProps} />);
|
||||
@@ -414,7 +411,6 @@ describe("DatabaseContainerSection", () => {
|
||||
containerDisabled: false,
|
||||
containerOnChange: jest.fn(),
|
||||
handleOnDemandCreateContainer: jest.fn(),
|
||||
sectionType: "target",
|
||||
};
|
||||
|
||||
const { container } = render(<DatabaseContainerSection {...fullProps} />);
|
||||
@@ -432,7 +428,6 @@ describe("DatabaseContainerSection", () => {
|
||||
selectedContainer: "container1",
|
||||
containerDisabled: true,
|
||||
containerOnChange: jest.fn(),
|
||||
sectionType: "target",
|
||||
};
|
||||
|
||||
const { container } = render(<DatabaseContainerSection {...disabledProps} />);
|
||||
@@ -448,7 +443,6 @@ describe("DatabaseContainerSection", () => {
|
||||
containerOptions: [],
|
||||
selectedContainer: "",
|
||||
containerOnChange: jest.fn(),
|
||||
sectionType: "target",
|
||||
};
|
||||
|
||||
const { container } = render(<DatabaseContainerSection {...emptyOptionsProps} />);
|
||||
|
||||
@@ -15,7 +15,6 @@ export const DatabaseContainerSection = ({
|
||||
containerDisabled,
|
||||
containerOnChange,
|
||||
handleOnDemandCreateContainer,
|
||||
sectionType,
|
||||
}: DatabaseContainerSectionProps) => (
|
||||
<Stack tokens={{ childrenGap: 15 }} className="databaseContainerSection">
|
||||
<label className="subHeading">{heading}</label>
|
||||
@@ -28,7 +27,6 @@ export const DatabaseContainerSection = ({
|
||||
disabled={!!databaseDisabled}
|
||||
selectedKey={selectedDatabase}
|
||||
onChange={databaseOnChange}
|
||||
data-test={`${sectionType}-databaseDropdown`}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={ContainerCopyMessages.containerDropdownLabel}>
|
||||
@@ -41,7 +39,6 @@ export const DatabaseContainerSection = ({
|
||||
disabled={!!containerDisabled}
|
||||
selectedKey={selectedContainer}
|
||||
onChange={containerOnChange}
|
||||
data-test={`${sectionType}-containerDropdown`}
|
||||
/>
|
||||
{handleOnDemandCreateContainer && (
|
||||
<ActionButton className="create-container-link-btn" onClick={() => handleOnDemandCreateContainer()}>
|
||||
|
||||
@@ -37,7 +37,6 @@ exports[`DatabaseContainerSection Snapshot Testing matches snapshot with all pro
|
||||
class="ms-Dropdown is-required dropdown-112"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="target-databaseDropdown"
|
||||
id="Dropdown98"
|
||||
role="combobox"
|
||||
tabindex="0"
|
||||
@@ -95,7 +94,6 @@ exports[`DatabaseContainerSection Snapshot Testing matches snapshot with all pro
|
||||
class="ms-Dropdown is-required dropdown-112"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="target-containerDropdown"
|
||||
id="Dropdown99"
|
||||
role="combobox"
|
||||
tabindex="0"
|
||||
@@ -184,7 +182,6 @@ exports[`DatabaseContainerSection Snapshot Testing matches snapshot with disable
|
||||
class="ms-Dropdown is-disabled is-required dropdown-143"
|
||||
data-is-focusable="false"
|
||||
data-ktp-target="true"
|
||||
data-test="target-databaseDropdown"
|
||||
id="Dropdown103"
|
||||
role="combobox"
|
||||
tabindex="-1"
|
||||
@@ -242,7 +239,6 @@ exports[`DatabaseContainerSection Snapshot Testing matches snapshot with disable
|
||||
class="ms-Dropdown is-disabled is-required dropdown-143"
|
||||
data-is-focusable="false"
|
||||
data-ktp-target="true"
|
||||
data-test="target-containerDropdown"
|
||||
id="Dropdown104"
|
||||
role="combobox"
|
||||
tabindex="-1"
|
||||
@@ -310,7 +306,6 @@ exports[`DatabaseContainerSection Snapshot Testing matches snapshot with empty o
|
||||
class="ms-Dropdown is-required dropdown-112"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="target-databaseDropdown"
|
||||
id="Dropdown105"
|
||||
role="combobox"
|
||||
tabindex="0"
|
||||
@@ -368,7 +363,6 @@ exports[`DatabaseContainerSection Snapshot Testing matches snapshot with empty o
|
||||
class="ms-Dropdown is-required dropdown-112"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="target-containerDropdown"
|
||||
id="Dropdown106"
|
||||
role="combobox"
|
||||
tabindex="0"
|
||||
@@ -436,7 +430,6 @@ exports[`DatabaseContainerSection Snapshot Testing matches snapshot with minimal
|
||||
class="ms-Dropdown is-required dropdown-112"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="source-databaseDropdown"
|
||||
id="Dropdown96"
|
||||
role="combobox"
|
||||
tabindex="0"
|
||||
@@ -494,7 +487,6 @@ exports[`DatabaseContainerSection Snapshot Testing matches snapshot with minimal
|
||||
class="ms-Dropdown is-required dropdown-112"
|
||||
data-is-focusable="true"
|
||||
data-ktp-target="true"
|
||||
data-test="source-containerDropdown"
|
||||
id="Dropdown97"
|
||||
role="combobox"
|
||||
tabindex="0"
|
||||
|
||||
@@ -83,7 +83,6 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
data-test={`CopyJobActionMenu/Button:${job.Name}`}
|
||||
role="button"
|
||||
iconProps={{ iconName: "More", styles: { root: { fontSize: "20px", fontWeight: "bold" } } }}
|
||||
menuProps={{ items: getMenuItems() }}
|
||||
|
||||
@@ -80,7 +80,6 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
|
||||
<Stack.Item verticalFill={true} grow={1} shrink={1} style={styles.stackItem}>
|
||||
<ScrollablePane scrollbarVisibility={ScrollbarVisibility.auto}>
|
||||
<ShimmeredDetailsList
|
||||
className="CopyJobListContainer"
|
||||
onRenderRow={_onRenderRow}
|
||||
checkboxVisibility={2}
|
||||
columns={columns}
|
||||
|
||||
@@ -49,7 +49,6 @@ export interface DatabaseContainerSectionProps {
|
||||
containerDisabled?: boolean;
|
||||
containerOnChange: (ev: React.FormEvent<HTMLDivElement>, option: DropdownOptionType) => void;
|
||||
handleOnDemandCreateContainer?: () => void;
|
||||
sectionType: "source" | "target";
|
||||
}
|
||||
|
||||
export interface CopyJobContextState {
|
||||
|
||||
@@ -122,9 +122,6 @@
|
||||
background-color: @BaseMediumLow;
|
||||
}
|
||||
}
|
||||
.ms-DetailsHeader-cellTitle {
|
||||
padding-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.ms-DetailsRow {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { IndexingPolicy } from "@azure/cosmos";
|
||||
import { act } from "@testing-library/react";
|
||||
import { AuthType } from "AuthType";
|
||||
import { shallow } from "enzyme";
|
||||
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
|
||||
import ko from "knockout";
|
||||
import React from "react";
|
||||
import { updateCollection } from "../../../Common/dataAccess/updateCollection";
|
||||
@@ -444,3 +447,49 @@ describe("SettingsComponent", () => {
|
||||
expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsComponent - indexing policy subscription", () => {
|
||||
const baseProps: SettingsComponentProps = {
|
||||
settingsTab: new CollectionSettingsTabV2({
|
||||
collection: collection,
|
||||
tabKind: ViewModels.CollectionTabKind.CollectionSettingsV2,
|
||||
title: "Scale & Settings",
|
||||
tabPath: "",
|
||||
node: undefined,
|
||||
}),
|
||||
};
|
||||
|
||||
it("subscribes to the correct container's indexing policy and updates state on change", async () => {
|
||||
const containerId = collection.id();
|
||||
const mockIndexingPolicy: IndexingPolicy = {
|
||||
automatic: false,
|
||||
indexingMode: "lazy",
|
||||
includedPaths: [{ path: "/foo/*" }],
|
||||
excludedPaths: [{ path: "/bar/*" }],
|
||||
compositeIndexes: [],
|
||||
spatialIndexes: [],
|
||||
vectorIndexes: [],
|
||||
fullTextIndexes: [],
|
||||
};
|
||||
|
||||
const wrapper = shallow(<SettingsComponent {...baseProps} />);
|
||||
const instance = wrapper.instance() as SettingsComponent;
|
||||
|
||||
await act(async () => {
|
||||
useIndexingPolicyStore.setState({
|
||||
indexingPolicies: {
|
||||
[containerId]: mockIndexingPolicy,
|
||||
},
|
||||
});
|
||||
// Wait for the async refreshCollectionData to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.state("indexingPolicyContent")).toEqual(mockIndexingPolicy);
|
||||
expect(wrapper.state("indexingPolicyContentBaseline")).toEqual(mockIndexingPolicy);
|
||||
// @ts-expect-error: rawDataModel is intentionally accessed for test validation
|
||||
expect(instance.collection.rawDataModel.indexingPolicy).toEqual(mockIndexingPolicy);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ThroughputBucketsComponent,
|
||||
ThroughputBucketsComponentProps,
|
||||
} from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent";
|
||||
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils";
|
||||
@@ -73,7 +74,6 @@ import {
|
||||
parseConflictResolutionMode,
|
||||
parseConflictResolutionProcedure,
|
||||
} from "./SettingsUtils";
|
||||
|
||||
interface SettingsV2TabInfo {
|
||||
tab: SettingsV2TabTypes;
|
||||
content: JSX.Element;
|
||||
@@ -182,7 +182,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
private totalThroughputUsed: number;
|
||||
private throughputBucketsEnabled: boolean;
|
||||
public mongoDBCollectionResource: MongoDBCollectionResource;
|
||||
|
||||
private unsubscribe: () => void;
|
||||
constructor(props: SettingsComponentProps) {
|
||||
super(props);
|
||||
|
||||
@@ -312,6 +312,13 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
if (this.isCollectionSettingsTab) {
|
||||
this.refreshIndexTransformationProgress();
|
||||
this.loadMongoIndexes();
|
||||
this.unsubscribe = useIndexingPolicyStore.subscribe(
|
||||
() => {
|
||||
this.refreshCollectionData();
|
||||
},
|
||||
(state) => state.indexingPolicies[this.collection?.id()],
|
||||
);
|
||||
this.refreshCollectionData();
|
||||
}
|
||||
|
||||
this.setBaseline();
|
||||
@@ -319,7 +326,11 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
useCommandBar.getState().setContextButtons(this.getTabsButtons());
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
}
|
||||
}
|
||||
componentDidUpdate(): void {
|
||||
if (this.props.settingsTab.isActive()) {
|
||||
useCommandBar.getState().setContextButtons(this.getTabsButtons());
|
||||
@@ -849,7 +860,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
{ name: "name_of_property", query: "query_to_compute_property" },
|
||||
] as DataModels.ComputedProperties;
|
||||
}
|
||||
|
||||
const throughputBuckets = this.offer?.throughputBuckets;
|
||||
|
||||
return {
|
||||
@@ -1009,10 +1019,31 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
startKey,
|
||||
);
|
||||
};
|
||||
private refreshCollectionData = async (): Promise<void> => {
|
||||
const containerId = this.collection.id();
|
||||
const latestIndexingPolicy = useIndexingPolicyStore.getState().indexingPolicies[containerId];
|
||||
const rawPolicy = latestIndexingPolicy ?? this.collection.indexingPolicy();
|
||||
|
||||
const latestCollection: DataModels.IndexingPolicy = {
|
||||
automatic: rawPolicy?.automatic ?? true,
|
||||
indexingMode: rawPolicy?.indexingMode ?? "consistent",
|
||||
includedPaths: rawPolicy?.includedPaths ?? [],
|
||||
excludedPaths: rawPolicy?.excludedPaths ?? [],
|
||||
compositeIndexes: rawPolicy?.compositeIndexes ?? [],
|
||||
spatialIndexes: rawPolicy?.spatialIndexes ?? [],
|
||||
vectorIndexes: rawPolicy?.vectorIndexes ?? [],
|
||||
fullTextIndexes: rawPolicy?.fullTextIndexes ?? [],
|
||||
};
|
||||
|
||||
this.collection.rawDataModel.indexingPolicy = latestCollection;
|
||||
this.setState({
|
||||
indexingPolicyContent: latestCollection,
|
||||
indexingPolicyContentBaseline: latestCollection,
|
||||
});
|
||||
};
|
||||
|
||||
private saveCollectionSettings = async (startKey: number): Promise<void> => {
|
||||
const newCollection: DataModels.Collection = { ...this.collection.rawDataModel };
|
||||
|
||||
if (
|
||||
this.state.isSubSettingsSaveable ||
|
||||
this.state.isContainerPolicyDirty ||
|
||||
@@ -1252,7 +1283,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
|
||||
onScaleDiscardableChange: this.onScaleDiscardableChange,
|
||||
throughputError: this.state.throughputError,
|
||||
};
|
||||
|
||||
if (!this.isCollectionSettingsTab) {
|
||||
return (
|
||||
<div className="settingsV2MainContainer">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -187,7 +187,7 @@ export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({
|
||||
<Text styles={textSubHeadingStyle}>Current {partitionKeyName.toLowerCase()}</Text>
|
||||
<Text styles={textSubHeadingStyle}>Partitioning</Text>
|
||||
</Stack>
|
||||
<Stack tokens={{ childrenGap: 5 }} data-test="partition-key-values">
|
||||
<Stack tokens={{ childrenGap: 5 }}>
|
||||
<Text styles={textSubHeadingStyle1}>{partitionKeyValue}</Text>
|
||||
<Text styles={textSubHeadingStyle1}>
|
||||
{isHierarchicalPartitionedContainer() ? "Hierarchical" : "Non-hierarchical"}
|
||||
@@ -199,7 +199,6 @@ export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
<MessageBar
|
||||
data-test="partition-key-warning"
|
||||
messageBarType={MessageBarType.warning}
|
||||
messageBarIconProps={{ iconName: "WarningSolid", className: "messageBarWarningIcon" }}
|
||||
styles={darkThemeMessageBarStyles}
|
||||
@@ -221,7 +220,6 @@ export const PartitionKeyComponent: React.FC<PartitionKeyComponentProps> = ({
|
||||
</Text>
|
||||
{configContext.platform !== Platform.Emulator && (
|
||||
<PrimaryButton
|
||||
data-test="change-partition-key-button"
|
||||
styles={{ root: { width: "fit-content" } }}
|
||||
text="Change"
|
||||
onClick={startPartitionkeyChangeWorkflow}
|
||||
|
||||
@@ -78,7 +78,6 @@ exports[`PartitionKeyComponent renders default component and matches snapshot 1`
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack
|
||||
data-test="partition-key-values"
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 5,
|
||||
@@ -109,7 +108,6 @@ exports[`PartitionKeyComponent renders default component and matches snapshot 1`
|
||||
</Stack>
|
||||
</Stack>
|
||||
<StyledMessageBar
|
||||
data-test="partition-key-warning"
|
||||
messageBarIconProps={
|
||||
{
|
||||
"className": "messageBarWarningIcon",
|
||||
@@ -162,7 +160,6 @@ exports[`PartitionKeyComponent renders default component and matches snapshot 1`
|
||||
To change the partition key, a new destination container must be created or an existing destination container selected. Data will then be copied to the destination container.
|
||||
</Text>
|
||||
<CustomizedPrimaryButton
|
||||
data-test="change-partition-key-button"
|
||||
onClick={[Function]}
|
||||
styles={
|
||||
{
|
||||
@@ -240,7 +237,6 @@ exports[`PartitionKeyComponent renders read-only component and matches snapshot
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack
|
||||
data-test="partition-key-values"
|
||||
tokens={
|
||||
{
|
||||
"childrenGap": 5,
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -208,7 +208,7 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
</div>
|
||||
</Stack>
|
||||
{createNewContainer ? (
|
||||
<Stack data-test="create-new-container-form">
|
||||
<Stack>
|
||||
<MessageBar>All configurations except for unique keys will be copied from the source container</MessageBar>
|
||||
<Stack className="panelGroupSpacing">
|
||||
<Stack horizontal>
|
||||
@@ -230,7 +230,6 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
</TooltipHost>
|
||||
</Stack>
|
||||
<input
|
||||
data-test="new-container-id-input"
|
||||
name="collectionId"
|
||||
id="collectionId"
|
||||
type="text"
|
||||
@@ -272,7 +271,6 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
|
||||
<input
|
||||
type="text"
|
||||
data-test="new-container-partition-key-input"
|
||||
id="addCollection-partitionKeyValue"
|
||||
aria-required
|
||||
required
|
||||
@@ -306,7 +304,6 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
type="text"
|
||||
id="addCollection-partitionKeyValue"
|
||||
key={`addCollection-partitionKeyValue_${index}`}
|
||||
data-test={`new-container-sub-partition-key-input-${index}`}
|
||||
aria-required
|
||||
required
|
||||
size={40}
|
||||
@@ -330,8 +327,6 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
data-test={`remove-sub-partition-key-button-${index}`}
|
||||
ariaLabel="Remove hierarchical partition key"
|
||||
iconProps={{ iconName: "Delete" }}
|
||||
style={{ height: 27 }}
|
||||
onClick={() => {
|
||||
@@ -344,7 +339,6 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
})}
|
||||
<Stack className="panelGroupSpacing">
|
||||
<DefaultButton
|
||||
data-test="add-sub-partition-key-button"
|
||||
styles={{ root: { padding: 0, width: 200, height: 30 }, label: { fontSize: 12 } }}
|
||||
disabled={subPartitionKeys.length >= Constants.BackendDefaults.maxNumMultiHashPartition}
|
||||
onClick={() => setSubPartitionKeys([...subPartitionKeys, ""])}
|
||||
@@ -352,11 +346,7 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
Add hierarchical partition key
|
||||
</DefaultButton>
|
||||
{subPartitionKeys.length > 0 && (
|
||||
<Text
|
||||
data-test="hierarchical-partitioning-info-text"
|
||||
variant="small"
|
||||
style={{ color: "var(--colorNeutralForeground1)" }}
|
||||
>
|
||||
<Text variant="small" style={{ color: "var(--colorNeutralForeground1)" }}>
|
||||
<Icon iconName="InfoSolid" className="removeIcon" tabIndex={0} /> This feature allows you to
|
||||
partition your data with up to three levels of keys for better data distribution. Requires .NET V3,
|
||||
Java V4 SDK, or preview JavaScript V3 SDK.{" "}
|
||||
@@ -369,7 +359,7 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack data-test="use-existing-container-form">
|
||||
<Stack>
|
||||
<Stack horizontal>
|
||||
<span className="mandatoryStar">* </span>
|
||||
<Text className="panelTextBold" variant="small">
|
||||
@@ -400,7 +390,6 @@ export const ChangePartitionKeyPane: React.FC<ChangePartitionKeyPaneProps> = ({
|
||||
}}
|
||||
defaultSelectedKey={targetCollectionId}
|
||||
responsiveMode={999}
|
||||
ariaLabel="Existing Containers"
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
@@ -61,8 +61,7 @@ const useStyles = makeStyles({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
overflowY: "auto",
|
||||
minHeight: "100vh",
|
||||
backgroundColor: "var(--colorNeutralBackground1)",
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
},
|
||||
@@ -74,19 +73,20 @@ const useStyles = makeStyles({
|
||||
},
|
||||
title: {
|
||||
fontSize: "48px",
|
||||
fontWeight: "400",
|
||||
fontWeight: "500",
|
||||
margin: "16px auto",
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: "18px",
|
||||
marginBottom: "40px",
|
||||
color: "var(--colorNeutralForeground2)",
|
||||
},
|
||||
cardContainer: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, 1fr)",
|
||||
gap: "16px",
|
||||
width: "60%",
|
||||
width: "66%",
|
||||
margin: "0 auto",
|
||||
backgroundColor: "var(--colorNeutralBackground1)",
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
@@ -100,7 +100,7 @@ const useStyles = makeStyles({
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
border: "1px solid var(--colorNeutralStroke1)",
|
||||
borderRadius: "4px",
|
||||
boxShadow: "rgba(0, 0, 0, 0.25) 0px 4px 4px",
|
||||
boxShadow: "var(--shadow4)",
|
||||
cursor: "pointer",
|
||||
minHeight: "150px",
|
||||
"&:hover": {
|
||||
@@ -128,10 +128,11 @@ const useStyles = makeStyles({
|
||||
textAlign: "left",
|
||||
},
|
||||
moreStuffContainer: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: "32px",
|
||||
width: "90%",
|
||||
width: "66%",
|
||||
margin: "40px auto",
|
||||
},
|
||||
moreStuffColumn: {
|
||||
display: "flex",
|
||||
@@ -226,7 +227,7 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
return (
|
||||
<Stack
|
||||
className="splashStackContainer"
|
||||
style={{ width: "60%", cursor: "pointer", margin: "40px auto" }}
|
||||
style={{ width: "66%", cursor: "pointer", margin: "40px auto" }}
|
||||
tokens={{ childrenGap: 16 }}
|
||||
>
|
||||
<Stack className="splashStackRow" horizontal>
|
||||
@@ -902,9 +903,9 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
return (
|
||||
<div className={styles.splashScreenContainer}>
|
||||
<div className={styles.splashScreen}>
|
||||
<h2 className={styles.title} role="heading" aria-label="Welcome to Azure Cosmos DB">
|
||||
<h1 className={styles.title} role="heading" aria-label="Welcome to Azure Cosmos DB">
|
||||
Welcome to Azure Cosmos DB<span className="activePatch"></span>
|
||||
</h2>
|
||||
</h1>
|
||||
<div className={styles.subtitle}>Globally distributed, multi-model database service for any scale</div>
|
||||
{getSplashScreenButtons()}
|
||||
{useCarousel.getState().showCoachMark && (
|
||||
|
||||
@@ -15,7 +15,7 @@ const useStyles = makeStyles({
|
||||
button: {
|
||||
border: "1px solid var(--colorNeutralStroke1)",
|
||||
boxSizing: "border-box",
|
||||
boxShadow: "rgba(0, 0, 0, 0.25) 0px 4px 4px",
|
||||
boxShadow: "var(--shadow4)",
|
||||
borderRadius: "4px",
|
||||
padding: "32px 16px",
|
||||
backgroundColor: "var(--colorNeutralBackground1)",
|
||||
|
||||
107
src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx
Normal file
107
src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { CircleFilled } from "@fluentui/react-icons";
|
||||
import type { IIndexMetric } from "Explorer/Tabs/QueryTab/ResultsView";
|
||||
import { useIndexAdvisorStyles } from "Explorer/Tabs/QueryTab/StylesAdvisor";
|
||||
import * as React from "react";
|
||||
|
||||
// SDK response format
|
||||
export interface IndexMetricsResponse {
|
||||
UtilizedIndexes?: {
|
||||
SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>;
|
||||
CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>;
|
||||
};
|
||||
PotentialIndexes?: {
|
||||
SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>;
|
||||
CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIndexMetrics(indexMetrics: IndexMetricsResponse): {
|
||||
included: IIndexMetric[];
|
||||
notIncluded: IIndexMetric[];
|
||||
} {
|
||||
const included: IIndexMetric[] = [];
|
||||
const notIncluded: IIndexMetric[] = [];
|
||||
|
||||
// Process UtilizedIndexes (Included in Current Policy)
|
||||
if (indexMetrics.UtilizedIndexes) {
|
||||
// Single indexes
|
||||
indexMetrics.UtilizedIndexes.SingleIndexes?.forEach((index) => {
|
||||
included.push({
|
||||
index: index.IndexSpec,
|
||||
impact: index.IndexImpactScore || "Utilized",
|
||||
section: "Included",
|
||||
path: index.IndexSpec,
|
||||
});
|
||||
});
|
||||
|
||||
// Composite indexes
|
||||
indexMetrics.UtilizedIndexes.CompositeIndexes?.forEach((index) => {
|
||||
const compositeSpec = index.IndexSpecs.join(", ");
|
||||
included.push({
|
||||
index: compositeSpec,
|
||||
impact: index.IndexImpactScore || "Utilized",
|
||||
section: "Included",
|
||||
composite: index.IndexSpecs.map((spec) => {
|
||||
const [path, order] = spec.trim().split(/\s+/);
|
||||
return {
|
||||
path: path.trim(),
|
||||
order: order?.toLowerCase() === "desc" ? "descending" : "ascending",
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Process PotentialIndexes (Not Included in Current Policy)
|
||||
if (indexMetrics.PotentialIndexes) {
|
||||
// Single indexes
|
||||
indexMetrics.PotentialIndexes.SingleIndexes?.forEach((index) => {
|
||||
notIncluded.push({
|
||||
index: index.IndexSpec,
|
||||
impact: index.IndexImpactScore || "Unknown",
|
||||
section: "Not Included",
|
||||
path: index.IndexSpec,
|
||||
});
|
||||
});
|
||||
|
||||
// Composite indexes
|
||||
indexMetrics.PotentialIndexes.CompositeIndexes?.forEach((index) => {
|
||||
const compositeSpec = index.IndexSpecs.join(", ");
|
||||
notIncluded.push({
|
||||
index: compositeSpec,
|
||||
impact: index.IndexImpactScore || "Unknown",
|
||||
section: "Not Included",
|
||||
composite: index.IndexSpecs.map((spec) => {
|
||||
const [path, order] = spec.trim().split(/\s+/);
|
||||
return {
|
||||
path: path.trim(),
|
||||
order: order?.toLowerCase() === "desc" ? "descending" : "ascending",
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { included, notIncluded };
|
||||
}
|
||||
|
||||
export const renderImpactDots = (impact: string): JSX.Element => {
|
||||
const style = useIndexAdvisorStyles();
|
||||
let count = 0;
|
||||
|
||||
if (impact === "High") {
|
||||
count = 3;
|
||||
} else if (impact === "Medium") {
|
||||
count = 2;
|
||||
} else if (impact === "Low") {
|
||||
count = 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.indexAdvisorImpactDots}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<CircleFilled key={i} className={style.indexAdvisorImpactDot} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,18 +3,21 @@ import QueryError from "Common/QueryError";
|
||||
import { IndeterminateProgressBar } from "Explorer/Controls/IndeterminateProgressBar";
|
||||
import { MessageBanner } from "Explorer/Controls/MessageBanner";
|
||||
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||
import useZoomLevel from "hooks/useZoomLevel";
|
||||
import React from "react";
|
||||
import { conditionalClass } from "Utils/StyleUtils";
|
||||
import RunQuery from "../../../../images/RunQuery.png";
|
||||
import { QueryResults } from "../../../Contracts/ViewModels";
|
||||
import { ErrorList } from "./ErrorList";
|
||||
import { ResultsView } from "./ResultsView";
|
||||
import useZoomLevel from "hooks/useZoomLevel";
|
||||
import { conditionalClass } from "Utils/StyleUtils";
|
||||
|
||||
export interface ResultsViewProps {
|
||||
isMongoDB: boolean;
|
||||
queryResults: QueryResults;
|
||||
executeQueryDocumentsPage: (firstItemIndex: number) => Promise<void>;
|
||||
queryEditorContent?: string;
|
||||
databaseId?: string;
|
||||
containerId?: string;
|
||||
}
|
||||
|
||||
interface QueryResultProps extends ResultsViewProps {
|
||||
@@ -49,6 +52,8 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
|
||||
queryResults,
|
||||
executeQueryDocumentsPage,
|
||||
isExecuting,
|
||||
databaseId,
|
||||
containerId,
|
||||
}: QueryResultProps): JSX.Element => {
|
||||
const styles = useQueryTabStyles();
|
||||
const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent);
|
||||
@@ -91,6 +96,9 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
|
||||
queryResults={queryResults}
|
||||
executeQueryDocumentsPage={executeQueryDocumentsPage}
|
||||
isMongoDB={isMongoDB}
|
||||
queryEditorContent={queryEditorContent}
|
||||
databaseId={databaseId}
|
||||
containerId={containerId}
|
||||
/>
|
||||
) : (
|
||||
<ExecuteQueryCallToAction />
|
||||
|
||||
@@ -52,8 +52,9 @@ describe("QueryTabComponent", () => {
|
||||
copilotVersion: "v3.0",
|
||||
},
|
||||
});
|
||||
|
||||
const propsMock: Readonly<IQueryTabComponentProps> = {
|
||||
collection: { databaseId: "CopilotSampleDB" },
|
||||
collection: { databaseId: "CopilotSampleDB", id: () => "CopilotContainer" },
|
||||
onTabAccessor: () => jest.fn(),
|
||||
isExecutionError: false,
|
||||
tabId: "mockTabId",
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useMonacoTheme } from "hooks/useTheme";
|
||||
import React, { Fragment, createRef } from "react";
|
||||
import "react-splitter-layout/lib/index.css";
|
||||
import { format } from "react-string-format";
|
||||
import create from "zustand";
|
||||
//TODO: Uncomment next two lines when query copilot is reinstated in DE
|
||||
// import QueryCommandIcon from "../../../../images/CopilotCommand.svg";
|
||||
// import LaunchCopilot from "../../../../images/CopilotTabIcon.svg";
|
||||
@@ -57,6 +58,20 @@ import { SaveQueryPane } from "../../Panes/SaveQueryPane/SaveQueryPane";
|
||||
import TabsBase from "../TabsBase";
|
||||
import "./QueryTabComponent.less";
|
||||
|
||||
export interface QueryMetadataStore {
|
||||
userQuery: string;
|
||||
databaseId: string;
|
||||
containerId: string;
|
||||
setMetadata: (query1: string, db: string, container: string) => void;
|
||||
}
|
||||
|
||||
export const useQueryMetadataStore = create<QueryMetadataStore>((set) => ({
|
||||
userQuery: "",
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }),
|
||||
}));
|
||||
|
||||
enum ToggleState {
|
||||
Result,
|
||||
QueryMetrics,
|
||||
@@ -264,6 +279,10 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
}
|
||||
|
||||
public onExecuteQueryClick = async (): Promise<void> => {
|
||||
const query1 = this.state.sqlQueryEditorContent;
|
||||
const db = this.props.collection.databaseId;
|
||||
const container = this.props.collection.id();
|
||||
useQueryMetadataStore.getState().setMetadata(query1, db, container);
|
||||
this._iterator = undefined;
|
||||
|
||||
setTimeout(async () => {
|
||||
@@ -780,6 +799,8 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
errors={this.props.copilotStore?.errors}
|
||||
isExecuting={this.props.copilotStore?.isExecuting}
|
||||
queryResults={this.props.copilotStore?.queryResults}
|
||||
databaseId={this.props.collection.databaseId}
|
||||
containerId={this.props.collection.id()}
|
||||
executeQueryDocumentsPage={(firstItemIndex: number) =>
|
||||
QueryDocumentsPerPage(
|
||||
firstItemIndex,
|
||||
@@ -795,6 +816,8 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
|
||||
errors={this.state.errors}
|
||||
isExecuting={this.state.isExecuting}
|
||||
queryResults={this.state.queryResults}
|
||||
databaseId={this.props.collection.databaseId}
|
||||
containerId={this.props.collection.id()}
|
||||
executeQueryDocumentsPage={(firstItemIndex: number) =>
|
||||
this._executeQueryDocumentsPage(firstItemIndex)
|
||||
}
|
||||
|
||||
170
src/Explorer/Tabs/QueryTab/ResultsView.test.tsx
Normal file
170
src/Explorer/Tabs/QueryTab/ResultsView.test.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { IndexAdvisorTab } from "Explorer/Tabs/QueryTab/ResultsView";
|
||||
import React from "react";
|
||||
|
||||
const mockReplace = jest.fn();
|
||||
const mockFetchAll = jest.fn();
|
||||
const mockRead = jest.fn();
|
||||
const mockLogConsoleProgress = jest.fn();
|
||||
const mockHandleError = jest.fn();
|
||||
|
||||
const indexMetricsResponse = {
|
||||
UtilizedIndexes: {
|
||||
SingleIndexes: [{ IndexSpec: "/foo/?", IndexImpactScore: "High" }],
|
||||
CompositeIndexes: [{ IndexSpecs: ["/baz/? DESC", "/qux/? ASC"], IndexImpactScore: "Low" }],
|
||||
},
|
||||
PotentialIndexes: {
|
||||
SingleIndexes: [{ IndexSpec: "/bar/?", IndexImpactScore: "Medium" }],
|
||||
CompositeIndexes: [] as Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>,
|
||||
},
|
||||
};
|
||||
|
||||
const mockQueryResults = {
|
||||
documents: [] as unknown[],
|
||||
hasMoreResults: false,
|
||||
itemCount: 0,
|
||||
firstItemIndex: 0,
|
||||
lastItemIndex: 0,
|
||||
requestCharge: 0,
|
||||
activityId: "test-activity-id",
|
||||
};
|
||||
|
||||
mockRead.mockResolvedValue({
|
||||
resource: {
|
||||
indexingPolicy: {
|
||||
automatic: true,
|
||||
indexingMode: "consistent",
|
||||
includedPaths: [{ path: "/*" }, { path: "/foo/?" }],
|
||||
excludedPaths: [],
|
||||
},
|
||||
partitionKey: "pk",
|
||||
},
|
||||
});
|
||||
|
||||
mockReplace.mockResolvedValue({
|
||||
resource: {
|
||||
indexingPolicy: {
|
||||
automatic: true,
|
||||
indexingMode: "consistent",
|
||||
includedPaths: [{ path: "/*" }],
|
||||
excludedPaths: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
jest.mock("Common/CosmosClient", () => ({
|
||||
client: () => ({
|
||||
database: () => ({
|
||||
container: () => ({
|
||||
items: {
|
||||
query: () => ({
|
||||
fetchAll: mockFetchAll,
|
||||
}),
|
||||
},
|
||||
read: mockRead,
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("./StylesAdvisor", () => ({
|
||||
useIndexAdvisorStyles: () => ({}),
|
||||
}));
|
||||
|
||||
jest.mock("../../../Utils/NotificationConsoleUtils", () => ({
|
||||
logConsoleProgress: (...args: unknown[]) => {
|
||||
mockLogConsoleProgress(...args);
|
||||
return () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../../Common/ErrorHandlingUtils", () => ({
|
||||
handleError: (...args: unknown[]) => mockHandleError(...args),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchAll.mockResolvedValue({ indexMetrics: indexMetricsResponse });
|
||||
});
|
||||
|
||||
describe("IndexAdvisorTab Basic Tests", () => {
|
||||
test("component renders without crashing", () => {
|
||||
const { container } = render(
|
||||
<IndexAdvisorTab queryEditorContent="SELECT * FROM c" databaseId="db1" containerId="col1" />,
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
test("renders component and handles missing parameters", () => {
|
||||
const { container } = render(<IndexAdvisorTab />);
|
||||
expect(container).toBeTruthy();
|
||||
// Should not crash when parameters are missing
|
||||
});
|
||||
|
||||
test("fetches index metrics with query results", async () => {
|
||||
render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="db1"
|
||||
containerId="col1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(mockFetchAll).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test("displays content after loading", async () => {
|
||||
render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="db1"
|
||||
containerId="col1"
|
||||
/>,
|
||||
);
|
||||
// Wait for the component to finish loading
|
||||
await waitFor(() => expect(mockFetchAll).toHaveBeenCalled());
|
||||
// Component should have rendered some content
|
||||
expect(screen.getByText(/Index Advisor/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls log console progress when fetching metrics", async () => {
|
||||
render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="db1"
|
||||
containerId="col1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(mockLogConsoleProgress).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test("handles error when fetch fails", async () => {
|
||||
mockFetchAll.mockRejectedValueOnce(new Error("fetch failed"));
|
||||
render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="db1"
|
||||
containerId="col1"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(mockHandleError).toHaveBeenCalled(), { timeout: 3000 });
|
||||
});
|
||||
|
||||
test("renders with all required props", () => {
|
||||
const { container } = render(
|
||||
<IndexAdvisorTab
|
||||
queryResults={mockQueryResults}
|
||||
queryEditorContent="SELECT * FROM c"
|
||||
databaseId="testDb"
|
||||
containerId="testContainer"
|
||||
/>,
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { CompositePath, IndexingPolicy } from "@azure/cosmos";
|
||||
import { FontIcon } from "@fluentui/react";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DataGrid,
|
||||
DataGridBody,
|
||||
DataGridCell,
|
||||
@@ -8,28 +11,45 @@ import {
|
||||
DataGridRow,
|
||||
SelectTabData,
|
||||
SelectTabEvent,
|
||||
Spinner,
|
||||
Tab,
|
||||
TabList,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableColumnDefinition,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
createTableColumn,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons";
|
||||
import { ArrowDownloadRegular, ChevronDown20Regular, ChevronRight20Regular, CopyRegular } from "@fluentui/react-icons";
|
||||
import copy from "clipboard-copy";
|
||||
import { HttpHeaders } from "Common/Constants";
|
||||
import MongoUtility from "Common/MongoUtility";
|
||||
import { QueryMetrics } from "Contracts/DataModels";
|
||||
import { QueryResults } from "Contracts/ViewModels";
|
||||
import { EditorReact } from "Explorer/Controls/Editor/EditorReact";
|
||||
import {
|
||||
parseIndexMetrics,
|
||||
renderImpactDots,
|
||||
type IndexMetricsResponse,
|
||||
} from "Explorer/Tabs/QueryTab/IndexAdvisorUtils";
|
||||
import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent";
|
||||
import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { userContext } from "UserContext";
|
||||
import copy from "clipboard-copy";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { logConsoleProgress } from "Utils/NotificationConsoleUtils";
|
||||
import create from "zustand";
|
||||
import { client } from "../../../Common/CosmosClient";
|
||||
import { handleError } from "../../../Common/ErrorHandlingUtils";
|
||||
import { sampleDataClient } from "../../../Common/SampleDataClient";
|
||||
import { ResultsViewProps } from "./QueryResultSection";
|
||||
|
||||
import { useIndexAdvisorStyles } from "./StylesAdvisor";
|
||||
enum ResultsTabs {
|
||||
Results = "results",
|
||||
QueryStats = "queryStats",
|
||||
IndexAdvisor = "indexadv",
|
||||
}
|
||||
|
||||
const ResultsTab: React.FC<ResultsViewProps> = ({ queryResults, isMongoDB, executeQueryDocumentsPage }) => {
|
||||
const styles = useQueryTabStyles();
|
||||
/* eslint-disable react/prop-types */
|
||||
@@ -523,14 +543,331 @@ const QueryStatsTab: React.FC<Pick<ResultsViewProps, "queryResults">> = ({ query
|
||||
);
|
||||
};
|
||||
|
||||
export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => {
|
||||
export interface IIndexMetric {
|
||||
index: string;
|
||||
impact: string;
|
||||
section: "Included" | "Not Included" | "Header";
|
||||
path?: string;
|
||||
composite?: { path: string; order: string }[];
|
||||
}
|
||||
export const IndexAdvisorTab: React.FC<{
|
||||
queryResults?: QueryResults;
|
||||
queryEditorContent?: string;
|
||||
databaseId?: string;
|
||||
containerId?: string;
|
||||
}> = ({ queryResults, queryEditorContent, databaseId, containerId }) => {
|
||||
const style = useIndexAdvisorStyles();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [indexMetrics, setIndexMetrics] = useState<IndexMetricsResponse | null>(null);
|
||||
const [showIncluded, setShowIncluded] = useState(true);
|
||||
const [showNotIncluded, setShowNotIncluded] = useState(true);
|
||||
const [selectedIndexes, setSelectedIndexes] = useState<IIndexMetric[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
const [updateMessageShown, setUpdateMessageShown] = useState(false);
|
||||
const [included, setIncludedIndexes] = useState<IIndexMetric[]>([]);
|
||||
const [notIncluded, setNotIncludedIndexes] = useState<IIndexMetric[]>([]);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [justUpdatedPolicy, setJustUpdatedPolicy] = useState(false);
|
||||
const indexingMetricsDocLink = "https://learn.microsoft.com/azure/cosmos-db/nosql/index-metrics";
|
||||
|
||||
const fetchIndexMetrics = async () => {
|
||||
if (!queryEditorContent || !databaseId || !containerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const clearMessage = logConsoleProgress(`Querying items with IndexMetrics in container ${containerId}`);
|
||||
try {
|
||||
const querySpec = {
|
||||
query: queryEditorContent,
|
||||
};
|
||||
|
||||
// Use sampleDataClient for CopilotSampleDB, regular client for other databases
|
||||
const cosmosClient = databaseId === "CopilotSampleDB" ? sampleDataClient() : client();
|
||||
|
||||
const sdkResponse = await cosmosClient
|
||||
.database(databaseId)
|
||||
.container(containerId)
|
||||
.items.query(querySpec, {
|
||||
populateIndexMetrics: true,
|
||||
})
|
||||
.fetchAll();
|
||||
|
||||
const parsedMetrics =
|
||||
typeof sdkResponse.indexMetrics === "string" ? JSON.parse(sdkResponse.indexMetrics) : sdkResponse.indexMetrics;
|
||||
|
||||
setIndexMetrics(parsedMetrics);
|
||||
} catch (error) {
|
||||
handleError(error, "queryItemsWithIndexMetrics", `Error querying items from ${containerId}`);
|
||||
} finally {
|
||||
clearMessage();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch index metrics when query results change (i.e., when Execute Query is clicked)
|
||||
useEffect(() => {
|
||||
if (queryEditorContent && databaseId && containerId && queryResults) {
|
||||
fetchIndexMetrics();
|
||||
}
|
||||
}, [queryResults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!indexMetrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { included, notIncluded } = parseIndexMetrics(indexMetrics);
|
||||
setIncludedIndexes(included);
|
||||
setNotIncludedIndexes(notIncluded);
|
||||
if (justUpdatedPolicy) {
|
||||
setJustUpdatedPolicy(false);
|
||||
} else {
|
||||
setUpdateMessageShown(false);
|
||||
}
|
||||
}, [indexMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
const allSelected =
|
||||
notIncluded.length > 0 && notIncluded.every((item) => selectedIndexes.some((s) => s.index === item.index));
|
||||
setSelectAll(allSelected);
|
||||
}, [selectedIndexes, notIncluded]);
|
||||
|
||||
const handleCheckboxChange = (indexObj: IIndexMetric, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIndexes((prev) => [...prev, indexObj]);
|
||||
} else {
|
||||
setSelectedIndexes((prev) => prev.filter((item) => item.index !== indexObj.index));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
setSelectAll(checked);
|
||||
setSelectedIndexes(checked ? notIncluded : []);
|
||||
};
|
||||
|
||||
const handleUpdatePolicy = async () => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const containerRef = client().database(databaseId).container(containerId);
|
||||
const { resource: containerDef } = await containerRef.read();
|
||||
|
||||
const newIncludedPaths = selectedIndexes
|
||||
.filter((index) => !index.composite)
|
||||
.map((index) => {
|
||||
return {
|
||||
path: index.path,
|
||||
};
|
||||
});
|
||||
|
||||
const newCompositeIndexes: CompositePath[][] = selectedIndexes
|
||||
.filter((index) => Array.isArray(index.composite))
|
||||
.map(
|
||||
(index) =>
|
||||
(index.composite as { path: string; order: string }[]).map((comp) => ({
|
||||
path: comp.path,
|
||||
order: comp.order === "descending" ? "descending" : "ascending",
|
||||
})) as CompositePath[],
|
||||
);
|
||||
|
||||
const updatedPolicy: IndexingPolicy = {
|
||||
...containerDef.indexingPolicy,
|
||||
includedPaths: [...(containerDef.indexingPolicy?.includedPaths || []), ...newIncludedPaths],
|
||||
compositeIndexes: [...(containerDef.indexingPolicy?.compositeIndexes || []), ...newCompositeIndexes],
|
||||
automatic: containerDef.indexingPolicy?.automatic ?? true,
|
||||
indexingMode: containerDef.indexingPolicy?.indexingMode ?? "consistent",
|
||||
excludedPaths: containerDef.indexingPolicy?.excludedPaths ?? [],
|
||||
};
|
||||
await containerRef.replace({
|
||||
id: containerId,
|
||||
partitionKey: containerDef.partitionKey,
|
||||
indexingPolicy: updatedPolicy,
|
||||
});
|
||||
useIndexingPolicyStore.getState().setIndexingPolicyFor(containerId, updatedPolicy);
|
||||
const selectedIndexSet = new Set(selectedIndexes.map((s) => s.index));
|
||||
const updatedNotIncluded: typeof notIncluded = [];
|
||||
const newlyIncluded: typeof included = [];
|
||||
for (const item of notIncluded) {
|
||||
if (selectedIndexSet.has(item.index)) {
|
||||
newlyIncluded.push(item);
|
||||
} else {
|
||||
updatedNotIncluded.push(item);
|
||||
}
|
||||
}
|
||||
const newIncluded = [...included, ...newlyIncluded];
|
||||
const newNotIncluded = updatedNotIncluded;
|
||||
setIncludedIndexes(newIncluded);
|
||||
setNotIncludedIndexes(newNotIncluded);
|
||||
setSelectedIndexes([]);
|
||||
setSelectAll(false);
|
||||
setUpdateMessageShown(true);
|
||||
setJustUpdatedPolicy(true);
|
||||
} catch (err) {
|
||||
console.error("Failed to update indexing policy:", err);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderRow = (item: IIndexMetric, index: number) => {
|
||||
const isHeader = item.section === "Header";
|
||||
const isNotIncluded = item.section === "Not Included";
|
||||
|
||||
return (
|
||||
<TableRow key={index}>
|
||||
<TableCell colSpan={2}>
|
||||
<div className={style.indexAdvisorGrid}>
|
||||
{isNotIncluded ? (
|
||||
<Checkbox
|
||||
checked={selectedIndexes.some((selected) => selected.index === item.index)}
|
||||
onChange={(_, data) => handleCheckboxChange(item, data.checked === true)}
|
||||
/>
|
||||
) : isHeader && item.index === "Not Included in Current Policy" && notIncluded.length > 0 ? (
|
||||
<Checkbox checked={selectAll} onChange={(_, data) => handleSelectAll(data.checked === true)} />
|
||||
) : (
|
||||
<div className={style.indexAdvisorCheckboxSpacer}></div>
|
||||
)}
|
||||
{isHeader ? (
|
||||
<span
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => {
|
||||
if (item.index === "Included in Current Policy") {
|
||||
setShowIncluded(!showIncluded);
|
||||
} else if (item.index === "Not Included in Current Policy") {
|
||||
setShowNotIncluded(!showNotIncluded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.index === "Included in Current Policy" ? (
|
||||
showIncluded ? (
|
||||
<ChevronDown20Regular />
|
||||
) : (
|
||||
<ChevronRight20Regular />
|
||||
)
|
||||
) : showNotIncluded ? (
|
||||
<ChevronDown20Regular />
|
||||
) : (
|
||||
<ChevronRight20Regular />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<div className={style.indexAdvisorChevronSpacer}></div>
|
||||
)}
|
||||
<div className={isHeader ? style.indexAdvisorRowBold : style.indexAdvisorRowNormal}>{item.index}</div>
|
||||
<div className={isHeader ? style.indexAdvisorRowImpactHeader : style.indexAdvisorRowImpact}>
|
||||
{!isHeader && item.impact}
|
||||
</div>
|
||||
<div>{!isHeader && renderImpactDots(item.impact)}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
const indexMetricItems = React.useMemo(() => {
|
||||
const items: IIndexMetric[] = [];
|
||||
items.push({ index: "Not Included in Current Policy", impact: "", section: "Header" });
|
||||
if (showNotIncluded) {
|
||||
notIncluded.forEach((item) => items.push({ ...item, section: "Not Included" }));
|
||||
}
|
||||
items.push({ index: "Included in Current Policy", impact: "", section: "Header" });
|
||||
if (showIncluded) {
|
||||
included.forEach((item) => items.push({ ...item, section: "Included" }));
|
||||
}
|
||||
return items;
|
||||
}, [included, notIncluded, showIncluded, showNotIncluded]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<Spinner
|
||||
size="small"
|
||||
style={
|
||||
{
|
||||
"--spinner-size": "16px",
|
||||
"--spinner-thickness": "2px",
|
||||
"--spinner-color": "#0078D4",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={style.indexAdvisorMessage}>
|
||||
{updateMessageShown ? (
|
||||
<>
|
||||
<span className={style.indexAdvisorSuccessIcon}>
|
||||
<FontIcon iconName="CheckMark" style={{ color: "white", fontSize: 12 }} />
|
||||
</span>
|
||||
<span>
|
||||
Your indexing policy has been updated with the new included paths. You may review the changes in Scale &
|
||||
Settings.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>
|
||||
Index Advisor uses Indexing Metrics to suggest query paths that, when included in your indexing policy,
|
||||
can improve the performance of this query by reducing RU costs and lowering latency.{" "}
|
||||
<a href={indexingMetricsDocLink} target="_blank" rel="noopener noreferrer">
|
||||
Learn more about Indexing Metrics
|
||||
</a>
|
||||
.{" "}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={style.indexAdvisorTitle}>Indexes analysis</div>
|
||||
<Table className={style.indexAdvisorTable}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={2}>
|
||||
<div className={style.indexAdvisorGrid}>
|
||||
<div className={style.indexAdvisorCheckboxSpacer}></div>
|
||||
<div className={style.indexAdvisorChevronSpacer}></div>
|
||||
<div>Index</div>
|
||||
<div>
|
||||
<span style={{ whiteSpace: "nowrap" }}>Estimated Impact</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{indexMetricItems.map(renderRow)}</TableBody>
|
||||
</Table>
|
||||
{selectedIndexes.length > 0 && (
|
||||
<div className={style.indexAdvisorButtonBar}>
|
||||
{isUpdating ? (
|
||||
<div className={style.indexAdvisorButtonSpinner}>
|
||||
<Spinner size="tiny" />{" "}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleUpdatePolicy} className={style.indexAdvisorButton}>
|
||||
Update Indexing Policy with selected index(es)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const ResultsView: React.FC<ResultsViewProps> = ({
|
||||
isMongoDB,
|
||||
queryResults,
|
||||
executeQueryDocumentsPage,
|
||||
queryEditorContent,
|
||||
databaseId,
|
||||
containerId,
|
||||
}) => {
|
||||
const styles = useQueryTabStyles();
|
||||
const [activeTab, setActiveTab] = useState<ResultsTabs>(ResultsTabs.Results);
|
||||
|
||||
const onTabSelect = useCallback((event: SelectTabEvent, data: SelectTabData) => {
|
||||
setActiveTab(data.value as ResultsTabs);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div data-test="QueryTab/ResultsPane/ResultsView" className={styles.queryResultsTabPanel}>
|
||||
<TabList selectedValue={activeTab} onTabSelect={onTabSelect}>
|
||||
@@ -548,6 +885,13 @@ export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResult
|
||||
>
|
||||
Query Stats
|
||||
</Tab>
|
||||
<Tab
|
||||
data-test="QueryTab/ResultsPane/ResultsView/IndexAdvisorTab"
|
||||
id={ResultsTabs.IndexAdvisor}
|
||||
value={ResultsTabs.IndexAdvisor}
|
||||
>
|
||||
Index Advisor
|
||||
</Tab>
|
||||
</TabList>
|
||||
<div className={styles.queryResultsTabContentContainer}>
|
||||
{activeTab === ResultsTabs.Results && (
|
||||
@@ -558,7 +902,30 @@ export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResult
|
||||
/>
|
||||
)}
|
||||
{activeTab === ResultsTabs.QueryStats && <QueryStatsTab queryResults={queryResults} />}
|
||||
{activeTab === ResultsTabs.IndexAdvisor && (
|
||||
<IndexAdvisorTab
|
||||
queryResults={queryResults}
|
||||
queryEditorContent={queryEditorContent}
|
||||
databaseId={databaseId}
|
||||
containerId={containerId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export interface IndexingPolicyStore {
|
||||
indexingPolicies: { [containerId: string]: IndexingPolicy };
|
||||
setIndexingPolicyFor: (containerId: string, indexingPolicy: IndexingPolicy) => void;
|
||||
}
|
||||
|
||||
export const useIndexingPolicyStore = create<IndexingPolicyStore>((set) => ({
|
||||
indexingPolicies: {},
|
||||
setIndexingPolicyFor: (containerId, indexingPolicy) =>
|
||||
set((state) => ({
|
||||
indexingPolicies: {
|
||||
...state.indexingPolicies,
|
||||
[containerId]: { ...indexingPolicy },
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
95
src/Explorer/Tabs/QueryTab/StylesAdvisor.ts
Normal file
95
src/Explorer/Tabs/QueryTab/StylesAdvisor.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { makeStyles } from "@fluentui/react-components";
|
||||
export type IndexAdvisorStyles = ReturnType<typeof useIndexAdvisorStyles>;
|
||||
export const useIndexAdvisorStyles = makeStyles({
|
||||
indexAdvisorMessage: {
|
||||
padding: "1rem",
|
||||
fontSize: "1.2rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
indexAdvisorSuccessIcon: {
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#107C10",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
indexAdvisorTitle: {
|
||||
padding: "1rem",
|
||||
fontSize: "1.3rem",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
indexAdvisorTable: {
|
||||
display: "block",
|
||||
alignItems: "center",
|
||||
marginBottom: "7rem",
|
||||
},
|
||||
indexAdvisorGrid: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "30px 30px 1fr 50px 120px",
|
||||
alignItems: "center",
|
||||
gap: "15px",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
indexAdvisorCheckboxSpacer: {
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
},
|
||||
indexAdvisorChevronSpacer: {
|
||||
width: "24px",
|
||||
},
|
||||
indexAdvisorRowBold: {
|
||||
fontWeight: "bold",
|
||||
},
|
||||
indexAdvisorRowNormal: {
|
||||
fontWeight: "normal",
|
||||
},
|
||||
indexAdvisorRowImpactHeader: {
|
||||
fontSize: 0,
|
||||
},
|
||||
indexAdvisorRowImpact: {
|
||||
fontWeight: "normal",
|
||||
},
|
||||
indexAdvisorImpactDot: {
|
||||
color: "#0078D4",
|
||||
fontSize: "12px",
|
||||
display: "inline-flex",
|
||||
},
|
||||
indexAdvisorImpactDots: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
},
|
||||
indexAdvisorButtonBar: {
|
||||
padding: "1rem",
|
||||
marginTop: "-7rem",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
indexAdvisorButtonSpinner: {
|
||||
marginTop: "1rem",
|
||||
minWidth: "320px",
|
||||
minHeight: "40px",
|
||||
display: "flex",
|
||||
alignItems: "left",
|
||||
justifyContent: "left",
|
||||
marginLeft: "10rem",
|
||||
},
|
||||
indexAdvisorButton: {
|
||||
backgroundColor: "#0078D4",
|
||||
color: "white",
|
||||
padding: "8px 16px",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
marginTop: "1rem",
|
||||
fontSize: "1rem",
|
||||
fontWeight: 500,
|
||||
transition: "background 0.2s",
|
||||
":hover": {
|
||||
backgroundColor: "#005a9e",
|
||||
},
|
||||
},
|
||||
});
|
||||
15
src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts
Normal file
15
src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import create from "zustand";
|
||||
|
||||
interface QueryMetadataStore {
|
||||
userQuery: string;
|
||||
databaseId: string;
|
||||
containerId: string;
|
||||
setMetadata: (query1: string, db: string, container: string) => void;
|
||||
}
|
||||
|
||||
export const useQueryMetadataStore = create<QueryMetadataStore>((set) => ({
|
||||
userQuery: "",
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }),
|
||||
}));
|
||||
@@ -125,10 +125,7 @@ const App = (): JSX.Element => {
|
||||
<KeyboardShortcutRoot>
|
||||
<div className="flexContainer" aria-hidden="false">
|
||||
{userContext.features.enableContainerCopy && userContext.apiType === "SQL" ? (
|
||||
<>
|
||||
<ContainerCopyPanel explorer={explorer} />
|
||||
<SidePanel />
|
||||
</>
|
||||
<ContainerCopyPanel explorer={explorer} />
|
||||
) : (
|
||||
<DivExplorer explorer={explorer} />
|
||||
)}
|
||||
|
||||
@@ -470,15 +470,6 @@ export class DataExplorer {
|
||||
return this.frame.getByTestId("notification-console/header-status");
|
||||
}
|
||||
|
||||
async getDropdownItemByName(name: string, ariaLabel?: string): Promise<Locator> {
|
||||
const dropdownItemsWrapper = this.frame.locator("div.ms-Dropdown-items");
|
||||
if (ariaLabel) {
|
||||
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(ariaLabel);
|
||||
}
|
||||
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
|
||||
return containerDropdownItems.filter({ hasText: name });
|
||||
}
|
||||
|
||||
/** Waits for the Data Explorer app to load */
|
||||
static async waitForExplorer(page: Page) {
|
||||
const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle();
|
||||
|
||||
145
test/sql/indexAdvisor.spec.ts
Normal file
145
test/sql/indexAdvisor.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { CommandBarButton, DataExplorer, TestAccount } from "../fx";
|
||||
import { createTestSQLContainer, TestContainerContext } from "../testData";
|
||||
|
||||
// Test container context for setup and cleanup
|
||||
let testContainer: TestContainerContext;
|
||||
let DATABASE_ID: string;
|
||||
let CONTAINER_ID: string;
|
||||
|
||||
// Set up test database and container with data before all tests
|
||||
test.beforeAll(async () => {
|
||||
testContainer = await createTestSQLContainer(true);
|
||||
DATABASE_ID = testContainer.database.id;
|
||||
CONTAINER_ID = testContainer.container.id;
|
||||
});
|
||||
|
||||
// Clean up test database after all tests
|
||||
test.afterAll(async () => {
|
||||
if (testContainer) {
|
||||
await testContainer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to set up query tab and navigate to Index Advisor
|
||||
async function setupIndexAdvisorTab(page: Page, customQuery?: string) {
|
||||
const explorer = await DataExplorer.open(page, TestAccount.SQL);
|
||||
const databaseNode = await explorer.waitForNode(DATABASE_ID);
|
||||
await databaseNode.expand();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const containerNode = await explorer.waitForNode(`${DATABASE_ID}/${CONTAINER_ID}`);
|
||||
await containerNode.openContextMenu();
|
||||
await containerNode.contextMenuItem("New SQL Query").click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const queryTab = explorer.queryTab("tab0");
|
||||
const queryEditor = queryTab.editor();
|
||||
await queryEditor.locator.waitFor({ timeout: 30 * 1000 });
|
||||
await queryTab.executeCTA.waitFor();
|
||||
|
||||
if (customQuery) {
|
||||
await queryEditor.locator.click();
|
||||
await queryEditor.setText(customQuery);
|
||||
}
|
||||
|
||||
const executeQueryButton = explorer.commandBarButton(CommandBarButton.ExecuteQuery);
|
||||
await executeQueryButton.click();
|
||||
await expect(queryTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
|
||||
|
||||
const indexAdvisorTab = queryTab.resultsView.getByTestId("QueryTab/ResultsPane/ResultsView/IndexAdvisorTab");
|
||||
await indexAdvisorTab.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
return { explorer, queryTab, indexAdvisorTab };
|
||||
}
|
||||
|
||||
test("Index Advisor tab loads without errors", async ({ page }) => {
|
||||
const { indexAdvisorTab } = await setupIndexAdvisorTab(page);
|
||||
await expect(indexAdvisorTab).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
test("Verify UI sections are collapsible", async ({ page }) => {
|
||||
const { explorer } = await setupIndexAdvisorTab(page);
|
||||
|
||||
// Verify both section headers exist
|
||||
const includedHeader = explorer.frame.getByText("Included in Current Policy", { exact: true });
|
||||
const notIncludedHeader = explorer.frame.getByText("Not Included in Current Policy", { exact: true });
|
||||
|
||||
await expect(includedHeader).toBeVisible();
|
||||
await expect(notIncludedHeader).toBeVisible();
|
||||
|
||||
// Test collapsibility by checking if chevron/arrow icon changes state
|
||||
// Both sections should be expandable/collapsible regardless of content
|
||||
await includedHeader.click();
|
||||
await page.waitForTimeout(300);
|
||||
await includedHeader.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await notIncludedHeader.click();
|
||||
await page.waitForTimeout(300);
|
||||
await notIncludedHeader.click();
|
||||
await page.waitForTimeout(300);
|
||||
});
|
||||
|
||||
test("Verify SDK response structure - Case 1: Empty response", async ({ page }) => {
|
||||
const { explorer } = await setupIndexAdvisorTab(page);
|
||||
|
||||
// Verify both section headers still exist even with no data
|
||||
await expect(explorer.frame.getByText("Included in Current Policy", { exact: true })).toBeVisible();
|
||||
await expect(explorer.frame.getByText("Not Included in Current Policy", { exact: true })).toBeVisible();
|
||||
|
||||
// Verify table headers
|
||||
const table = explorer.frame.locator("table");
|
||||
await expect(table.getByText("Index", { exact: true })).toBeVisible();
|
||||
await expect(table.getByText("Estimated Impact", { exact: true })).toBeVisible();
|
||||
|
||||
// Verify "Update Indexing Policy" button is NOT visible when there are no potential indexes
|
||||
const updateButton = explorer.frame.getByRole("button", { name: /Update Indexing Policy/i });
|
||||
await expect(updateButton).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Verify index suggestions and apply potential index", async ({ page }) => {
|
||||
const customQuery = 'SELECT * FROM c WHERE c.partitionKey = "partition_1" ORDER BY c.randomData';
|
||||
const { explorer } = await setupIndexAdvisorTab(page, customQuery);
|
||||
|
||||
// Wait for Index Advisor to process the query
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify "Not Included in Current Policy" section has suggestions
|
||||
const notIncludedHeader = explorer.frame.getByText("Not Included in Current Policy", { exact: true });
|
||||
await expect(notIncludedHeader).toBeVisible();
|
||||
|
||||
// Find the checkbox for the suggested composite index
|
||||
// The composite index should be /partitionKey ASC, /randomData ASC
|
||||
const checkboxes = explorer.frame.locator('input[type="checkbox"]');
|
||||
const checkboxCount = await checkboxes.count();
|
||||
|
||||
// Should have at least one checkbox for the potential index
|
||||
expect(checkboxCount).toBeGreaterThan(0);
|
||||
|
||||
// Select the first checkbox (the high-impact composite index)
|
||||
await checkboxes.first().check();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify "Update Indexing Policy" button becomes visible
|
||||
const updateButton = explorer.frame.getByRole("button", { name: /Update Indexing Policy/i });
|
||||
await expect(updateButton).toBeVisible();
|
||||
|
||||
// Click the "Update Indexing Policy" button
|
||||
await updateButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify success message appears
|
||||
const successMessage = explorer.frame.getByText(/Your indexing policy has been updated with the new included paths/i);
|
||||
await expect(successMessage).toBeVisible();
|
||||
|
||||
// Verify the message mentions reviewing changes in Scale & Settings
|
||||
const reviewMessage = explorer.frame.getByText(/You may review the changes in Scale & Settings/i);
|
||||
await expect(reviewMessage).toBeVisible();
|
||||
|
||||
// Verify the checkmark icon is shown
|
||||
const checkmarkIcon = explorer.frame.locator('[data-icon-name="CheckMark"]');
|
||||
await expect(checkmarkIcon).toBeVisible();
|
||||
});
|
||||
@@ -9,7 +9,7 @@ let queryTab: QueryTab = null!;
|
||||
let queryEditor: Editor = null!;
|
||||
|
||||
test.beforeAll("Create Test Database", async () => {
|
||||
context = await createTestSQLContainer({ includeTestData: true });
|
||||
context = await createTestSQLContainer(true);
|
||||
});
|
||||
|
||||
test.beforeEach("Open new query tab", async ({ page }) => {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { expect, Page, test } from "@playwright/test";
|
||||
import { DataExplorer, TestAccount } from "../../fx";
|
||||
import { createTestSQLContainer, TestContainerContext } from "../../testData";
|
||||
|
||||
test.describe("Change Partition Key", () => {
|
||||
let pageInstance: Page;
|
||||
let context: TestContainerContext = null!;
|
||||
let explorer: DataExplorer = null!;
|
||||
const newPartitionKeyPath = "/newPartitionKey";
|
||||
const newContainerId = "testcontainer_1";
|
||||
|
||||
test.beforeAll("Create Test Database", async () => {
|
||||
context = await createTestSQLContainer();
|
||||
});
|
||||
|
||||
test.beforeEach("Open container settings", async ({ page }) => {
|
||||
pageInstance = page;
|
||||
explorer = await DataExplorer.open(page, TestAccount.SQL);
|
||||
|
||||
// Click Scale & Settings and open Partition Key tab
|
||||
await explorer.openScaleAndSettings(context);
|
||||
const PartitionKeyTab = explorer.frame.getByTestId("settings-tab-header/PartitionKeyTab");
|
||||
await PartitionKeyTab.click();
|
||||
});
|
||||
|
||||
test.afterAll("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();
|
||||
|
||||
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();
|
||||
|
||||
// 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);
|
||||
|
||||
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();
|
||||
changePkPanel.getByTestId("new-container-sub-partition-key-input-0").fill("/customerId");
|
||||
|
||||
await changePkPanel.getByTestId("Panel/OkButton").click();
|
||||
|
||||
await pageInstance.waitForLoadState("networkidle");
|
||||
await expect(changePkPanel).not.toBeVisible({ timeout: 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");
|
||||
|
||||
const jobRow = explorer.frame.locator(".ms-ProgressIndicator-itemDescription");
|
||||
await expect(jobRow.getByText("Completed")).toBeVisible({ timeout: 30 * 1000 });
|
||||
|
||||
const newContainerNode = await explorer.waitForContainerNode(context.database.id, newContainerId);
|
||||
expect(newContainerNode).not.toBeNull();
|
||||
|
||||
// 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 containerDropdownItem = await explorer.getDropdownItemByName(newContainerId, "Existing Containers");
|
||||
await containerDropdownItem.click();
|
||||
|
||||
await changePkPanel.getByTestId("Panel/OkButton").click();
|
||||
await explorer.frame.getByRole("button", { name: "Cancel" }).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 });
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ test.describe("Autoscale and Manual throughput", () => {
|
||||
let explorer: DataExplorer = null!;
|
||||
|
||||
test.beforeAll("Create Test Database", async () => {
|
||||
context = await createTestSQLContainer({ includeTestData: true });
|
||||
context = await createTestSQLContainer(true);
|
||||
});
|
||||
|
||||
test.beforeEach("Open container settings", async ({ page }) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ test.describe("Settings under Scale & Settings", () => {
|
||||
let explorer: DataExplorer = null!;
|
||||
|
||||
test.beforeAll("Create Test Database", async () => {
|
||||
context = await createTestSQLContainer({ includeTestData: true });
|
||||
context = await createTestSQLContainer(true);
|
||||
});
|
||||
|
||||
test.beforeEach("Open Settings tab under Scale & Settings", async ({ page }) => {
|
||||
|
||||
@@ -74,18 +74,8 @@ export class TestContainerContext {
|
||||
}
|
||||
}
|
||||
|
||||
type createTestSqlContainerConfig = {
|
||||
includeTestData?: boolean;
|
||||
partitionKey?: string;
|
||||
databaseName?: string;
|
||||
};
|
||||
|
||||
export async function createTestSQLContainer({
|
||||
includeTestData = false,
|
||||
partitionKey = "/partitionKey",
|
||||
databaseName = "",
|
||||
}: createTestSqlContainerConfig = {}) {
|
||||
const databaseId = databaseName ? databaseName : generateUniqueName("db");
|
||||
export async function createTestSQLContainer(includeTestData?: boolean) {
|
||||
const databaseId = generateUniqueName("db");
|
||||
const containerId = "testcontainer"; // A unique container name isn't needed because the database is unique
|
||||
const credentials = getAzureCLICredentials();
|
||||
const adaptedCredentials = new AzureIdentityCredentialAdapter(credentials);
|
||||
@@ -114,7 +104,7 @@ export async function createTestSQLContainer({
|
||||
try {
|
||||
const { container } = await database.containers.createIfNotExists({
|
||||
id: containerId,
|
||||
partitionKey,
|
||||
partitionKey: "/partitionKey",
|
||||
});
|
||||
if (includeTestData) {
|
||||
const batchCount = TestData.length / 100;
|
||||
|
||||
Reference in New Issue
Block a user