diff --git a/src/Common/dataAccess/createStoredProcedure.ts b/src/Common/dataAccess/createStoredProcedure.ts index ab7839f3e..28f67846b 100644 --- a/src/Common/dataAccess/createStoredProcedure.ts +++ b/src/Common/dataAccess/createStoredProcedure.ts @@ -9,7 +9,7 @@ import { SqlStoredProcedureCreateUpdateParameters, SqlStoredProcedureResource, } from "../../Utils/arm/generatedClients/cosmos/types"; -import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; +import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; import { client } from "../CosmosClient"; import { handleError } from "../ErrorHandlingUtils"; @@ -20,6 +20,7 @@ export async function createStoredProcedure( ): Promise { const clearMessage = logConsoleProgress(`Creating stored procedure ${storedProcedure.id}`); try { + let resource: StoredProcedureDefinition & Resource; if ( userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations && @@ -60,14 +61,16 @@ export async function createStoredProcedure( storedProcedure.id, createSprocParams, ); - return rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource); + resource = rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource); + } else { + const response = await client() + .database(databaseId) + .container(collectionId) + .scripts.storedProcedures.create(storedProcedure); + resource = response.resource; } - - const response = await client() - .database(databaseId) - .container(collectionId) - .scripts.storedProcedures.create(storedProcedure); - return response?.resource; + logConsoleInfo(`Successfully created stored procedure ${storedProcedure.id}`); + return resource; } catch (error) { handleError(error, "CreateStoredProcedure", `Error while creating stored procedure ${storedProcedure.id}`); throw error; diff --git a/src/Common/dataAccess/createTrigger.ts b/src/Common/dataAccess/createTrigger.ts index dd4ec1af5..c1cda22be 100644 --- a/src/Common/dataAccess/createTrigger.ts +++ b/src/Common/dataAccess/createTrigger.ts @@ -3,7 +3,7 @@ import { AuthType } from "../../AuthType"; import { userContext } from "../../UserContext"; import { createUpdateSqlTrigger, getSqlTrigger } from "../../Utils/arm/generatedClients/cosmos/sqlResources"; import { SqlTriggerCreateUpdateParameters, SqlTriggerResource } from "../../Utils/arm/generatedClients/cosmos/types"; -import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; +import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; import { client } from "../CosmosClient"; import { handleError } from "../ErrorHandlingUtils"; @@ -14,6 +14,7 @@ export async function createTrigger( ): Promise { const clearMessage = logConsoleProgress(`Creating trigger ${trigger.id}`); try { + let resource: SqlTriggerResource | TriggerDefinition; if ( userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations && @@ -52,14 +53,16 @@ export async function createTrigger( trigger.id, createTriggerParams, ); - return rpResponse && rpResponse.properties?.resource; + resource = rpResponse && rpResponse.properties?.resource; + } else { + const sdkResponse = await client() + .database(databaseId) + .container(collectionId) + .scripts.triggers.create(trigger as unknown as TriggerDefinition); // TODO: TypeScript does not like the SQL SDK trigger type + resource = sdkResponse.resource; } - - const response = await client() - .database(databaseId) - .container(collectionId) - .scripts.triggers.create(trigger as unknown as TriggerDefinition); // TODO: TypeScript does not like the SQL SDK trigger type - return response.resource; + logConsoleInfo(`Successfully created trigger ${trigger.id}`); + return resource; } catch (error) { handleError(error, "CreateTrigger", `Error while creating trigger ${trigger.id}`); throw error; diff --git a/src/Common/dataAccess/createUserDefinedFunction.ts b/src/Common/dataAccess/createUserDefinedFunction.ts index 3d1bca86e..c2ac4c489 100644 --- a/src/Common/dataAccess/createUserDefinedFunction.ts +++ b/src/Common/dataAccess/createUserDefinedFunction.ts @@ -9,7 +9,7 @@ import { SqlUserDefinedFunctionCreateUpdateParameters, SqlUserDefinedFunctionResource, } from "../../Utils/arm/generatedClients/cosmos/types"; -import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; +import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; import { client } from "../CosmosClient"; import { handleError } from "../ErrorHandlingUtils"; @@ -20,6 +20,7 @@ export async function createUserDefinedFunction( ): Promise { const clearMessage = logConsoleProgress(`Creating user defined function ${userDefinedFunction.id}`); try { + let resource: UserDefinedFunctionDefinition & Resource; if ( userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations && @@ -60,14 +61,17 @@ export async function createUserDefinedFunction( userDefinedFunction.id, createUDFParams, ); - return rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource); - } - const response = await client() - .database(databaseId) - .container(collectionId) - .scripts.userDefinedFunctions.create(userDefinedFunction); - return response?.resource; + resource = rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource); + } else { + const response = await client() + .database(databaseId) + .container(collectionId) + .scripts.userDefinedFunctions.create(userDefinedFunction); + resource = response.resource; + } + logConsoleInfo(`Successfully created user defined function ${userDefinedFunction.id}`); + return resource; } catch (error) { handleError( error, diff --git a/src/Common/dataAccess/deleteStoredProcedure.ts b/src/Common/dataAccess/deleteStoredProcedure.ts index 403b707ff..61ad16127 100644 --- a/src/Common/dataAccess/deleteStoredProcedure.ts +++ b/src/Common/dataAccess/deleteStoredProcedure.ts @@ -28,6 +28,7 @@ export async function deleteStoredProcedure( } else { await client().database(databaseId).container(collectionId).scripts.storedProcedure(storedProcedureId).delete(); } + logConsoleProgress(`Successfully deleted stored procedure ${storedProcedureId}`); } catch (error) { handleError(error, "DeleteStoredProcedure", `Error while deleting stored procedure ${storedProcedureId}`); throw error; diff --git a/src/Common/dataAccess/deleteTrigger.ts b/src/Common/dataAccess/deleteTrigger.ts index 22b77f009..568f4cefe 100644 --- a/src/Common/dataAccess/deleteTrigger.ts +++ b/src/Common/dataAccess/deleteTrigger.ts @@ -24,6 +24,7 @@ export async function deleteTrigger(databaseId: string, collectionId: string, tr } else { await client().database(databaseId).container(collectionId).scripts.trigger(triggerId).delete(); } + logConsoleProgress(`Successfully deleted trigger ${triggerId}`); } catch (error) { handleError(error, "DeleteTrigger", `Error while deleting trigger ${triggerId}`); throw error; diff --git a/src/Common/dataAccess/deleteUserDefinedFunction.ts b/src/Common/dataAccess/deleteUserDefinedFunction.ts index ee70b803c..d551cfbf0 100644 --- a/src/Common/dataAccess/deleteUserDefinedFunction.ts +++ b/src/Common/dataAccess/deleteUserDefinedFunction.ts @@ -24,6 +24,7 @@ export async function deleteUserDefinedFunction(databaseId: string, collectionId } else { await client().database(databaseId).container(collectionId).scripts.userDefinedFunction(id).delete(); } + logConsoleProgress(`Successfully deleted user defined function ${id}`); } catch (error) { handleError(error, "DeleteUserDefinedFunction", `Error while deleting user defined function ${id}`); throw error; diff --git a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts index 27175de68..d63f0cfad 100644 --- a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts +++ b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts @@ -25,7 +25,18 @@ export default { subscriptionDropdownPlaceholder: "Select a subscription", sourceAccountDropdownLabel: "Account", sourceAccountDropdownPlaceholder: "Select an account", - migrationTypeCheckboxLabel: "Copy container in offline mode", + 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).", + }, + }, // Select Source and Target Containers Screen selectSourceAndTargetContainersDescription: diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.test.tsx new file mode 100644 index 000000000..50fff3f72 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.test.tsx @@ -0,0 +1,241 @@ +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(); + + 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(); + + 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(); + + 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", () => { + const { container } = render(); + + expect(container.querySelector("[data-test='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, + }, + }); + + const { container } = render(); + + expect(container.querySelector("[data-test='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(); + + 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(); + + 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(); + + const choiceGroup = screen.getByRole("radiogroup"); + expect(choiceGroup).toBeInTheDocument(); + expect(choiceGroup).toHaveAttribute("aria-labelledby", "migrationTypeChoiceGroup"); + }); + + it("should have proper radio button labels", () => { + render(); + + 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(); + + 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(); + + expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.tsx new file mode 100644 index 000000000..35c2d6a63 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.tsx @@ -0,0 +1,77 @@ +/* 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%" } }, + }, +]; + +const choiceGroupStyles = { + flexContainer: { display: "flex" as const }, + root: { + selectors: { + ".ms-ChoiceField": { + color: "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + color: "var(--colorNeutralForeground1)", + }, + }, + }, +}; + +export const MigrationType: React.FC = 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 ( + + + + + {selectedKeyContent && ( + + + + + + )} + + ); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.test.tsx deleted file mode 100644 index 67289fe39..000000000 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -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(); - - expect(container.firstChild).toMatchSnapshot(); - }); - - it("should render in checked state", () => { - const { container } = render(); - - expect(container.firstChild).toMatchSnapshot(); - }); - - it("should display the correct label text", () => { - render(); - - 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(); - - const checkbox = screen.getByRole("checkbox"); - expect(checkbox).toBeChecked(); - expect(checkbox).toHaveAttribute("checked"); - }); - }); - - describe("FluentUI Integration", () => { - it("should render FluentUI Checkbox component correctly", () => { - render(); - - const checkbox = screen.getByRole("checkbox"); - expect(checkbox).toBeInTheDocument(); - expect(checkbox).toHaveAttribute("type", "checkbox"); - }); - - it("should render FluentUI Stack component correctly", () => { - render(); - - const stackContainer = document.querySelector(".migrationTypeRow"); - expect(stackContainer).toBeInTheDocument(); - }); - - it("should apply FluentUI Stack horizontal alignment correctly", () => { - const { container } = render(); - - const stackContainer = container.querySelector(".migrationTypeRow"); - expect(stackContainer).toBeInTheDocument(); - }); - }); -}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx deleted file mode 100644 index 4e8ae6946..000000000 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* eslint-disable react/prop-types */ -/* eslint-disable react/display-name */ -import { Checkbox, ICheckboxStyles, Stack } from "@fluentui/react"; -import React from "react"; -import ContainerCopyMessages from "../../../../ContainerCopyMessages"; - -interface MigrationTypeCheckboxProps { - checked: boolean; - onChange: (_ev?: React.FormEvent, checked?: boolean) => void; -} - -const checkboxStyles: ICheckboxStyles = { - text: { color: "var(--colorNeutralForeground1)" }, - checkbox: { borderColor: "var(--colorNeutralStroke1)" }, - root: { - selectors: { - ":hover .ms-Checkbox-text": { color: "var(--colorNeutralForeground1)" }, - }, - }, -}; - -export const MigrationTypeCheckbox: React.FC = React.memo(({ checked, onChange }) => { - return ( - - - - ); -}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationType.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationType.test.tsx.snap new file mode 100644 index 000000000..1986f7540 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationType.test.tsx.snap @@ -0,0 +1,109 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MigrationType Component Rendering should render migration type component with radio buttons 1`] = ` +
+
+
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+
+ +
+

+ 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 + + 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 + + . +

+
+
+
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationTypeCheckbox.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationTypeCheckbox.test.tsx.snap deleted file mode 100644 index db0a71b75..000000000 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationTypeCheckbox.test.tsx.snap +++ /dev/null @@ -1,82 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`MigrationTypeCheckbox Component Rendering should render in checked state 1`] = ` -
-
- - -
-
-`; - -exports[`MigrationTypeCheckbox Component Rendering should render with default props (unchecked state) 1`] = ` -
-
- - -
-
-`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx index 5fb556c3c..65529338e 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx @@ -1,5 +1,5 @@ import "@testing-library/jest-dom"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import React from "react"; import { useCopyJobContext } from "../../../Context/CopyJobContext"; import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; @@ -18,19 +18,8 @@ jest.mock("./Components/AccountDropdown", () => ({ AccountDropdown: jest.fn(() =>
Account Dropdown
), })); -jest.mock("./Components/MigrationTypeCheckbox", () => ({ - MigrationTypeCheckbox: jest.fn(({ checked, onChange }: { checked: boolean; onChange: () => void }) => ( -
- - Copy container in offline mode -
- )), +jest.mock("./Components/MigrationType", () => ({ + MigrationType: jest.fn(() =>
Migration Type
), })); describe("SelectAccount", () => { @@ -83,7 +72,7 @@ describe("SelectAccount", () => { expect(screen.getByTestId("subscription-dropdown")).toBeInTheDocument(); expect(screen.getByTestId("account-dropdown")).toBeInTheDocument(); - expect(screen.getByTestId("migration-type-checkbox")).toBeInTheDocument(); + expect(screen.getByTestId("migration-type")).toBeInTheDocument(); }); it("should render correctly with snapshot", () => { @@ -93,78 +82,20 @@ describe("SelectAccount", () => { }); describe("Migration Type Functionality", () => { - it("should display migration type checkbox as unchecked when migrationType is Online", () => { - (useCopyJobContext as jest.Mock).mockReturnValue({ - ...defaultContextValue, - copyJobState: { - ...defaultContextValue.copyJobState, - migrationType: CopyJobMigrationType.Online, - }, - }); - + it("should render migration type component", () => { render(); - 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(); - - 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(); - - 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, - }); + const migrationTypeComponent = screen.getByTestId("migration-type"); + expect(migrationTypeComponent).toBeInTheDocument(); }); }); describe("Performance and Optimization", () => { - it("should maintain referential equality of handler functions between renders", async () => { + it("should render without performance issues", () => { const { rerender } = render(); - - const migrationCheckbox = (await import("./Components/MigrationTypeCheckbox")).MigrationTypeCheckbox as jest.Mock; - const firstRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange; - rerender(); - const secondRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange; - - expect(firstRenderHandler).toBe(secondRenderHandler); + expect(screen.getByTestId("migration-type")).toBeInTheDocument(); }); }); }); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx index 1d7715f48..f4a0dcee3 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx @@ -1,24 +1,11 @@ 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 { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox"; +import { MigrationType } from "./Components/MigrationType"; import { SubscriptionDropdown } from "./Components/SubscriptionDropdown"; const SelectAccount = React.memo(() => { - const { copyJobState, setCopyJobState } = useCopyJobContext(); - - const handleMigrationTypeChange = (_ev?: React.FormEvent, checked?: boolean) => { - setCopyJobState((prevState) => ({ - ...prevState, - migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online, - })); - }; - - const migrationTypeChecked = copyJobState?.migrationType === CopyJobMigrationType.Offline; - return ( {ContainerCopyMessages.selectAccountDescription} @@ -27,7 +14,7 @@ const SelectAccount = React.memo(() => { - + ); }); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap index b84b677cc..0b540eba6 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap @@ -21,14 +21,9 @@ exports[`SelectAccount Component Rendering should render correctly with snapshot Account Dropdown
- - Copy container in offline mode + Migration Type
`; diff --git a/src/Explorer/ContainerCopy/containerCopyStyles.less b/src/Explorer/ContainerCopy/containerCopyStyles.less index c86986c62..6f99f4055 100644 --- a/src/Explorer/ContainerCopy/containerCopyStyles.less +++ b/src/Explorer/ContainerCopy/containerCopyStyles.less @@ -138,6 +138,14 @@ color: var(--colorNeutralForeground1); } } + .migrationTypeDescription { + p { + color: var(--colorNeutralForeground1); + } + a { + color: var(--colorBrandForeground1); + } + } } .create-container-link-btn { padding: 0; @@ -181,6 +189,9 @@ background-color: var(--colorNeutralBackground3); } } + .ms-DetailsHeader-cellTitle { + padding-left: 20px; + } } .ms-DetailsRow { diff --git a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx index e6e57f1c7..7dfd49b11 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx @@ -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(); + 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); + }); +}); diff --git a/src/Explorer/Controls/Settings/SettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsComponent.tsx index 0802fc863..95f7159cc 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.tsx @@ -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 void; constructor(props: SettingsComponentProps) { super(props); @@ -312,6 +312,13 @@ export class SettingsComponent extends React.Component { + this.refreshCollectionData(); + }, + (state) => state.indexingPolicies[this.collection?.id()], + ); + this.refreshCollectionData(); } this.setBaseline(); @@ -319,7 +326,11 @@ export class SettingsComponent extends React.Component => { + 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 => { const newCollection: DataModels.Collection = { ...this.collection.rawDataModel }; - if ( this.state.isSubSettingsSaveable || this.state.isContainerPolicyDirty || @@ -1252,7 +1283,6 @@ export class SettingsComponent extends React.Component diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx index a0bfc2116..039a66304 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx @@ -155,7 +155,12 @@ export class ComputedPropertiesComponent extends React.Component<   about how to define computed properties and how to use them. -
+
); } diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/IndexingPolicyRefreshComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/IndexingPolicyRefreshComponent.tsx index d601e3857..509373b8d 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/IndexingPolicyRefreshComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/IndexingPolicyRefreshComponent.tsx @@ -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 { diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx index 648395722..e2da4db0b 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx @@ -302,8 +302,8 @@ export class SubSettingsComponent extends React.Component ( diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ComputedPropertiesComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ComputedPropertiesComponent.test.tsx.snap index d5684b825..9646cf995 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ComputedPropertiesComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ComputedPropertiesComponent.test.tsx.snap @@ -31,6 +31,7 @@ exports[`ComputedPropertiesComponent renders 1`] = `
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap index 10bf3b17a..3a710db53 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap @@ -167,10 +167,12 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = ` options={ [ { + "ariaLabel": "geography-option", "key": "Geography", "text": "Geography", }, { + "ariaLabel": "geometry-option", "key": "Geometry", "text": "Geometry", }, @@ -652,10 +654,12 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = ` options={ [ { + "ariaLabel": "geography-option", "key": "Geography", "text": "Geography", }, { + "ariaLabel": "geometry-option", "key": "Geometry", "text": "Geometry", }, @@ -1224,10 +1228,12 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = ` options={ [ { + "ariaLabel": "geography-option", "key": "Geography", "text": "Geography", }, { + "ariaLabel": "geometry-option", "key": "Geometry", "text": "Geometry", }, @@ -1760,10 +1766,12 @@ exports[`SubSettingsComponent renders 1`] = ` options={ [ { + "ariaLabel": "geography-option", "key": "Geography", "text": "Geography", }, { + "ariaLabel": "geometry-option", "key": "Geometry", "text": "Geometry", }, @@ -2330,10 +2338,12 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = ` options={ [ { + "ariaLabel": "geography-option", "key": "Geography", "text": "Geography", }, { + "ariaLabel": "geometry-option", "key": "Geometry", "text": "Geometry", }, diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap index 27c4eeff5..569bfd035 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -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": [ { diff --git a/src/Explorer/Panes/UploadItemsPane/UploadItemsPane.tsx b/src/Explorer/Panes/UploadItemsPane/UploadItemsPane.tsx index 778634d71..47d7fd846 100644 --- a/src/Explorer/Panes/UploadItemsPane/UploadItemsPane.tsx +++ b/src/Explorer/Panes/UploadItemsPane/UploadItemsPane.tsx @@ -205,7 +205,7 @@ export const UploadItemsPane: FunctionComponent = ({ onUpl tooltip="Select one or more JSON files to upload. Each file can contain a single JSON document or an array of JSON documents. The combined size of all files in an individual upload operation must be less than 2 MB. You can perform multiple upload operations for larger data sets." /> {uploadFileData?.length > 0 && ( -
+
File upload status ; + 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 ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); +}; diff --git a/src/Explorer/Tabs/QueryTab/QueryResultSection.tsx b/src/Explorer/Tabs/QueryTab/QueryResultSection.tsx index e6bd5fdc1..a5ee941ef 100644 --- a/src/Explorer/Tabs/QueryTab/QueryResultSection.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryResultSection.tsx @@ -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; + queryEditorContent?: string; + databaseId?: string; + containerId?: string; } interface QueryResultProps extends ResultsViewProps { @@ -49,6 +52,8 @@ export const QueryResultSection: React.FC = ({ 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 = ({ queryResults={queryResults} executeQueryDocumentsPage={executeQueryDocumentsPage} isMongoDB={isMongoDB} + queryEditorContent={queryEditorContent} + databaseId={databaseId} + containerId={containerId} /> ) : ( diff --git a/src/Explorer/Tabs/QueryTab/QueryTabComponent.test.tsx b/src/Explorer/Tabs/QueryTab/QueryTabComponent.test.tsx index bc2e2f213..bf3ed538f 100644 --- a/src/Explorer/Tabs/QueryTab/QueryTabComponent.test.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryTabComponent.test.tsx @@ -52,8 +52,9 @@ describe("QueryTabComponent", () => { copilotVersion: "v3.0", }, }); + const propsMock: Readonly = { - collection: { databaseId: "CopilotSampleDB" }, + collection: { databaseId: "CopilotSampleDB", id: () => "CopilotContainer" }, onTabAccessor: () => jest.fn(), isExecutionError: false, tabId: "mockTabId", diff --git a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx index e3dafec50..7108a3d70 100644 --- a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx @@ -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((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 => { + 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 QueryDocumentsPerPage( firstItemIndex, @@ -795,6 +816,8 @@ class QueryTabComponentImpl extends React.Component this._executeQueryDocumentsPage(firstItemIndex) } diff --git a/src/Explorer/Tabs/QueryTab/ResultsView.test.tsx b/src/Explorer/Tabs/QueryTab/ResultsView.test.tsx new file mode 100644 index 000000000..31bdf939c --- /dev/null +++ b/src/Explorer/Tabs/QueryTab/ResultsView.test.tsx @@ -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( + , + ); + expect(container).toBeTruthy(); + }); + + test("renders component and handles missing parameters", () => { + const { container } = render(); + expect(container).toBeTruthy(); + // Should not crash when parameters are missing + }); + + test("fetches index metrics with query results", async () => { + render( + , + ); + await waitFor(() => expect(mockFetchAll).toHaveBeenCalled()); + }); + + test("displays content after loading", async () => { + render( + , + ); + // 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( + , + ); + await waitFor(() => expect(mockLogConsoleProgress).toHaveBeenCalled()); + }); + + test("handles error when fetch fails", async () => { + mockFetchAll.mockRejectedValueOnce(new Error("fetch failed")); + render( + , + ); + await waitFor(() => expect(mockHandleError).toHaveBeenCalled(), { timeout: 3000 }); + }); + + test("renders with all required props", () => { + const { container } = render( + , + ); + expect(container).toBeTruthy(); + expect(container.firstChild).toBeTruthy(); + }); +}); diff --git a/src/Explorer/Tabs/QueryTab/ResultsView.tsx b/src/Explorer/Tabs/QueryTab/ResultsView.tsx index 64b987b69..fbc56e89f 100644 --- a/src/Explorer/Tabs/QueryTab/ResultsView.tsx +++ b/src/Explorer/Tabs/QueryTab/ResultsView.tsx @@ -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 = ({ queryResults, isMongoDB, executeQueryDocumentsPage }) => { const styles = useQueryTabStyles(); /* eslint-disable react/prop-types */ @@ -523,14 +543,331 @@ const QueryStatsTab: React.FC> = ({ query ); }; -export const ResultsView: React.FC = ({ 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(null); + const [showIncluded, setShowIncluded] = useState(true); + const [showNotIncluded, setShowNotIncluded] = useState(true); + const [selectedIndexes, setSelectedIndexes] = useState([]); + const [selectAll, setSelectAll] = useState(false); + const [updateMessageShown, setUpdateMessageShown] = useState(false); + const [included, setIncludedIndexes] = useState([]); + const [notIncluded, setNotIncludedIndexes] = useState([]); + 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 ( + + +
+ {isNotIncluded ? ( + selected.index === item.index)} + onChange={(_, data) => handleCheckboxChange(item, data.checked === true)} + /> + ) : isHeader && item.index === "Not Included in Current Policy" && notIncluded.length > 0 ? ( + handleSelectAll(data.checked === true)} /> + ) : ( +
+ )} + {isHeader ? ( + { + 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 ? ( + + ) : ( + + ) + ) : showNotIncluded ? ( + + ) : ( + + )} + + ) : ( +
+ )} +
{item.index}
+
+ {!isHeader && item.impact} +
+
{!isHeader && renderImpactDots(item.impact)}
+
+
+
+ ); + }; + 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 ( +
+ +
+ ); + } + + return ( +
+
+ {updateMessageShown ? ( + <> + + + + + Your indexing policy has been updated with the new included paths. You may review the changes in Scale & + Settings. + + + ) : ( + <> + + 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.{" "} + + Learn more about Indexing Metrics + + .{" "} + + + )} +
+
Indexes analysis
+ + + + +
+
+
+
Index
+
+ Estimated Impact +
+
+
+
+
+ {indexMetricItems.map(renderRow)} +
+ {selectedIndexes.length > 0 && ( +
+ {isUpdating ? ( +
+ {" "} +
+ ) : ( + + )} +
+ )} +
+ ); +}; +export const ResultsView: React.FC = ({ + isMongoDB, + queryResults, + executeQueryDocumentsPage, + queryEditorContent, + databaseId, + containerId, +}) => { const styles = useQueryTabStyles(); const [activeTab, setActiveTab] = useState(ResultsTabs.Results); const onTabSelect = useCallback((event: SelectTabEvent, data: SelectTabData) => { setActiveTab(data.value as ResultsTabs); }, []); - return (
@@ -548,6 +885,13 @@ export const ResultsView: React.FC = ({ isMongoDB, queryResult > Query Stats + + Index Advisor +
{activeTab === ResultsTabs.Results && ( @@ -558,7 +902,30 @@ export const ResultsView: React.FC = ({ isMongoDB, queryResult /> )} {activeTab === ResultsTabs.QueryStats && } + {activeTab === ResultsTabs.IndexAdvisor && ( + + )}
); }; +export interface IndexingPolicyStore { + indexingPolicies: { [containerId: string]: IndexingPolicy }; + setIndexingPolicyFor: (containerId: string, indexingPolicy: IndexingPolicy) => void; +} + +export const useIndexingPolicyStore = create((set) => ({ + indexingPolicies: {}, + setIndexingPolicyFor: (containerId, indexingPolicy) => + set((state) => ({ + indexingPolicies: { + ...state.indexingPolicies, + [containerId]: { ...indexingPolicy }, + }, + })), +})); diff --git a/src/Explorer/Tabs/QueryTab/StylesAdvisor.ts b/src/Explorer/Tabs/QueryTab/StylesAdvisor.ts new file mode 100644 index 000000000..29f62b35a --- /dev/null +++ b/src/Explorer/Tabs/QueryTab/StylesAdvisor.ts @@ -0,0 +1,95 @@ +import { makeStyles } from "@fluentui/react-components"; +export type IndexAdvisorStyles = ReturnType; +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", + }, + }, +}); diff --git a/src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts b/src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts new file mode 100644 index 000000000..cccf3c7bb --- /dev/null +++ b/src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts @@ -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((set) => ({ + userQuery: "", + databaseId: "", + containerId: "", + setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }), +})); diff --git a/src/Explorer/Tabs/StoredProcedureTab/StoredProcedureTabComponent.tsx b/src/Explorer/Tabs/StoredProcedureTab/StoredProcedureTabComponent.tsx index 8b99758f9..8b9b95534 100644 --- a/src/Explorer/Tabs/StoredProcedureTab/StoredProcedureTabComponent.tsx +++ b/src/Explorer/Tabs/StoredProcedureTab/StoredProcedureTabComponent.tsx @@ -435,7 +435,6 @@ export default class StoredProcedureTabComponent extends React.Component< }); useCommandBar.getState().setContextButtons(this.getTabsButtons()); }, 100); - return createdResource; }, (createError) => { diff --git a/src/Metrics/ScenarioMonitor.ts b/src/Metrics/ScenarioMonitor.ts index a982cca3e..73e3fbcf7 100644 --- a/src/Metrics/ScenarioMonitor.ts +++ b/src/Metrics/ScenarioMonitor.ts @@ -1,6 +1,7 @@ import { Metric, onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals"; import { configContext } from "../ConfigContext"; -import { trackEvent } from "../Shared/appInsights"; +import { Action } from "../Shared/Telemetry/TelemetryConstants"; +import { traceFailure, traceMark, traceStart, traceSuccess } from "../Shared/Telemetry/TelemetryProcessor"; import { userContext } from "../UserContext"; import MetricScenario, { reportHealthy, reportUnhealthy } from "./MetricEvents"; import { scenarioConfigs } from "./MetricScenarioConfigs"; @@ -83,6 +84,13 @@ class ScenarioMonitor { ctx.phases.set(phase, { startMarkName: phaseStartMarkName }); }); + traceMark(Action.MetricsScenario, { + event: "scenario_start", + scenario, + requiredPhases: config.requiredPhases.join(","), + timeoutMs: config.timeoutMs, + }); + ctx.timeoutId = window.setTimeout(() => this.emit(ctx, false, true), config.timeoutMs); this.contexts.set(scenario, ctx); } @@ -96,6 +104,12 @@ class ScenarioMonitor { const startMarkName = `scenario_${scenario}_${phase}_start`; performance.mark(startMarkName); ctx.phases.set(phase, { startMarkName }); + + traceStart(Action.MetricsScenario, { + event: "phase_start", + scenario, + phase, + }); } completePhase(scenario: MetricScenario, phase: MetricPhase) { @@ -110,6 +124,22 @@ class ScenarioMonitor { phaseCtx.endMarkName = endMarkName; ctx.completed.add(phase); + const navigationStart = performance.timeOrigin; + const startEntry = performance.getEntriesByName(phaseCtx.startMarkName)[0]; + const endEntry = performance.getEntriesByName(endMarkName)[0]; + const endTimeISO = endEntry ? new Date(navigationStart + endEntry.startTime).toISOString() : undefined; + const durationMs = startEntry && endEntry ? endEntry.startTime - startEntry.startTime : undefined; + + traceSuccess(Action.MetricsScenario, { + event: "phase_complete", + scenario, + phase, + endTimeISO, + durationMs, + completedCount: ctx.completed.size, + requiredCount: ctx.config.requiredPhases.length, + }); + this.tryEmitIfReady(ctx); } @@ -133,6 +163,14 @@ class ScenarioMonitor { // Build a snapshot with failure info const failureSnapshot = this.buildSnapshot(ctx, { final: false, timedOut: false }); + traceFailure(Action.MetricsScenario, { + event: "phase_fail", + scenario, + phase, + failedPhases: Array.from(ctx.failed).join(","), + completedPhases: Array.from(ctx.completed).join(","), + }); + // Emit unhealthy immediately this.emit(ctx, false, false, failureSnapshot); } @@ -191,27 +229,22 @@ class ScenarioMonitor { // Build snapshot if not provided const finalSnapshot = snapshot || this.buildSnapshot(ctx, { final: false, timedOut }); - // Emit enriched telemetry with performance data - // TODO: Call portal backend metrics endpoint - trackEvent( - { name: "MetricScenarioComplete" }, - { - scenario: ctx.scenario, - healthy: healthy.toString(), - timedOut: timedOut.toString(), - platform, - api, - durationMs: finalSnapshot.durationMs.toString(), - completedPhases: finalSnapshot.completed.join(","), - failedPhases: finalSnapshot.failedPhases?.join(","), - lcp: finalSnapshot.vitals?.lcp?.toString(), - inp: finalSnapshot.vitals?.inp?.toString(), - cls: finalSnapshot.vitals?.cls?.toString(), - fcp: finalSnapshot.vitals?.fcp?.toString(), - ttfb: finalSnapshot.vitals?.ttfb?.toString(), - phaseTimings: JSON.stringify(finalSnapshot.phaseTimings), - }, - ); + traceMark(Action.MetricsScenario, { + event: "scenario_end", + scenario: ctx.scenario, + healthy, + timedOut, + platform, + api, + durationMs: finalSnapshot.durationMs, + completedPhases: finalSnapshot.completed.join(","), + failedPhases: finalSnapshot.failedPhases?.join(","), + lcp: finalSnapshot.vitals?.lcp, + inp: finalSnapshot.vitals?.inp, + cls: finalSnapshot.vitals?.cls, + fcp: finalSnapshot.vitals?.fcp, + ttfb: finalSnapshot.vitals?.ttfb, + }); // Call portal backend health metrics endpoint if (healthy && !timedOut) { @@ -227,9 +260,16 @@ class ScenarioMonitor { private cleanupPerformanceEntries(ctx: InternalScenarioContext) { performance.clearMarks(ctx.startMarkName); ctx.config.requiredPhases.forEach((phase) => { - performance.clearMarks(`scenario_${ctx.scenario}_${phase}`); + const phaseCtx = ctx.phases.get(phase); + if (phaseCtx?.startMarkName) { + performance.clearMarks(phaseCtx.startMarkName); + } + if (phaseCtx?.endMarkName) { + performance.clearMarks(phaseCtx.endMarkName); + } + performance.clearMarks(`scenario_${ctx.scenario}_${phase}_failed`); + performance.clearMeasures(`scenario_${ctx.scenario}_${phase}_duration`); }); - performance.clearMeasures(`scenario_${ctx.scenario}_total`); } private buildSnapshot( diff --git a/src/Shared/Telemetry/TelemetryConstants.ts b/src/Shared/Telemetry/TelemetryConstants.ts index bee104eea..4c2b8cf16 100644 --- a/src/Shared/Telemetry/TelemetryConstants.ts +++ b/src/Shared/Telemetry/TelemetryConstants.ts @@ -2,6 +2,7 @@ // Some of the enums names are used in Fabric. Please do not rename them. export enum Action { CollapseTreeNode, + MetricsScenario, CreateCollection, // Used in Fabric. Please do not rename. CreateGlobalSecondaryIndex, CreateDocument, // Used in Fabric. Please do not rename. diff --git a/test/fx.ts b/test/fx.ts index c1c2b6a47..1de8be90d 100644 --- a/test/fx.ts +++ b/test/fx.ts @@ -378,7 +378,9 @@ type PanelOpenOptions = { export enum CommandBarButton { Save = "Save", + Execute = "Execute", ExecuteQuery = "Execute Query", + UploadItem = "Upload Item", } /** Helper class that provides locator methods for DataExplorer components, on top of a Frame */ diff --git a/test/sql/containercopy.spec.ts b/test/sql/containercopy.spec.ts index c019b99b7..eff5faca1 100644 --- a/test/sql/containercopy.spec.ts +++ b/test/sql/containercopy.spec.ts @@ -83,22 +83,33 @@ test.describe("Container Copy", () => { ); await accountItem.click(); - // Verifying online or offline checkbox functionality + // Verifying online or offline migration functionality /** - * This test verifies the functionality of the migration type checkbox that toggles between + * This test verifies the functionality of the migration type radio that toggles between * online and offline container copy modes. It ensures that: * 1. When online mode is selected, the user is directed to a permissions screen * 2. When offline mode is selected, the user bypasses the permissions screen * 3. The UI correctly reflects the selected migration type throughout the workflow */ - const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox"); - await fluentUiCheckboxContainer.click(); + const migrationTypeContainer = panel.getByTestId("migration-type"); + const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i }); + await onlineCopyRadioButton.click({ force: true }); + + await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible(); + await panel.getByRole("button", { name: "Next" }).click(); + await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).toBeVisible(); await expect(panel.getByText("Online container copy", { exact: true })).toBeVisible(); await panel.getByRole("button", { name: "Previous" }).click(); - await fluentUiCheckboxContainer.click(); + + const offlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Offline mode/i }); + await offlineCopyRadioButton.click({ force: true }); + + await expect(migrationTypeContainer.getByTestId("migration-type-description-offline")).toBeVisible(); + await panel.getByRole("button", { name: "Next" }).click(); + await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible(); await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible(); @@ -284,8 +295,9 @@ test.describe("Container Copy", () => { throw new Error("No dropdown items available after filtering"); } - const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox"); - await fluentUiCheckboxContainer.click(); + const migrationTypeContainer = panel.getByTestId("migration-type"); + const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i }); + await onlineCopyRadioButton.click({ force: true }); await panel.getByRole("button", { name: "Next" }).click(); diff --git a/test/sql/document.spec.ts b/test/sql/document.spec.ts index 95cdd112a..5d17c22c3 100644 --- a/test/sql/document.spec.ts +++ b/test/sql/document.spec.ts @@ -1,7 +1,18 @@ import { expect, test } from "@playwright/test"; -import { DataExplorer, DocumentsTab, TestAccount } from "../fx"; -import { retry, setPartitionKeys } from "../testData"; +import { existsSync, mkdtempSync, rmdirSync, unlinkSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { CommandBarButton, DataExplorer, DocumentsTab, ONE_MINUTE_MS, TestAccount } from "../fx"; +import { + createTestSQLContainer, + itemsPerPartition, + partitionCount, + retry, + setPartitionKeys, + TestContainerContext, + TestData, +} from "../testData"; import { documentTestCases } from "./testCases"; let explorer: DataExplorer = null!; @@ -95,3 +106,105 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) { } }); } + +test.describe.serial("Upload Item", () => { + let context: TestContainerContext = null!; + let uploadDocumentDirPath: string = null!; + let uploadDocumentFilePath: string = null!; + + test.beforeAll("Create Test database and open documents tab", async ({ browser }) => { + uploadDocumentDirPath = mkdtempSync(path.join(tmpdir(), "upload-document-")); + uploadDocumentFilePath = path.join(uploadDocumentDirPath, "uploadDocument.json"); + + const page = await browser.newPage(); + context = await createTestSQLContainer(); + explorer = await DataExplorer.open(page, TestAccount.SQL); + + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.expand(); + + const containerMenuNode = await explorer.waitForContainerItemsNode(context.database.id, context.container.id); + await containerMenuNode.element.click(); + // We need to click twice in order to remove a tooltip + await containerMenuNode.element.click(); + }); + + test.afterAll("Delete Test Database and uploadDocument temp folder", async () => { + if (existsSync(uploadDocumentFilePath)) { + unlinkSync(uploadDocumentFilePath); + } + if (existsSync(uploadDocumentDirPath)) { + rmdirSync(uploadDocumentDirPath); + } + if (!process.env.CI) { + await context?.dispose(); + } + }); + + test.afterEach("Close Upload Items panel if still open", async () => { + const closeUploadItemsPanelButton = explorer.frame.getByLabel("Close Upload Items"); + if (await closeUploadItemsPanelButton.isVisible()) { + await closeUploadItemsPanelButton.click(); + } + }); + + test("upload document", async () => { + // Create file to upload + const TestDataJsonString: string = JSON.stringify(TestData, null, 2); + writeFileSync(uploadDocumentFilePath, TestDataJsonString); + + const uploadItemCommandBar = explorer.commandBarButton(CommandBarButton.UploadItem); + await uploadItemCommandBar.click(); + + // Select file to upload + await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath); + + const uploadButton = explorer.frame.getByTestId("Panel/OkButton"); + await uploadButton.click(); + + // Verify upload success message + const fileUploadStatusExpected: string = `${partitionCount * itemsPerPartition} created, 0 throttled, 0 errors`; + const fileUploadStatus = explorer.frame.getByTestId("file-upload-status"); + await expect(fileUploadStatus).toContainText(fileUploadStatusExpected, { + timeout: ONE_MINUTE_MS, + }); + + // Select file to upload again + await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath); + await uploadButton.click(); + + // Verify upload failure message + const errorIcon = explorer.frame.getByRole("img", { name: "error" }); + await expect(errorIcon).toBeVisible({ timeout: ONE_MINUTE_MS }); + await expect(fileUploadStatus).toContainText( + `0 created, 0 throttled, ${partitionCount * itemsPerPartition} errors`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); + + test("upload invalid json", async () => { + // Create file to upload + let TestDataJsonString: string = JSON.stringify(TestData, null, 2); + // Remove the first '[' so that it becomes invalid json + TestDataJsonString = TestDataJsonString.substring(1); + writeFileSync(uploadDocumentFilePath, TestDataJsonString); + + const uploadItemCommandBar = explorer.commandBarButton(CommandBarButton.UploadItem); + await uploadItemCommandBar.click(); + + // Select file to upload + await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath); + + const uploadButton = explorer.frame.getByTestId("Panel/OkButton"); + await uploadButton.click(); + + // Verify upload failure message + const fileUploadErrorList = explorer.frame.getByLabel("error list"); + // The parsing error will show up differently in different browsers so just check for the word "JSON" + await expect(fileUploadErrorList).toContainText("JSON", { + timeout: ONE_MINUTE_MS, + }); + }); +}); diff --git a/test/sql/indexAdvisor.spec.ts b/test/sql/indexAdvisor.spec.ts new file mode 100644 index 000000000..4d9ac6aa2 --- /dev/null +++ b/test/sql/indexAdvisor.spec.ts @@ -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(); +}); diff --git a/test/sql/scaleAndSettings/computedProperties.spec.ts b/test/sql/scaleAndSettings/computedProperties.spec.ts new file mode 100644 index 000000000..d1f95e53e --- /dev/null +++ b/test/sql/scaleAndSettings/computedProperties.spec.ts @@ -0,0 +1,108 @@ +import { expect, Page, test } from "@playwright/test"; +import * as DataModels from "../../../src/Contracts/DataModels"; +import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx"; +import { createTestSQLContainer, TestContainerContext } from "../../testData"; + +test.describe("Computed Properties", () => { + let context: TestContainerContext = null!; + let explorer: DataExplorer = null!; + + test.beforeAll("Create Test Database", async () => { + context = await createTestSQLContainer(); + }); + + test.beforeEach("Open Settings tab under Scale & Settings", async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.expand(); + + // Click Scale & Settings and open Settings tab + await explorer.openScaleAndSettings(context); + const computedPropertiesTab = explorer.frame.getByTestId("settings-tab-header/ComputedPropertiesTab"); + await computedPropertiesTab.click(); + }); + + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); + + test("Add valid computed property", async ({ page }) => { + await clearComputedPropertiesTextBoxContent({ page }); + + // Create computed property + const computedProperties: DataModels.ComputedProperties = [ + { + name: "cp_lowerName", + query: "SELECT VALUE LOWER(c.name) FROM c", + }, + ]; + const computedPropertiesString: string = JSON.stringify(computedProperties); + await page.keyboard.type(computedPropertiesString); + + // Save changes + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated container ${context.container.id}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); + + test("Add computed property with invalid query", async ({ page }) => { + await clearComputedPropertiesTextBoxContent({ page }); + + // Create computed property with no VALUE keyword in query + const computedProperties: DataModels.ComputedProperties = [ + { + name: "cp_lowerName", + query: "SELECT LOWER(c.name) FROM c", + }, + ]; + const computedPropertiesString: string = JSON.stringify(computedProperties); + await page.keyboard.type(computedPropertiesString); + + // Save changes + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Failed to update container ${context.container.id}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); + + test("Add computed property with invalid json", async ({ page }) => { + await clearComputedPropertiesTextBoxContent({ page }); + + // Create computed property with no VALUE keyword in query + const computedProperties: DataModels.ComputedProperties = [ + { + name: "cp_lowerName", + query: "SELECT LOWER(c.name) FROM c", + }, + ]; + const computedPropertiesString: string = JSON.stringify(computedProperties); + await page.keyboard.type(computedPropertiesString + "]"); + + // Save button should remain disabled due to invalid json + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeDisabled(); + }); + + const clearComputedPropertiesTextBoxContent = async ({ page }: { page: Page }): Promise => { + // Get computed properties text box + await explorer.frame.waitForSelector(".monaco-scrollable-element", { state: "visible" }); + const computedPropertiesEditor = explorer.frame.getByTestId("computed-properties-editor"); + await computedPropertiesEditor.click(); + + // Clear existing content (Ctrl+A + Backspace does not work with webkit) + for (let i = 0; i < 100; i++) { + await page.keyboard.press("Backspace"); + } + }; +}); diff --git a/test/sql/scaleAndSettings/settings.spec.ts b/test/sql/scaleAndSettings/settings.spec.ts index f82c5413f..3f14422eb 100644 --- a/test/sql/scaleAndSettings/settings.spec.ts +++ b/test/sql/scaleAndSettings/settings.spec.ts @@ -11,7 +11,7 @@ test.describe("Settings under Scale & Settings", () => { const page = await browser.newPage(); explorer = await DataExplorer.open(page, TestAccount.SQL); - // Click Scale & Settings and open Scale tab + // Click Scale & Settings and open Settings tab await explorer.openScaleAndSettings(context); const settingsTab = explorer.frame.getByTestId("settings-tab-header/SubSettingsTab"); await settingsTab.click(); @@ -53,4 +53,28 @@ test.describe("Settings under Scale & Settings", () => { }, ); }); + + test("Set Geospatial Config to Geometry then Geography", async () => { + const geometryRadioButton = explorer.frame.getByRole("radio", { name: "geometry-option" }); + await geometryRadioButton.click(); + + await explorer.commandBarButton(CommandBarButton.Save).click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated container ${context.container.id}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + + const geographyRadioButton = explorer.frame.getByRole("radio", { name: "geography-option" }); + await geographyRadioButton.click(); + + await explorer.commandBarButton(CommandBarButton.Save).click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated container ${context.container.id}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); }); diff --git a/test/sql/scripts/storedProcedure.spec.ts b/test/sql/scripts/storedProcedure.spec.ts new file mode 100644 index 000000000..35fb4e0f8 --- /dev/null +++ b/test/sql/scripts/storedProcedure.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from "@playwright/test"; +import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx"; +import { createTestSQLContainer, TestContainerContext } from "../../testData"; + +test.describe("Stored Procedures", () => { + let context: TestContainerContext = null!; + let explorer: DataExplorer = null!; + + test.beforeAll("Create Test Database", async () => { + context = await createTestSQLContainer(); + }); + + test.beforeEach("Open container", async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); + }); + + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); + + test("Add, execute, and delete stored procedure", async ({ page }, testInfo) => { + void page; + // Open container context menu and click New Stored Procedure + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.openContextMenu(); + await containerNode.contextMenuItem("New Stored Procedure").click(); + + // Type stored procedure id and use stock procedure + const storedProcedureIdTextBox = explorer.frame.getByLabel("Stored procedure id"); + await storedProcedureIdTextBox.isVisible(); + const storedProcedureName = `stored-procedure-${testInfo.testId}`; + await storedProcedureIdTextBox.fill(storedProcedureName); + + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully created stored procedure ${storedProcedureName}`, + { + timeout: 2 * ONE_MINUTE_MS, + }, + ); + + // Execute stored procedure + const executeButton = explorer.commandBarButton(CommandBarButton.Execute); + await executeButton.click(); + const executeSidePanelButton = explorer.frame.getByTestId("Panel/OkButton"); + await executeSidePanelButton.click(); + + const executeStoredProcedureResult = explorer.frame.getByLabel("Execute stored procedure result"); + await expect(executeStoredProcedureResult).toBeAttached({ + timeout: ONE_MINUTE_MS, + }); + + // Delete stored procedure + await containerNode.expand(); + const storedProceduresNode = await explorer.waitForNode( + `${context.database.id}/${context.container.id}/Stored Procedures`, + ); + await storedProceduresNode.expand(); + const storedProcedureNode = await explorer.waitForNode( + `${context.database.id}/${context.container.id}/Stored Procedures/${storedProcedureName}`, + ); + + await storedProcedureNode.openContextMenu(); + await storedProcedureNode.contextMenuItem("Delete Stored Procedure").click(); + const deleteStoredProcedureButton = explorer.frame.getByTestId("DialogButton:Delete"); + await deleteStoredProcedureButton.click(); + + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully deleted stored procedure ${storedProcedureName}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); +}); diff --git a/test/sql/scripts/trigger.spec.ts b/test/sql/scripts/trigger.spec.ts new file mode 100644 index 000000000..6874c2aac --- /dev/null +++ b/test/sql/scripts/trigger.spec.ts @@ -0,0 +1,80 @@ +import { expect, test } from "@playwright/test"; +import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx"; +import { createTestSQLContainer, TestContainerContext } from "../../testData"; + +test.describe("Triggers", () => { + let context: TestContainerContext = null!; + let explorer: DataExplorer = null!; + const triggerBody = `function validateToDoItemTimestamp() { + var context = getContext(); + var request = context.getRequest(); + + var itemToCreate = request.getBody(); + + if (!("timestamp" in itemToCreate)) { + var ts = new Date(); + itemToCreate["timestamp"] = ts.getTime(); + } + + request.setBody(itemToCreate); + }`; + test.beforeAll("Create Test Database", async () => { + context = await createTestSQLContainer(); + }); + + test.beforeEach("Open container", async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); + }); + + if (!process.env.CI) { + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); + } + + test("Add and delete trigger", async ({ page }, testInfo) => { + // Open container context menu and click New Trigger + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.openContextMenu(); + await containerNode.contextMenuItem("New Trigger").click(); + + // Assign Trigger id + const triggerIdTextBox = explorer.frame.getByLabel("Trigger Id"); + const triggerId: string = `validateItemTimestamp-${testInfo.testId}`; + await triggerIdTextBox.fill(triggerId); + + // Create Trigger body that validates item timestamp + const triggerBodyTextArea = explorer.frame.getByTestId("EditorReact/Host/Loaded"); + await triggerBodyTextArea.click(); + + // Clear existing content + const isMac: boolean = process.platform === "darwin"; + await page.keyboard.press(isMac ? "Meta+A" : "Control+A"); + await page.keyboard.press("Backspace"); + + await page.keyboard.type(triggerBody); + + // Save changes + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await saveButton.click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText(`Successfully created trigger ${triggerId}`, { + timeout: 2 * ONE_MINUTE_MS, + }); + + // Delete Trigger + await containerNode.expand(); + const triggersNode = await explorer.waitForNode(`${context.database.id}/${context.container.id}/Triggers`); + await triggersNode.expand(); + const triggerNode = await explorer.waitForNode( + `${context.database.id}/${context.container.id}/Triggers/${triggerId}`, + ); + + await triggerNode.openContextMenu(); + await triggerNode.contextMenuItem("Delete Trigger").click(); + const deleteTriggerButton = explorer.frame.getByTestId("DialogButton:Delete"); + await deleteTriggerButton.click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText(`Successfully deleted trigger ${triggerId}`, { + timeout: ONE_MINUTE_MS, + }); + }); +}); diff --git a/test/sql/scripts/userDefinedFunction.spec.ts b/test/sql/scripts/userDefinedFunction.spec.ts new file mode 100644 index 000000000..911b1f4ce --- /dev/null +++ b/test/sql/scripts/userDefinedFunction.spec.ts @@ -0,0 +1,82 @@ +import { expect, test } from "@playwright/test"; +import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx"; +import { createTestSQLContainer, TestContainerContext } from "../../testData"; + +test.describe("User Defined Functions", () => { + let context: TestContainerContext = null!; + let explorer: DataExplorer = null!; + const udfBody: string = `function extractDocumentId(doc) { + return { + id: doc.id + }; + }`; + + test.beforeAll("Create Test Database", async () => { + context = await createTestSQLContainer(); + }); + + test.beforeEach("Open container", async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); + }); + + if (!process.env.CI) { + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); + } + + test("Add, execute, and delete user defined function", async ({ page }, testInfo) => { + // Open container context menu and click New UDF + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.openContextMenu(); + await containerNode.contextMenuItem("New UDF").click(); + + // Assign UDF id + const udfIdTextBox = explorer.frame.getByLabel("User Defined Function Id"); + const udfId: string = `extractDocumentId-${testInfo.testId}`; + await udfIdTextBox.fill(udfId); + + // Create UDF body that extracts the document id from a document + const udfBodyTextArea = explorer.frame.getByTestId("EditorReact/Host/Loaded"); + await udfBodyTextArea.click(); + + // Clear existing content + const isMac: boolean = process.platform === "darwin"; + await page.keyboard.press(isMac ? "Meta+A" : "Control+A"); + await page.keyboard.press("Backspace"); + + await page.keyboard.type(udfBody); + + // Save changes + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully created user defined function ${udfId}`, + { + timeout: 2 * ONE_MINUTE_MS, + }, + ); + + // Delete UDF + await containerNode.expand(); + const udfsNode = await explorer.waitForNode( + `${context.database.id}/${context.container.id}/User Defined Functions`, + ); + await udfsNode.expand(); + const udfNode = await explorer.waitForNode( + `${context.database.id}/${context.container.id}/User Defined Functions/${udfId}`, + ); + await udfNode.openContextMenu(); + await udfNode.contextMenuItem("Delete User Defined Function").click(); + const deleteUserDefinedFunctionButton = explorer.frame.getByTestId("DialogButton:Delete"); + await deleteUserDefinedFunctionButton.click(); + + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully deleted user defined function ${udfId}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); +}); diff --git a/test/testData.ts b/test/testData.ts index b440f565c..7e5a1f26c 100644 --- a/test/testData.ts +++ b/test/testData.ts @@ -37,27 +37,35 @@ export interface PartitionKey { value: string | null; } -const partitionCount = 4; +export const partitionCount = 4; // If we increase this number, we need to split bulk creates into multiple batches. // Bulk operations are limited to 100 items per partition. -const itemsPerPartition = 100; +export const itemsPerPartition = 100; function createTestItems(): TestItem[] { const items: TestItem[] = []; for (let i = 0; i < partitionCount; i++) { for (let j = 0; j < itemsPerPartition; j++) { - const id = crypto.randomBytes(32).toString("base64"); + const id = createSafeRandomString(32); items.push({ id, partitionKey: `partition_${i}`, - randomData: crypto.randomBytes(32).toString("base64"), + randomData: createSafeRandomString(32), }); } } return items; } +// Document IDs cannot contain '/', '\', or '#' +function createSafeRandomString(byteLength: number): string { + return crypto + .randomBytes(byteLength) + .toString("base64") + .replace(/[/\\#]/g, "_"); +} + export const TestData: TestItem[] = createTestItems(); export class TestContainerContext {