From 8698c6a3e2759b290246f486f217888fb5c22147 Mon Sep 17 00:00:00 2001 From: Bikram Choudhury Date: Thu, 26 Mar 2026 11:33:48 +0530 Subject: [PATCH] feat: Redesign container-copy flow to select destination account and enable cross-account container creation --- src/Common/dataAccess/createCollection.ts | 9 +- src/Common/dataAccess/createDatabase.test.ts | 134 ++++++++++++++ src/Common/dataAccess/createDatabase.ts | 13 +- src/Common/dataAccess/readDatabases.test.ts | 64 ++++++- src/Common/dataAccess/readDatabases.ts | 17 ++ src/Contracts/DataModels.ts | 8 + .../Actions/CopyJobActions.test.tsx | 20 +-- .../ContainerCopy/Actions/CopyJobActions.tsx | 2 +- .../ContainerCopy/ContainerCopyMessages.ts | 15 +- .../Context/CopyJobContext.test.tsx | 20 +-- .../ContainerCopy/Context/CopyJobContext.tsx | 8 +- ...ddReadPermissionToDefaultIdentity.test.tsx | 4 +- .../AssignPermissions.test.tsx | 10 +- .../PointInTimeRestore.test.tsx | 4 +- .../hooks/usePermissionsSection.test.tsx | 20 +-- .../AddCollectionPanelWrapper.test.tsx | 2 +- .../AddCollectionPanelWrapper.tsx | 102 ++++++++++- .../AddCollectionPanelWrapper.test.tsx.snap | 40 ++--- .../PreviewCopyJob/PreviewCopyJob.test.tsx | 24 +-- .../Screens/PreviewCopyJob/PreviewCopyJob.tsx | 12 +- .../PreviewCopyJob.test.tsx.snap | 86 +++++---- .../Components/AccountDropdown.test.tsx | 54 +++--- .../Components/AccountDropdown.tsx | 22 +-- .../Components/SubscriptionDropdown.tsx | 10 +- .../SelectAccount/SelectAccount.test.tsx | 6 +- .../__snapshots__/SelectAccount.test.tsx.snap | 2 +- .../Events/DropDownChangeHandler.test.tsx | 32 ++-- .../SelectSourceAndTargetContainers.test.tsx | 4 +- .../memoizedData.test.tsx | 4 +- .../Utils/useCopyJobNavigation.test.tsx | 4 +- .../useCreateCopyJobScreensList.test.tsx | 34 ++-- .../Utils/useCreateCopyJobScreensList.tsx | 2 +- .../Components/CopyJobDetails.test.tsx | 12 +- .../Components/CopyJobDetails.tsx | 4 +- .../ContainerCopy/Types/CopyJobTypes.ts | 4 +- src/Explorer/Explorer.test.tsx | 168 ++++++++++++++++++ src/Explorer/Explorer.tsx | 12 +- .../AddCollectionPanel.test.tsx | 52 ++++++ .../AddCollectionPanel/AddCollectionPanel.tsx | 25 ++- 39 files changed, 817 insertions(+), 248 deletions(-) create mode 100644 src/Common/dataAccess/createDatabase.test.ts create mode 100644 src/Explorer/Explorer.test.tsx diff --git a/src/Common/dataAccess/createCollection.ts b/src/Common/dataAccess/createCollection.ts index 7f531211c..91031034b 100644 --- a/src/Common/dataAccess/createCollection.ts +++ b/src/Common/dataAccess/createCollection.ts @@ -34,6 +34,7 @@ export const createCollection = async (params: DataModels.CreateCollectionParams databaseId: params.databaseId, databaseLevelThroughput: params.databaseLevelThroughput, offerThroughput: params.offerThroughput, + targetAccountOverride: params.targetAccountOverride, }; await createDatabase(createDatabaseParams); } @@ -63,7 +64,7 @@ export const createCollection = async (params: DataModels.CreateCollectionParams }; const createCollectionWithARM = async (params: DataModels.CreateCollectionParams): Promise => { - if (!params.createNewDatabase) { + if (!params.createNewDatabase && !params.targetAccountOverride) { const isValid = await useDatabases.getState().validateCollectionId(params.databaseId, params.collectionId); if (!isValid) { const collectionName = getCollectionName().toLocaleLowerCase(); @@ -122,9 +123,9 @@ const createSqlContainer = async (params: DataModels.CreateCollectionParams): Pr }; const createResponse = await createUpdateSqlContainer( - userContext.subscriptionId, - userContext.resourceGroup, - userContext.databaseAccount.name, + params.targetAccountOverride?.subscriptionId ?? userContext.subscriptionId, + params.targetAccountOverride?.resourceGroup ?? userContext.resourceGroup, + params.targetAccountOverride?.accountName ?? userContext.databaseAccount.name, params.databaseId, params.collectionId, rpPayload, diff --git a/src/Common/dataAccess/createDatabase.test.ts b/src/Common/dataAccess/createDatabase.test.ts new file mode 100644 index 000000000..9f295ca89 --- /dev/null +++ b/src/Common/dataAccess/createDatabase.test.ts @@ -0,0 +1,134 @@ +jest.mock("../../Utils/arm/request"); +jest.mock("../CosmosClient"); +jest.mock("../../Utils/arm/generatedClients/cosmos/sqlResources"); + +import ko from "knockout"; +import { AuthType } from "../../AuthType"; +import { CreateDatabaseParams, DatabaseAccount } from "../../Contracts/DataModels"; +import * as ViewModels from "../../Contracts/ViewModels"; +import { useDatabases } from "../../Explorer/useDatabases"; +import { updateUserContext } from "../../UserContext"; +import { createUpdateSqlDatabase } from "../../Utils/arm/generatedClients/cosmos/sqlResources"; +import { SqlDatabaseGetResults } from "../../Utils/arm/generatedClients/cosmos/types"; +import { createDatabase } from "./createDatabase"; + +const mockCreateUpdateSqlDatabase = createUpdateSqlDatabase as jest.MockedFunction; + +describe("createDatabase", () => { + beforeAll(() => { + updateUserContext({ + databaseAccount: { name: "default-account" } as DatabaseAccount, + subscriptionId: "default-subscription", + resourceGroup: "default-rg", + apiType: "SQL", + authType: AuthType.AAD, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockCreateUpdateSqlDatabase.mockResolvedValue({ + properties: { resource: { id: "db", _rid: "", _self: "", _ts: 0, _etag: "" } }, + } as SqlDatabaseGetResults); + useDatabases.setState({ + databases: [], + validateDatabaseId: () => true, + } as unknown as ReturnType); + }); + + it("should call ARM createUpdateSqlDatabase when logged in with AAD", async () => { + await createDatabase({ databaseId: "testDb" }); + expect(mockCreateUpdateSqlDatabase).toHaveBeenCalled(); + }); + + describe("targetAccountOverride behavior", () => { + it("should use targetAccountOverride subscriptionId, resourceGroup, and accountName for SQL DB creation", async () => { + const params: CreateDatabaseParams = { + databaseId: "testDb", + targetAccountOverride: { + subscriptionId: "override-sub", + resourceGroup: "override-rg", + accountName: "override-account", + }, + }; + + await createDatabase(params); + + expect(mockCreateUpdateSqlDatabase).toHaveBeenCalledWith( + "override-sub", + "override-rg", + "override-account", + "testDb", + expect.any(Object), + ); + }); + + it("should use userContext values when targetAccountOverride is not provided", async () => { + await createDatabase({ databaseId: "testDb" }); + + expect(mockCreateUpdateSqlDatabase).toHaveBeenCalledWith( + "default-subscription", + "default-rg", + "default-account", + "testDb", + expect.any(Object), + ); + }); + + it("should skip validateDatabaseId check when targetAccountOverride is provided", async () => { + // Simulate database already existing — validateDatabaseId returns false + useDatabases.setState({ + databases: [{ id: ko.observable("testDb") } as unknown as ViewModels.Database], + validateDatabaseId: () => false, + } as unknown as ReturnType); + + const params: CreateDatabaseParams = { + databaseId: "testDb", + targetAccountOverride: { + subscriptionId: "override-sub", + resourceGroup: "override-rg", + accountName: "override-account", + }, + }; + + // Should NOT throw even though the normal duplicate check would fail + await expect(createDatabase(params)).resolves.not.toThrow(); + expect(mockCreateUpdateSqlDatabase).toHaveBeenCalled(); + }); + + it("should throw if validateDatabaseId returns false and no targetAccountOverride is set", async () => { + useDatabases.setState({ + databases: [{ id: ko.observable("existingDb") } as unknown as ViewModels.Database], + validateDatabaseId: () => false, + } as unknown as ReturnType); + + await expect(createDatabase({ databaseId: "existingDb" })).rejects.toThrow(); + expect(mockCreateUpdateSqlDatabase).not.toHaveBeenCalled(); + }); + + it("should pass databaseId in request payload regardless of targetAccountOverride", async () => { + const params: CreateDatabaseParams = { + databaseId: "my-database", + targetAccountOverride: { + subscriptionId: "any-sub", + resourceGroup: "any-rg", + accountName: "any-account", + }, + }; + + await createDatabase(params); + + expect(mockCreateUpdateSqlDatabase).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + "my-database", + expect.objectContaining({ + properties: expect.objectContaining({ + resource: expect.objectContaining({ id: "my-database" }), + }), + }), + ); + }); + }); +}); diff --git a/src/Common/dataAccess/createDatabase.ts b/src/Common/dataAccess/createDatabase.ts index c562c4068..31190cfe7 100644 --- a/src/Common/dataAccess/createDatabase.ts +++ b/src/Common/dataAccess/createDatabase.ts @@ -41,7 +41,7 @@ export async function createDatabase(params: DataModels.CreateDatabaseParams): P } async function createDatabaseWithARM(params: DataModels.CreateDatabaseParams): Promise { - if (!useDatabases.getState().validateDatabaseId(params.databaseId)) { + if (!params.targetAccountOverride && !useDatabases.getState().validateDatabaseId(params.databaseId)) { const databaseName = getDatabaseName().toLocaleLowerCase(); throw new Error(`Create ${databaseName} failed: ${databaseName} with id ${params.databaseId} already exists`); } @@ -72,13 +72,10 @@ async function createSqlDatabase(params: DataModels.CreateDatabaseParams): Promi options, }, }; - const createResponse = await createUpdateSqlDatabase( - userContext.subscriptionId, - userContext.resourceGroup, - userContext.databaseAccount.name, - params.databaseId, - rpPayload, - ); + const sub = params.targetAccountOverride?.subscriptionId ?? userContext.subscriptionId; + const rg = params.targetAccountOverride?.resourceGroup ?? userContext.resourceGroup; + const acct = params.targetAccountOverride?.accountName ?? userContext.databaseAccount.name; + const createResponse = await createUpdateSqlDatabase(sub, rg, acct, params.databaseId, rpPayload); return createResponse && (createResponse.properties.resource as DataModels.Database); } diff --git a/src/Common/dataAccess/readDatabases.test.ts b/src/Common/dataAccess/readDatabases.test.ts index 8f6368f78..ec9acaa37 100644 --- a/src/Common/dataAccess/readDatabases.test.ts +++ b/src/Common/dataAccess/readDatabases.test.ts @@ -1,11 +1,12 @@ jest.mock("../../Utils/arm/request"); jest.mock("../CosmosClient"); + import { AuthType } from "../../AuthType"; import { DatabaseAccount } from "../../Contracts/DataModels"; import { updateUserContext } from "../../UserContext"; import { armRequest } from "../../Utils/arm/request"; import { client } from "../CosmosClient"; -import { readDatabases } from "./readDatabases"; +import { readDatabases, readDatabasesForAccount } from "./readDatabases"; describe("readDatabases", () => { beforeAll(() => { @@ -42,3 +43,64 @@ describe("readDatabases", () => { expect(client).toHaveBeenCalled(); }); }); + +describe("readDatabasesForAccount", () => { + const mockDatabase = { id: "testDb", _rid: "", _self: "", _etag: "", _ts: 0 }; + const mockArmResponse = { value: [{ properties: { resource: mockDatabase } }] }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should call ARM with a path that includes the provided subscriptionId, resourceGroup, and accountName", async () => { + (armRequest as jest.Mock).mockResolvedValue(mockArmResponse); + + await readDatabasesForAccount("test-sub", "test-rg", "test-account"); + + expect(armRequest).toHaveBeenCalledWith( + expect.objectContaining({ + path: expect.stringContaining("/subscriptions/test-sub/resourceGroups/test-rg/"), + }), + ); + expect(armRequest).toHaveBeenCalledWith( + expect.objectContaining({ + path: expect.stringContaining("/databaseAccounts/test-account/sqlDatabases"), + }), + ); + }); + + it("should return mapped database resources from the response", async () => { + const db1 = { id: "db1", _rid: "r1", _self: "/dbs/db1", _etag: "", _ts: 1 }; + const db2 = { id: "db2", _rid: "r2", _self: "/dbs/db2", _etag: "", _ts: 2 }; + + (armRequest as jest.Mock).mockResolvedValue({ + value: [{ properties: { resource: db1 } }, { properties: { resource: db2 } }], + }); + + const result = await readDatabasesForAccount("sub", "rg", "account"); + + expect(result).toEqual([db1, db2]); + }); + + it("should return an empty array when the response is null", async () => { + (armRequest as jest.Mock).mockResolvedValue(null); + + const result = await readDatabasesForAccount("sub", "rg", "account"); + + expect(result).toEqual([]); + }); + + it("should return an empty array when value is an empty list", async () => { + (armRequest as jest.Mock).mockResolvedValue({ value: [] }); + + const result = await readDatabasesForAccount("sub", "rg", "account"); + + expect(result).toEqual([]); + }); + + it("should throw and propagate errors from the ARM call", async () => { + (armRequest as jest.Mock).mockRejectedValue(new Error("ARM request failed")); + + await expect(readDatabasesForAccount("sub", "rg", "account")).rejects.toThrow("ARM request failed"); + }); +}); diff --git a/src/Common/dataAccess/readDatabases.ts b/src/Common/dataAccess/readDatabases.ts index 66ea1e76f..45224b715 100644 --- a/src/Common/dataAccess/readDatabases.ts +++ b/src/Common/dataAccess/readDatabases.ts @@ -112,3 +112,20 @@ async function readDatabasesWithARM(): Promise { return rpResponse?.value?.map((database) => database.properties?.resource as DataModels.Database); } + +export async function readDatabasesForAccount( + subscriptionId: string, + resourceGroup: string, + accountName: string, +): Promise { + const clearMessage = logConsoleProgress(`Querying databases for account ${accountName}`); + try { + const rpResponse = await listSqlDatabases(subscriptionId, resourceGroup, accountName); + return rpResponse?.value?.map((database) => database.properties?.resource as DataModels.Database) ?? []; + } catch (error) { + handleError(error, "ReadDatabasesForAccount", `Error while querying databases for account ${accountName}`); + throw error; + } finally { + clearMessage(); + } +} diff --git a/src/Contracts/DataModels.ts b/src/Contracts/DataModels.ts index e891fed58..2919e0d48 100644 --- a/src/Contracts/DataModels.ts +++ b/src/Contracts/DataModels.ts @@ -404,11 +404,18 @@ export interface AutoPilotOfferSettings { targetMaxThroughput?: number; } +export interface AccountOverride { + subscriptionId: string; + resourceGroup: string; + accountName: string; +} + export interface CreateDatabaseParams { autoPilotMaxThroughput?: number; databaseId: string; databaseLevelThroughput?: boolean; offerThroughput?: number; + targetAccountOverride?: AccountOverride; } export interface CreateCollectionParamsBase { @@ -428,6 +435,7 @@ export interface CreateCollectionParamsBase { export interface CreateCollectionParams extends CreateCollectionParamsBase { createNewDatabase: boolean; collectionId: string; + targetAccountOverride?: AccountOverride; } export interface CreateMaterializedViewsParams extends CreateCollectionParamsBase { diff --git a/src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx b/src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx index 9bb7fe3ea..b491f6bf5 100644 --- a/src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx +++ b/src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx @@ -457,13 +457,13 @@ describe("CopyJobActions", () => { jobName: "test-job", migrationType: "online" as any, source: { - subscription: {} as any, + subscriptionId: "sub-123", account: { id: "account-1", name: "source-account" } as any, databaseId: "source-db", containerId: "source-container", }, target: { - subscriptionId: "sub-123", + subscription: {} as any, account: { id: "account-1", name: "target-account" } as any, databaseId: "target-db", containerId: "target-container", @@ -498,7 +498,7 @@ describe("CopyJobActions", () => { ); const callArgs = (dataTransferService.create as jest.Mock).mock.calls[0][4]; - expect(callArgs.properties.source.remoteAccountName).toBeUndefined(); + expect(callArgs.properties.destination.remoteAccountName).toBeUndefined(); expect(mockRefreshJobList).toHaveBeenCalled(); expect(mockOnSuccess).toHaveBeenCalled(); @@ -509,13 +509,13 @@ describe("CopyJobActions", () => { jobName: "cross-account-job", migrationType: "offline" as any, source: { - subscription: {} as any, + subscriptionId: "sub-123", account: { id: "account-1", name: "source-account" } as any, databaseId: "source-db", containerId: "source-container", }, target: { - subscriptionId: "sub-456", + subscription: {} as any, account: { id: "account-2", name: "target-account" } as any, databaseId: "target-db", containerId: "target-container", @@ -528,7 +528,7 @@ describe("CopyJobActions", () => { await submitCreateCopyJob(mockState, mockOnSuccess); const callArgs = (dataTransferService.create as jest.Mock).mock.calls[0][4]; - expect(callArgs.properties.source.remoteAccountName).toBe("source-account"); + expect(callArgs.properties.destination.remoteAccountName).toBe("target-account"); expect(mockOnSuccess).toHaveBeenCalled(); }); @@ -537,13 +537,13 @@ describe("CopyJobActions", () => { jobName: "failing-job", migrationType: "online" as any, source: { - subscription: {} as any, + subscriptionId: "sub-123", account: { id: "account-1", name: "source-account" } as any, databaseId: "source-db", containerId: "source-container", }, target: { - subscriptionId: "sub-123", + subscription: {} as any, account: { id: "account-1", name: "target-account" } as any, databaseId: "target-db", containerId: "target-container", @@ -566,13 +566,13 @@ describe("CopyJobActions", () => { jobName: "test-job", migrationType: "online" as any, source: { - subscription: {} as any, + subscriptionId: "sub-123", account: { id: "account-1", name: "source-account" } as any, databaseId: "source-db", containerId: "source-container", }, target: { - subscriptionId: "sub-123", + subscription: {} as any, account: { id: "account-1", name: "target-account" } as any, databaseId: "target-db", containerId: "target-container", diff --git a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx index 821f87bc9..ed342bf29 100644 --- a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx +++ b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx @@ -137,12 +137,12 @@ export const submitCreateCopyJob = async (state: CopyJobContextState, onSuccess: properties: { source: { component: "CosmosDBSql", - ...(isSameAccount ? {} : { remoteAccountName: source?.account?.name }), databaseName: source?.databaseId, containerName: source?.containerId, }, destination: { component: "CosmosDBSql", + ...(isSameAccount ? {} : { remoteAccountName: target?.account?.name }), databaseName: target?.databaseId, containerName: target?.containerId, }, diff --git a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts index 65b308b9b..8ccf368ec 100644 --- a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts +++ b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts @@ -20,11 +20,11 @@ export default { createCopyJobPanelTitle: "Create copy job", // Select Account Screen - selectAccountDescription: "Please select a source account from which to copy.", + selectAccountDescription: "Please select a destination account to copy to.", subscriptionDropdownLabel: "Subscription", subscriptionDropdownPlaceholder: "Select a subscription", - sourceAccountDropdownLabel: "Account", - sourceAccountDropdownPlaceholder: "Select an account", + destinationAccountDropdownLabel: "Account", + destinationAccountDropdownPlaceholder: "Select an account", migrationTypeOptions: { offline: { title: "Offline mode", @@ -47,14 +47,17 @@ export default { databaseDropdownPlaceholder: "Select a database", containerDropdownLabel: "Container", containerDropdownPlaceholder: "Select a container", - createNewContainerSubHeading: "Select the properties for your container.", + createNewContainerSubHeading: (accountName?: string) => + accountName + ? `Configure the properties for the new container on destination account "${accountName}".` + : "Configure the properties for the new container.", createContainerButtonLabel: "Create a new container", createContainerHeading: "Create new container", // Preview and Create Screen jobNameLabel: "Job name", - sourceSubscriptionLabel: "Source subscription", - sourceAccountLabel: "Source account", + destinationSubscriptionLabel: "Destination subscription", + destinationAccountLabel: "Destination account", sourceDatabaseLabel: "Source database", sourceContainerLabel: "Source container", targetDatabaseLabel: "Destination database", diff --git a/src/Explorer/ContainerCopy/Context/CopyJobContext.test.tsx b/src/Explorer/ContainerCopy/Context/CopyJobContext.test.tsx index 7a0e7d874..c3383077d 100644 --- a/src/Explorer/ContainerCopy/Context/CopyJobContext.test.tsx +++ b/src/Explorer/ContainerCopy/Context/CopyJobContext.test.tsx @@ -59,12 +59,6 @@ describe("CopyJobContext", () => { jobName: "", migrationType: CopyJobMigrationType.Offline, source: { - subscription: null, - account: null, - databaseId: "", - containerId: "", - }, - target: { subscriptionId: "test-subscription-id", account: { id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account", @@ -75,6 +69,12 @@ describe("CopyJobContext", () => { databaseId: "", containerId: "", }, + target: { + subscription: null, + account: null, + databaseId: "", + containerId: "", + }, sourceReadAccessFromTarget: false, }); expect(contextValue.flow).toBeNull(); @@ -598,8 +598,8 @@ describe("CopyJobContext", () => { , ); - expect(contextValue.copyJobState.source?.subscription?.subscriptionId).toBeUndefined(); - expect(contextValue.copyJobState.source?.account?.name).toBeUndefined(); + expect(contextValue.copyJobState.source?.subscriptionId).toBe("test-subscription-id"); + expect(contextValue.copyJobState.source?.account?.name).toBe("test-account"); }); it("should initialize target with userContext values", () => { @@ -616,8 +616,8 @@ describe("CopyJobContext", () => { , ); - expect(contextValue.copyJobState.target.subscriptionId).toBe("test-subscription-id"); - expect(contextValue.copyJobState.target.account.name).toBe("test-account"); + expect(contextValue.copyJobState.target.subscription).toBeNull(); + expect(contextValue.copyJobState.target.account).toBeNull(); }); it("should initialize sourceReadAccessFromTarget as false", () => { diff --git a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx index ddb936dcf..72cb59527 100644 --- a/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx +++ b/src/Explorer/ContainerCopy/Context/CopyJobContext.tsx @@ -23,14 +23,14 @@ const getInitialCopyJobState = (): CopyJobContextState => { jobName: "", migrationType: CopyJobMigrationType.Offline, source: { - subscription: null, - account: null, + subscriptionId: userContext.subscriptionId || "", + account: userContext.databaseAccount || null, databaseId: "", containerId: "", }, target: { - subscriptionId: userContext.subscriptionId || "", - account: userContext.databaseAccount || null, + subscription: null, + account: null, databaseId: "", containerId: "", }, diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.test.tsx index 5ef3577b8..2aa90265a 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.test.tsx @@ -86,7 +86,7 @@ describe("AddReadPermissionToDefaultIdentity Component", () => { jobName: "test-job", migrationType: CopyJobMigrationType.Offline, source: { - subscription: { subscriptionId: "source-sub-id" } as Subscription, + subscriptionId: "source-sub-id", account: { id: "/subscriptions/source-sub-id/resourceGroups/source-rg/providers/Microsoft.DocumentDB/databaseAccounts/source-account", name: "source-account", @@ -101,7 +101,7 @@ describe("AddReadPermissionToDefaultIdentity Component", () => { containerId: "source-container", }, target: { - subscriptionId: "target-sub-id", + subscription: { subscriptionId: "target-sub-id" } as Subscription, account: { id: "/subscriptions/target-sub-id/resourceGroups/target-rg/providers/Microsoft.DocumentDB/databaseAccounts/target-account", name: "target-account", diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.test.tsx index 76f915c5e..1849a1385 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.test.tsx @@ -85,13 +85,13 @@ describe("AssignPermissions Component", () => { jobName: "test-job", migrationType: CopyJobMigrationType.Offline, source: { - subscription: { subscriptionId: "source-sub" } as any, + subscriptionId: "source-sub", account: { id: "source-account", name: "Source Account" } as any, databaseId: "source-db", containerId: "source-container", }, target: { - subscriptionId: "target-sub", + subscription: { subscriptionId: "target-sub" } as any, account: { id: "target-account", name: "Target Account" } as any, databaseId: "target-db", containerId: "target-container", @@ -164,13 +164,13 @@ describe("AssignPermissions Component", () => { const copyJobState = createMockCopyJobState({ migrationType: CopyJobMigrationType.Online, source: { - subscription: { subscriptionId: "same-sub" } as any, + subscriptionId: "same-sub", account: { id: "same-account", name: "Same Account" } as any, databaseId: "source-db", containerId: "source-container", }, target: { - subscriptionId: "same-sub", + subscription: { subscriptionId: "same-sub" } as any, account: { id: "same-account", name: "Same Account" } as any, databaseId: "target-db", containerId: "target-container", @@ -347,7 +347,7 @@ describe("AssignPermissions Component", () => { it("should handle missing account names", () => { const copyJobState = createMockCopyJobState({ source: { - subscription: { subscriptionId: "source-sub" } as any, + subscriptionId: "source-sub", account: { id: "source-account" } as any, databaseId: "source-db", containerId: "source-container", diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.test.tsx index de5ddcc49..58ab0745b 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.test.tsx @@ -50,13 +50,13 @@ describe("PointInTimeRestore", () => { jobName: "test-job", migrationType: CopyJobMigrationType.Offline, source: { - subscription: { subscriptionId: "test-sub", displayName: "Test Subscription" }, + subscriptionId: "test-sub", account: mockSourceAccount, databaseId: "test-db", containerId: "test-container", }, target: { - subscriptionId: "test-sub", + subscription: { subscriptionId: "test-sub", displayName: "Test Subscription" }, account: mockSourceAccount, databaseId: "target-db", containerId: "target-container", diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.test.tsx index 78935657d..a18162a4b 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/hooks/usePermissionsSection.test.tsx @@ -133,7 +133,7 @@ describe("usePermissionsSection", () => { type: "", kind: "", }, - subscription: undefined, + subscriptionId: "", databaseId: "", containerId: "", }, @@ -152,7 +152,7 @@ describe("usePermissionsSection", () => { type: "", kind: "", }, - subscriptionId: "", + subscription: undefined, databaseId: "", containerId: "", }, @@ -208,7 +208,7 @@ describe("usePermissionsSection", () => { type: "", kind: "", }, - subscription: undefined, + subscriptionId: "", databaseId: "", containerId: "", }, @@ -222,7 +222,7 @@ describe("usePermissionsSection", () => { type: "", kind: "", }, - subscriptionId: "", + subscription: undefined, databaseId: "", containerId: "", }, @@ -299,7 +299,7 @@ describe("usePermissionsSection", () => { type: "", kind: "", }, - subscriptionId: "", + subscription: undefined, databaseId: "", containerId: "", }, @@ -337,7 +337,7 @@ describe("usePermissionsSection", () => { type: "", kind: "", }, - subscriptionId: "", + subscription: undefined, databaseId: "", containerId: "", }, @@ -398,7 +398,7 @@ describe("usePermissionsSection", () => { type: "", kind: "", }, - subscriptionId: "", + subscription: undefined, databaseId: "", containerId: "", }, @@ -435,7 +435,7 @@ describe("usePermissionsSection", () => { type: "", kind: "", }, - subscription: undefined, + subscriptionId: "", databaseId: "", containerId: "", }, @@ -476,7 +476,7 @@ describe("usePermissionsSection", () => { type: "", kind: "", }, - subscription: undefined, + subscriptionId: "", databaseId: "", containerId: "", }, @@ -546,7 +546,7 @@ describe("usePermissionsSection", () => { type: "", kind: "", }, - subscriptionId: "", + subscription: undefined, databaseId: "", containerId: "", }, diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.test.tsx index 226ea15af..5e6e5f90a 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.test.tsx @@ -109,7 +109,7 @@ describe("AddCollectionPanelWrapper", () => { expect(container.querySelector(".addCollectionPanelWrapper")).toBeInTheDocument(); expect(container.querySelector(".addCollectionPanelHeader")).toBeInTheDocument(); expect(container.querySelector(".addCollectionPanelBody")).toBeInTheDocument(); - expect(screen.getByText(ContainerCopyMessages.createNewContainerSubHeading)).toBeInTheDocument(); + expect(screen.getByText(ContainerCopyMessages.createNewContainerSubHeading())).toBeInTheDocument(); expect(screen.getByTestId("add-collection-panel")).toBeInTheDocument(); }); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx index a274c9bbc..38c622220 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx @@ -1,11 +1,14 @@ -import { Stack, Text } from "@fluentui/react"; +import { IDropdownOption, MessageBar, MessageBarType, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react"; +import { readDatabasesForAccount } from "Common/dataAccess/readDatabases"; +import { AccountOverride } from "Contracts/DataModels"; import Explorer from "Explorer/Explorer"; import { useSidePanel } from "hooks/useSidePanel"; import { produce } from "immer"; -import React, { useCallback, useEffect } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { AddCollectionPanel } from "../../../../Panes/AddCollectionPanel/AddCollectionPanel"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; import { useCopyJobContext } from "../../../Context/CopyJobContext"; +import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils"; type AddCollectionPanelWrapperProps = { explorer?: Explorer; @@ -13,7 +16,26 @@ type AddCollectionPanelWrapperProps = { }; const AddCollectionPanelWrapper: React.FunctionComponent = ({ explorer, goBack }) => { - const { setCopyJobState } = useCopyJobContext(); + const { setCopyJobState, copyJobState } = useCopyJobContext(); + const [destinationDatabases, setDestinationDatabases] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [permissionError, setPermissionError] = useState(null); + + const targetAccountOverride: AccountOverride | undefined = useMemo(() => { + const accountId = copyJobState?.target?.account?.id; + if (!accountId) { + return undefined; + } + const details = getAccountDetailsFromResourceId(accountId); + if (!details?.subscriptionId || !details?.resourceGroup || !details?.accountName) { + return undefined; + } + return { + subscriptionId: details.subscriptionId, + resourceGroup: details.resourceGroup, + accountName: details.accountName, + }; + }, [copyJobState?.target?.account?.id]); useEffect(() => { const sidePanelStore = useSidePanel.getState(); @@ -25,6 +47,52 @@ const AddCollectionPanelWrapper: React.FunctionComponent { + if (!targetAccountOverride) { + setIsLoading(false); + return undefined; + } + + let cancelled = false; + const fetchDatabases = async () => { + setIsLoading(true); + setPermissionError(null); + try { + const databases = await readDatabasesForAccount( + targetAccountOverride.subscriptionId, + targetAccountOverride.resourceGroup, + targetAccountOverride.accountName, + ); + if (!cancelled) { + setDestinationDatabases(databases.map((db) => ({ key: db.id, text: db.id }))); + } + } catch (error) { + if (!cancelled) { + const message = error?.message || String(error); + if (message.includes("AuthorizationFailed") || message.includes("403")) { + setPermissionError( + `You do not have sufficient permissions to access the destination account "${targetAccountOverride.accountName}". ` + + "Please ensure you have at least Contributor or Owner access to create databases and containers.", + ); + } else { + setPermissionError( + `Failed to load databases from the destination account "${targetAccountOverride.accountName}": ${message}`, + ); + } + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + }; + + fetchDatabases(); + return () => { + cancelled = true; + }; + }, [targetAccountOverride]); + const handleAddCollectionSuccess = useCallback( (collectionData: { databaseId: string; collectionId: string }) => { setCopyJobState( @@ -38,13 +106,37 @@ const AddCollectionPanelWrapper: React.FunctionComponent + + + ); + } + + if (permissionError) { + return ( + + {permissionError} + + ); + } + return ( - {ContainerCopyMessages.createNewContainerSubHeading} + + {ContainerCopyMessages.createNewContainerSubHeading(targetAccountOverride?.accountName)} + - + ); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap index ae6d7b4ec..71d736b53 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap @@ -3,19 +3,19 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot 1`] = `
- Select the properties for your container. + Configure the properties for the new container.
- Select the properties for your container. + Configure the properties for the new container.
- Select the properties for your container. + Configure the properties for the new container.
- Select the properties for your container. + Configure the properties for the new container.
{ jobName: "", migrationType: CopyJobMigrationType.Offline, source: { - subscription: mockSubscription, + subscriptionId: "test-subscription-id", account: mockDatabaseAccount, databaseId: "source-database", containerId: "source-container", }, target: { - subscriptionId: "test-subscription-id", + subscription: mockSubscription, account: mockDatabaseAccount, databaseId: "target-database", containerId: "target-container", @@ -146,7 +146,7 @@ describe("PreviewCopyJob", () => { it("should render with missing source subscription information", () => { const mockContext = createMockContext({ source: { - subscription: undefined, + subscriptionId: "", account: mockDatabaseAccount, databaseId: "source-database", containerId: "source-container", @@ -165,7 +165,7 @@ describe("PreviewCopyJob", () => { it("should render with missing source account information", () => { const mockContext = createMockContext({ source: { - subscription: mockSubscription, + subscriptionId: "test-subscription-id", account: null, databaseId: "source-database", containerId: "source-container", @@ -184,13 +184,13 @@ describe("PreviewCopyJob", () => { it("should render with undefined database and container names", () => { const mockContext = createMockContext({ source: { - subscription: mockSubscription, + subscriptionId: "test-subscription-id", account: mockDatabaseAccount, databaseId: "", containerId: "", }, target: { - subscriptionId: "test-subscription-id", + subscription: mockSubscription, account: mockDatabaseAccount, databaseId: "", containerId: "", @@ -219,7 +219,7 @@ describe("PreviewCopyJob", () => { const mockContext = createMockContext({ source: { - subscription: longNameSubscription, + subscriptionId: longNameSubscription.subscriptionId, account: longNameAccount, databaseId: "long-database-name-for-testing-purposes", containerId: "long-container-name-for-testing-purposes", @@ -253,13 +253,13 @@ describe("PreviewCopyJob", () => { it("should handle special characters in database and container names", () => { const mockContext = createMockContext({ source: { - subscription: mockSubscription, + subscriptionId: "test-subscription-id", account: mockDatabaseAccount, databaseId: "test-db_with@special#chars", containerId: "test-container_with@special#chars", }, target: { - subscriptionId: "test-subscription-id", + subscription: mockSubscription, account: mockDatabaseAccount, databaseId: "target-db_with@special#chars", containerId: "target-container_with@special#chars", @@ -285,7 +285,7 @@ describe("PreviewCopyJob", () => { const mockContext = createMockContext({ target: { - subscriptionId: "target-subscription-id", + subscription: mockSubscription, account: targetAccount, databaseId: "target-database", containerId: "target-container", @@ -360,7 +360,7 @@ describe("PreviewCopyJob", () => { ); expect(getByText(/Job name/i)).toBeInTheDocument(); - expect(getByText(/Source subscription/i)).toBeInTheDocument(); - expect(getByText(/Source account/i)).toBeInTheDocument(); + expect(getByText(/Destination subscription/i)).toBeInTheDocument(); + expect(getByText(/Destination account/i)).toBeInTheDocument(); }); }); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx index 84abc0ece..1e83a22ff 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx @@ -36,15 +36,15 @@ const PreviewCopyJob: React.FC = () => { - {ContainerCopyMessages.sourceSubscriptionLabel} - - {copyJobState.source?.subscription?.displayName} + {ContainerCopyMessages.destinationSubscriptionLabel} + + {copyJobState.target?.subscription?.displayName} - {ContainerCopyMessages.sourceAccountLabel} - - {copyJobState.source?.account?.name} + {ContainerCopyMessages.destinationAccountLabel} + + {copyJobState.target?.account?.name} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/__snapshots__/PreviewCopyJob.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/__snapshots__/PreviewCopyJob.test.tsx.snap index 90bc05a8d..d11300143 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/__snapshots__/PreviewCopyJob.test.tsx.snap +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/__snapshots__/PreviewCopyJob.test.tsx.snap @@ -49,11 +49,11 @@ exports[`PreviewCopyJob should handle special characters in database and contain - Source subscription + Destination subscription Test Subscription @@ -64,11 +64,11 @@ exports[`PreviewCopyJob should handle special characters in database and contain - Source account + Destination account test-account @@ -371,11 +371,11 @@ exports[`PreviewCopyJob should render component with cross-subscription setup 1` - Source subscription + Destination subscription Test Subscription @@ -386,13 +386,13 @@ exports[`PreviewCopyJob should render component with cross-subscription setup 1` - Source account + Destination account - test-account + target-account
- Source subscription + Destination subscription Test Subscription @@ -708,11 +708,11 @@ exports[`PreviewCopyJob should render with default state and empty job name 1`] - Source account + Destination account test-account @@ -1015,13 +1015,13 @@ exports[`PreviewCopyJob should render with long subscription and account names 1 - Source subscription + Destination subscription - This is a very long subscription name that might cause display issues if not handled properly + Test Subscription
- Source account + Destination account - this-is-a-very-long-database-account-name-that-might-cause-display-issues + test-account
- Source subscription + Destination subscription Test Subscription @@ -1352,7 +1352,13 @@ exports[`PreviewCopyJob should render with missing source account information 1` - Source account + Destination account + + + test-account
- Source subscription + Destination subscription + + + Test Subscription
- Source account + Destination account test-account @@ -1969,11 +1981,11 @@ exports[`PreviewCopyJob should render with online migration type 1`] = ` - Source subscription + Destination subscription Test Subscription @@ -1984,11 +1996,11 @@ exports[`PreviewCopyJob should render with online migration type 1`] = ` - Source account + Destination account test-account @@ -2291,11 +2303,11 @@ exports[`PreviewCopyJob should render with pre-filled job name 1`] = ` - Source subscription + Destination subscription Test Subscription @@ -2306,11 +2318,11 @@ exports[`PreviewCopyJob should render with pre-filled job name 1`] = ` - Source account + Destination account test-account @@ -2613,11 +2625,11 @@ exports[`PreviewCopyJob should render with undefined database and container name - Source subscription + Destination subscription Test Subscription @@ -2628,11 +2640,11 @@ exports[`PreviewCopyJob should render with undefined database and container name - Source account + Destination account test-account diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx index d56031fed..8a90c4bb7 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx @@ -38,16 +38,16 @@ describe("AccountDropdown", () => { jobName: "", migrationType: CopyJobMigrationType.Offline, source: { - subscription: { - subscriptionId: "test-subscription-id", - displayName: "Test Subscription", - }, + subscriptionId: "", account: null, databaseId: "", containerId: "", }, target: { - subscriptionId: "", + subscription: { + subscriptionId: "test-subscription-id", + displayName: "Test Subscription", + }, account: null, databaseId: "", containerId: "", @@ -129,11 +129,11 @@ describe("AccountDropdown", () => { renderWithContext(); expect( - screen.getByText(`${ContainerCopyMessages.sourceAccountDropdownLabel}:`, { exact: true }), + screen.getByText(`${ContainerCopyMessages.destinationAccountDropdownLabel}:`, { exact: true }), ).toBeInTheDocument(); expect(screen.getByRole("combobox")).toHaveAttribute( "aria-label", - ContainerCopyMessages.sourceAccountDropdownLabel, + ContainerCopyMessages.destinationAccountDropdownLabel, ); }); @@ -202,7 +202,7 @@ describe("AccountDropdown", () => { const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0]; const newState = stateUpdateFunction(mockCopyJobState); - expect(newState.source.account).toEqual({ + expect(newState.target.account).toEqual({ ...mockDatabaseAccount1, id: normalizeAccountId(mockDatabaseAccount1.id), }); @@ -226,20 +226,21 @@ describe("AccountDropdown", () => { const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0]; const newState = stateUpdateFunction(mockCopyJobState); - expect(newState.source.account).toEqual({ + expect(newState.target.account).toEqual({ ...mockDatabaseAccount2, id: normalizeAccountId(mockDatabaseAccount2.id), }); }); it("should keep current account if it exists in the filtered list", async () => { + const normalizedAccount1 = { ...mockDatabaseAccount1, id: normalizeAccountId(mockDatabaseAccount1.id) }; const contextWithSelectedAccount = { ...mockCopyJobContextValue, copyJobState: { ...mockCopyJobState, - source: { - ...mockCopyJobState.source, - account: mockDatabaseAccount1, + target: { + ...mockCopyJobState.target, + account: normalizedAccount1, }, }, }; @@ -256,12 +257,9 @@ describe("AccountDropdown", () => { const newState = stateUpdateFunction(contextWithSelectedAccount.copyJobState); expect(newState).toEqual({ ...contextWithSelectedAccount.copyJobState, - source: { - ...contextWithSelectedAccount.copyJobState.source, - account: { - ...mockDatabaseAccount1, - id: normalizeAccountId(mockDatabaseAccount1.id), - }, + target: { + ...contextWithSelectedAccount.copyJobState.target, + account: normalizedAccount1, }, }); }); @@ -297,8 +295,8 @@ describe("AccountDropdown", () => { ...mockCopyJobContextValue, copyJobState: { ...mockCopyJobState, - source: { - ...mockCopyJobState.source, + target: { + ...mockCopyJobState.target, account: portalAccount, }, }, @@ -323,8 +321,8 @@ describe("AccountDropdown", () => { ...mockCopyJobContextValue, copyJobState: { ...mockCopyJobState, - source: { - ...mockCopyJobState.source, + target: { + ...mockCopyJobState.target, account: hostedAccount, }, }, @@ -361,8 +359,8 @@ describe("AccountDropdown", () => { ...mockCopyJobContextValue, copyJobState: { ...mockCopyJobState, - source: { - ...mockCopyJobState.source, + target: { + ...mockCopyJobState.target, subscription: null, }, } as CopyJobContextState, @@ -376,13 +374,13 @@ describe("AccountDropdown", () => { }); it("should not update state if account is already selected and the same", async () => { - const selectedAccount = mockDatabaseAccount1; + const selectedAccount = { ...mockDatabaseAccount1, id: normalizeAccountId(mockDatabaseAccount1.id) }; const contextWithSelectedAccount = { ...mockCopyJobContextValue, copyJobState: { ...mockCopyJobState, - source: { - ...mockCopyJobState.source, + target: { + ...mockCopyJobState.target, account: selectedAccount, }, }, @@ -409,7 +407,7 @@ describe("AccountDropdown", () => { renderWithContext(); const dropdown = screen.getByRole("combobox"); - expect(dropdown).toHaveAttribute("aria-label", ContainerCopyMessages.sourceAccountDropdownLabel); + expect(dropdown).toHaveAttribute("aria-label", ContainerCopyMessages.destinationAccountDropdownLabel); }); it("should have required attribute", () => { diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx index 7c19790e3..f2847c5be 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx @@ -25,7 +25,7 @@ export const normalizeAccountId = (id: string = "") => { export const AccountDropdown: React.FC = () => { const { copyJobState, setCopyJobState } = useCopyJobContext(); - const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId; + const selectedSubscriptionId = copyJobState?.target?.subscription?.subscriptionId; const allAccounts: DatabaseAccount[] = useDatabaseAccounts(selectedSubscriptionId); const sqlApiOnlyAccounts = (allAccounts || []) .filter((account) => apiType(account) === "SQL") @@ -36,11 +36,11 @@ export const AccountDropdown: React.FC = () => { const updateCopyJobState = (newAccount: DatabaseAccount) => { setCopyJobState((prevState) => { - if (prevState.source?.account?.id !== newAccount.id) { + if (prevState.target?.account?.id !== newAccount.id) { return { ...prevState, - source: { - ...prevState.source, + target: { + ...prevState.target, account: newAccount, }, }; @@ -51,13 +51,13 @@ export const AccountDropdown: React.FC = () => { useEffect(() => { if (sqlApiOnlyAccounts && sqlApiOnlyAccounts.length > 0 && selectedSubscriptionId) { - const currentAccountId = copyJobState?.source?.account?.id; + const currentAccountId = copyJobState?.target?.account?.id; const predefinedAccountId = normalizeAccountId(userContext.databaseAccount?.id); const selectedAccountId = currentAccountId || predefinedAccountId; - const targetAccount: DatabaseAccount | null = + const matchedAccount: DatabaseAccount | null = sqlApiOnlyAccounts.find((account) => account.id === selectedAccountId) || null; - updateCopyJobState(targetAccount || sqlApiOnlyAccounts[0]); + updateCopyJobState(matchedAccount || sqlApiOnlyAccounts[0]); } }, [sqlApiOnlyAccounts?.length, selectedSubscriptionId]); @@ -77,13 +77,13 @@ export const AccountDropdown: React.FC = () => { }; const isAccountDropdownDisabled = !selectedSubscriptionId || accountOptions.length === 0; - const selectedAccountId = normalizeAccountId(copyJobState?.source?.account?.id ?? ""); + const selectedAccountId = normalizeAccountId(copyJobState?.target?.account?.id ?? ""); return ( - + = React.m const updateCopyJobState = (newSubscription: Subscription) => { setCopyJobState((prevState) => { - if (prevState.source?.subscription?.subscriptionId !== newSubscription.subscriptionId) { + if (prevState.target?.subscription?.subscriptionId !== newSubscription.subscriptionId) { return { ...prevState, - source: { - ...prevState.source, + target: { + ...prevState.target, subscription: newSubscription, account: null, }, @@ -33,7 +33,7 @@ export const SubscriptionDropdown: React.FC = React.m useEffect(() => { if (subscriptions && subscriptions.length > 0) { - const currentSubscriptionId = copyJobState?.source?.subscription?.subscriptionId; + const currentSubscriptionId = copyJobState?.target?.subscription?.subscriptionId; const predefinedSubscriptionId = userContext.subscriptionId; const selectedSubscriptionId = currentSubscriptionId || predefinedSubscriptionId; @@ -61,7 +61,7 @@ export const SubscriptionDropdown: React.FC = React.m } }; - const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId; + const selectedSubscriptionId = copyJobState?.target?.subscription?.subscriptionId; return ( diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx index 65529338e..e620af348 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx @@ -30,13 +30,13 @@ describe("SelectAccount", () => { jobName: "", migrationType: CopyJobMigrationType.Online, source: { - subscription: null as any, + subscriptionId: "", account: null as any, databaseId: "", containerId: "", }, target: { - subscriptionId: "", + subscription: null as any, account: null as any, databaseId: "", containerId: "", @@ -68,7 +68,7 @@ describe("SelectAccount", () => { expect(container.firstChild).toHaveAttribute("data-test", "Panel:SelectAccountContainer"); expect(container.firstChild).toHaveClass("selectAccountContainer"); - expect(screen.getByText(/Please select a source account from which to copy/i)).toBeInTheDocument(); + expect(screen.getByText(/Please select a destination account to copy to/i)).toBeInTheDocument(); expect(screen.getByTestId("subscription-dropdown")).toBeInTheDocument(); expect(screen.getByTestId("account-dropdown")).toBeInTheDocument(); 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 0b540eba6..412af7f3f 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 @@ -8,7 +8,7 @@ exports[`SelectAccount Component Rendering should render correctly with snapshot - Please select a source account from which to copy. + Please select a destination account to copy to.
({ migrationType: CopyJobMigrationType.Offline, sourceReadAccessFromTarget: false, source: { - subscription: { - subscriptionId: "source-sub-id", - displayName: "Source Subscription", - state: "Enabled", - subscriptionPolicies: { - locationPlacementId: "test", - quotaId: "test", - spendingLimit: "Off", - }, - authorizationSource: "test", - }, + subscriptionId: "source-sub-id", account: { id: "source-account-id", name: "source-account", @@ -50,7 +40,17 @@ const createMockInitialState = (): CopyJobContextState => ({ containerId: "source-container", }, target: { - subscriptionId: "target-sub-id", + subscription: { + subscriptionId: "target-sub-id", + displayName: "Target Subscription", + state: "Enabled", + subscriptionPolicies: { + locationPlacementId: "test", + quotaId: "test", + spendingLimit: "Off", + }, + authorizationSource: "test", + }, account: { id: "target-account-id", name: "target-account", @@ -169,7 +169,7 @@ describe("dropDownChangeHandler", () => { expect(capturedState.source.databaseId).toBe("new-source-db"); expect(capturedState.source.containerId).toBeUndefined(); - expect(capturedState.source.subscription).toEqual(initialState.source.subscription); + expect(capturedState.source.subscriptionId).toEqual(initialState.source.subscriptionId); expect(capturedState.source.account).toEqual(initialState.source.account); expect(capturedState.target).toEqual(initialState.target); }); @@ -193,7 +193,7 @@ describe("dropDownChangeHandler", () => { expect(capturedState.source.containerId).toBe("new-source-container"); expect(capturedState.source.databaseId).toBe(initialState.source.databaseId); - expect(capturedState.source.subscription).toEqual(initialState.source.subscription); + expect(capturedState.source.subscriptionId).toEqual(initialState.source.subscriptionId); expect(capturedState.source.account).toEqual(initialState.source.account); expect(capturedState.target).toEqual(initialState.target); }); @@ -215,7 +215,7 @@ describe("dropDownChangeHandler", () => { expect(capturedState.target.databaseId).toBe("new-target-db"); expect(capturedState.target.containerId).toBeUndefined(); - expect(capturedState.target.subscriptionId).toBe(initialState.target.subscriptionId); + expect(capturedState.target.subscription).toEqual(initialState.target.subscription); expect(capturedState.target.account).toEqual(initialState.target.account); expect(capturedState.source).toEqual(initialState.source); }); @@ -239,7 +239,7 @@ describe("dropDownChangeHandler", () => { expect(capturedState.target.containerId).toBe("new-target-container"); expect(capturedState.target.databaseId).toBe(initialState.target.databaseId); - expect(capturedState.target.subscriptionId).toBe(initialState.target.subscriptionId); + expect(capturedState.target.subscription).toEqual(initialState.target.subscription); expect(capturedState.target.account).toEqual(initialState.target.account); expect(capturedState.source).toEqual(initialState.source); }); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.test.tsx index 5b7a6b13f..544bef450 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.test.tsx @@ -73,7 +73,7 @@ describe("SelectSourceAndTargetContainers", () => { jobName: "", migrationType: CopyJobMigrationType.Offline, source: { - subscription: { subscriptionId: "test-subscription-id" }, + subscriptionId: "test-subscription-id", account: { id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account", name: "test-account", @@ -82,7 +82,7 @@ describe("SelectSourceAndTargetContainers", () => { containerId: "container1", }, target: { - subscriptionId: "test-subscription-id", + subscription: { subscriptionId: "test-subscription-id" }, account: { id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account", name: "test-account", diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.test.tsx index 85de409cb..7488e7444 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/memoizedData.test.tsx @@ -71,13 +71,13 @@ describe("useSourceAndTargetData", () => { migrationType: CopyJobMigrationType.Offline, sourceReadAccessFromTarget: false, source: { - subscription: mockSubscription, + subscriptionId: "source-subscription-id", account: mockSourceAccount, databaseId: "source-db", containerId: "source-container", }, target: { - subscriptionId: "target-subscription-id", + subscription: mockSubscription, account: mockTargetAccount, databaseId: "target-db", containerId: "target-container", diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.test.tsx index 737d9c78f..3a2f5eb79 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCopyJobNavigation.test.tsx @@ -86,13 +86,13 @@ describe("useCopyJobNavigation", () => { jobName: "test-job", migrationType: CopyJobMigrationType.Offline, source: { - subscription: { subscriptionId: "source-sub-id" } as any, + subscriptionId: "source-sub-id", account: { id: "source-account-id", name: "Account-1" } as any, databaseId: "source-db", containerId: "source-container", }, target: { - subscriptionId: "target-sub-id", + subscription: { subscriptionId: "target-sub-id" } as any, account: { id: "target-account-id", name: "Account-2" } as any, databaseId: "target-db", containerId: "target-container", diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.test.tsx index 3768ef5f1..676c3d9ef 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.test.tsx @@ -142,14 +142,14 @@ describe("useCreateCopyJobScreensList", () => { jobName: "", migrationType: CopyJobMigrationType.Offline, source: { - subscription: { subscriptionId: "test-sub" } as any, + subscriptionId: "test-sub", account: { name: "test-account" } as any, databaseId: "", containerId: "", }, target: { - subscriptionId: "", - account: null as any, + subscription: { subscriptionId: "test-sub" } as any, + account: { name: "test-account" } as any, databaseId: "", containerId: "", }, @@ -171,14 +171,14 @@ describe("useCreateCopyJobScreensList", () => { jobName: "", migrationType: CopyJobMigrationType.Offline, source: { - subscription: null as any, - account: { name: "test-account" } as any, + subscriptionId: "", + account: null as any, databaseId: "", containerId: "", }, target: { - subscriptionId: "", - account: null as any, + subscription: null as any, + account: { name: "test-account" } as any, databaseId: "", containerId: "", }, @@ -210,13 +210,13 @@ describe("useCreateCopyJobScreensList", () => { jobName: "", migrationType: CopyJobMigrationType.Offline, source: { - subscription: null as any, + subscriptionId: "", account: null as any, databaseId: "source-db", containerId: "source-container", }, target: { - subscriptionId: "", + subscription: null as any, account: null as any, databaseId: "target-db", containerId: "target-container", @@ -240,13 +240,13 @@ describe("useCreateCopyJobScreensList", () => { jobName: "", migrationType: CopyJobMigrationType.Offline, source: { - subscription: null as any, + subscriptionId: "", account: null as any, databaseId: "", containerId: "source-container", }, target: { - subscriptionId: "", + subscription: null as any, account: null as any, databaseId: "target-db", containerId: "target-container", @@ -288,13 +288,13 @@ describe("useCreateCopyJobScreensList", () => { jobName: "valid-job-name_123", migrationType: CopyJobMigrationType.Offline, source: { - subscription: null as any, + subscriptionId: "", account: null as any, databaseId: "", containerId: "", }, target: { - subscriptionId: "", + subscription: null as any, account: null as any, databaseId: "", containerId: "", @@ -318,13 +318,13 @@ describe("useCreateCopyJobScreensList", () => { jobName: "invalid job name with spaces!", migrationType: CopyJobMigrationType.Offline, source: { - subscription: null as any, + subscriptionId: "", account: null as any, databaseId: "", containerId: "", }, target: { - subscriptionId: "", + subscription: null as any, account: null as any, databaseId: "", containerId: "", @@ -348,13 +348,13 @@ describe("useCreateCopyJobScreensList", () => { jobName: "", migrationType: CopyJobMigrationType.Offline, source: { - subscription: null as any, + subscriptionId: "", account: null as any, databaseId: "", containerId: "", }, target: { - subscriptionId: "", + subscription: null as any, account: null as any, databaseId: "", containerId: "", diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx index 0b5283558..a15e43799 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Utils/useCreateCopyJobScreensList.tsx @@ -36,7 +36,7 @@ function useCreateCopyJobScreensList(goBack: () => void): Screen[] { component: , validations: [ { - validate: (state: CopyJobContextState) => !!state?.source?.subscription && !!state?.source?.account, + validate: (state: CopyJobContextState) => !!state?.target?.subscription && !!state?.target?.account, message: "Please select a subscription and account to proceed", }, ], diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.test.tsx index 1aa57ebca..572bb0ceb 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.test.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.test.tsx @@ -19,7 +19,7 @@ jest.mock("../../ContainerCopyMessages", () => ({ sourceContainerLabel: "Source Container", targetDatabaseLabel: "Destination Database", targetContainerLabel: "Destination Container", - sourceAccountLabel: "Source Account", + destinationAccountLabel: "Destination account", MonitorJobs: { Columns: { lastUpdatedTime: "Date & time", @@ -102,8 +102,8 @@ describe("CopyJobDetails", () => { expect(screen.getByText("Date & time")).toBeInTheDocument(); expect(screen.getByText("2024-01-01T10:00:00Z")).toBeInTheDocument(); - expect(screen.getByText("Source Account")).toBeInTheDocument(); - expect(screen.getByText("sourceAccount")).toBeInTheDocument(); + expect(screen.getByText("Destination account")).toBeInTheDocument(); + expect(screen.getByText("targetAccount")).toBeInTheDocument(); expect(screen.getByText("Mode")).toBeInTheDocument(); expect(screen.getByText("Offline")).toBeInTheDocument(); @@ -263,7 +263,7 @@ describe("CopyJobDetails", () => { expect(screen.getByText("complex_source_container_with_underscores")).toBeInTheDocument(); expect(screen.getByText("complex-target-db-with-hyphens")).toBeInTheDocument(); expect(screen.getByText("complex_target_container_with_underscores")).toBeInTheDocument(); - expect(screen.getByText("complex.source.account")).toBeInTheDocument(); + expect(screen.getByText("complex.target.account")).toBeInTheDocument(); }); }); @@ -322,11 +322,11 @@ describe("CopyJobDetails", () => { render(); const dateTimeHeading = screen.getByText("Date & time"); - const sourceAccountHeading = screen.getByText("Source Account"); + const destinationAccountHeading = screen.getByText("Destination account"); const modeHeading = screen.getByText("Mode"); expect(dateTimeHeading).toHaveClass("bold"); - expect(sourceAccountHeading).toHaveClass("bold"); + expect(destinationAccountHeading).toHaveClass("bold"); expect(modeHeading).toHaveClass("bold"); }); }); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.tsx index 63c7fcf46..2ccf68a96 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.tsx @@ -106,8 +106,8 @@ const CopyJobDetails: React.FC = ({ job }) => { {job.LastUpdatedTime} - {ContainerCopyMessages.sourceAccountLabel} - {job.Source?.remoteAccountName} + {ContainerCopyMessages.destinationAccountLabel} + {job.Destination?.remoteAccountName} {ContainerCopyMessages.MonitorJobs.Columns.mode} diff --git a/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts b/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts index c004f3f9b..a020969db 100644 --- a/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts +++ b/src/Explorer/ContainerCopy/Types/CopyJobTypes.ts @@ -57,13 +57,13 @@ export interface CopyJobContextState { migrationType: CopyJobMigrationType; sourceReadAccessFromTarget?: boolean; source: { - subscription: Subscription | null; + subscriptionId: string; account: DatabaseAccount | null; databaseId: string; containerId: string; }; target: { - subscriptionId: string; + subscription: Subscription | null; account: DatabaseAccount | null; databaseId: string; containerId: string; diff --git a/src/Explorer/Explorer.test.tsx b/src/Explorer/Explorer.test.tsx new file mode 100644 index 000000000..a34019249 --- /dev/null +++ b/src/Explorer/Explorer.test.tsx @@ -0,0 +1,168 @@ +jest.mock("Utils/arm/generatedClients/cosmos/databaseAccounts"); +jest.mock("Utils/NotificationConsoleUtils", () => ({ + logConsoleProgress: jest.fn(() => jest.fn()), // returns a clearMessage fn + logConsoleInfo: jest.fn(), + logConsoleError: jest.fn(), +})); +jest.mock("Shared/Telemetry/TelemetryProcessor"); + +import { DatabaseAccount } from "../Contracts/DataModels"; +import { updateUserContext, userContext } from "../UserContext"; +import { update } from "../Utils/arm/generatedClients/cosmos/databaseAccounts"; +import Explorer from "./Explorer"; + +const mockUpdate = update as jest.MockedFunction; + +// Capture `useDialog.getState().openDialog` calls +const mockOpenDialog = jest.fn(); +const mockCloseDialog = jest.fn(); + +jest.mock("./Controls/Dialog", () => ({ + useDialog: { + getState: jest.fn(() => ({ + openDialog: mockOpenDialog, + closeDialog: mockCloseDialog, + })), + }, +})); + +// Silence useNotebook subscription calls +jest.mock("./Notebook/useNotebook", () => ({ + useNotebook: { + subscribe: jest.fn(), + getState: jest.fn().mockReturnValue( + new Proxy( + {}, + { + get: () => jest.fn().mockResolvedValue(undefined), + }, + ), + ), + }, +})); + +describe("Explorer.openEnableSynapseLinkDialog", () => { + let explorer: Explorer; + + const baseAccount: DatabaseAccount = { + id: "/subscriptions/ctx-sub/resourceGroups/ctx-rg/providers/Microsoft.DocumentDB/databaseAccounts/ctx-account", + name: "ctx-account", + location: "East US", + type: "Microsoft.DocumentDB/databaseAccounts", + kind: "GlobalDocumentDB", + tags: {}, + properties: { + documentEndpoint: "https://ctx-account.documents.azure.com:443/", + capabilities: [], + enableMultipleWriteLocations: false, + }, + }; + + beforeAll(() => { + updateUserContext({ + databaseAccount: baseAccount, + subscriptionId: "ctx-sub", + resourceGroup: "ctx-rg", + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockUpdate.mockResolvedValue(undefined); + explorer = new Explorer(); + }); + + describe("without targetAccountOverride", () => { + it("should open a dialog when called without override", () => { + explorer.openEnableSynapseLinkDialog(); + expect(mockOpenDialog).toHaveBeenCalledTimes(1); + }); + + it("should use userContext values in the update call on primary button click", async () => { + explorer.openEnableSynapseLinkDialog(); + + const dialogProps = mockOpenDialog.mock.calls[0][0]; + await dialogProps.onPrimaryButtonClick(); + + expect(mockUpdate).toHaveBeenCalledWith( + "ctx-sub", + "ctx-rg", + "ctx-account", + expect.objectContaining({ + properties: { enableAnalyticalStorage: true }, + }), + ); + }); + + it("should update userContext.databaseAccount.properties when no override is provided", async () => { + explorer.openEnableSynapseLinkDialog(); + + const dialogProps = mockOpenDialog.mock.calls[0][0]; + await dialogProps.onPrimaryButtonClick(); + + expect(userContext.databaseAccount.properties.enableAnalyticalStorage).toBe(true); + }); + }); + + describe("with targetAccountOverride", () => { + const override = { + subscriptionId: "override-sub", + resourceGroup: "override-rg", + accountName: "override-account", + }; + + it("should open a dialog when called with override", () => { + explorer.openEnableSynapseLinkDialog(override); + expect(mockOpenDialog).toHaveBeenCalledTimes(1); + }); + + it("should use override values in the update call on primary button click", async () => { + explorer.openEnableSynapseLinkDialog(override); + + const dialogProps = mockOpenDialog.mock.calls[0][0]; + await dialogProps.onPrimaryButtonClick(); + + expect(mockUpdate).toHaveBeenCalledWith( + "override-sub", + "override-rg", + "override-account", + expect.objectContaining({ + properties: { enableAnalyticalStorage: true }, + }), + ); + }); + + it("should NOT update userContext.databaseAccount.properties when override is provided", async () => { + // Reset the property first + userContext.databaseAccount.properties.enableAnalyticalStorage = false; + + explorer.openEnableSynapseLinkDialog(override); + + const dialogProps = mockOpenDialog.mock.calls[0][0]; + await dialogProps.onPrimaryButtonClick(); + + expect(userContext.databaseAccount.properties.enableAnalyticalStorage).toBe(false); + }); + + it("should use override values — NOT userContext — even when userContext has different values", async () => { + explorer.openEnableSynapseLinkDialog(override); + + const dialogProps = mockOpenDialog.mock.calls[0][0]; + await dialogProps.onPrimaryButtonClick(); + + // update should NOT be called with ctx-sub / ctx-rg / ctx-account + expect(mockUpdate).not.toHaveBeenCalledWith("ctx-sub", expect.anything(), expect.anything(), expect.anything()); + }); + }); + + describe("secondary button click", () => { + it("should close the dialog on secondary button click", () => { + explorer.openEnableSynapseLinkDialog(); + + const dialogProps = mockOpenDialog.mock.calls[0][0]; + dialogProps.onSecondaryButtonClick(); + + expect(mockCloseDialog).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index cf4aa01ea..ce71acc4a 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -227,7 +227,11 @@ export default class Explorer { this.refreshNotebookList(); } - public openEnableSynapseLinkDialog(): void { + public openEnableSynapseLinkDialog(targetAccountOverride?: DataModels.AccountOverride): void { + const subscriptionId = targetAccountOverride?.subscriptionId ?? userContext.subscriptionId; + const resourceGroup = targetAccountOverride?.resourceGroup ?? userContext.resourceGroup; + const accountName = targetAccountOverride?.accountName ?? userContext.databaseAccount.name; + const addSynapseLinkDialogProps: DialogProps = { linkProps: { linkText: "Learn more", @@ -249,7 +253,7 @@ export default class Explorer { useDialog.getState().closeDialog(); try { - await update(userContext.subscriptionId, userContext.resourceGroup, userContext.databaseAccount.name, { + await update(subscriptionId, resourceGroup, accountName, { properties: { enableAnalyticalStorage: true, }, @@ -258,7 +262,9 @@ export default class Explorer { clearInProgressMessage(); logConsoleInfo("Enabled Azure Synapse Link for this account"); TelemetryProcessor.traceSuccess(Action.EnableAzureSynapseLink, {}, startTime); - userContext.databaseAccount.properties.enableAnalyticalStorage = true; + if (!targetAccountOverride) { + userContext.databaseAccount.properties.enableAnalyticalStorage = true; + } } catch (error) { clearInProgressMessage(); logConsoleError(`Enabling Azure Synapse Link for this account failed. ${getErrorMessage(error)}`); diff --git a/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.test.tsx b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.test.tsx index f075eb828..75da84853 100644 --- a/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.test.tsx +++ b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.test.tsx @@ -12,4 +12,56 @@ describe("AddCollectionPanel", () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); + + describe("targetAccountOverride prop", () => { + it("should render with targetAccountOverride prop set", () => { + const override = { + subscriptionId: "override-sub", + resourceGroup: "override-rg", + accountName: "override-account", + }; + const wrapper = shallow(); + expect(wrapper).toBeDefined(); + }); + + it("should pass targetAccountOverride to openEnableSynapseLinkDialog button click", () => { + const mockOpenEnableSynapseLinkDialog = jest.fn(); + const explorerWithMock = { ...props.explorer, openEnableSynapseLinkDialog: mockOpenEnableSynapseLinkDialog }; + const override = { + subscriptionId: "override-sub", + resourceGroup: "override-rg", + accountName: "override-account", + }; + + const wrapper = shallow( + , + ); + + // isSynapseLinkEnabled section requires specific conditions; verify the component exists + expect(wrapper).toBeDefined(); + }); + }); + + describe("externalDatabaseOptions prop", () => { + it("should accept externalDatabaseOptions without error", () => { + const externalOptions = [ + { key: "db1", text: "Database One" }, + { key: "db2", text: "Database Two" }, + ]; + const wrapper = shallow(); + expect(wrapper).toBeDefined(); + }); + }); + + describe("isCopyJobFlow prop", () => { + it("should render with isCopyJobFlow=true", () => { + const wrapper = shallow(); + expect(wrapper).toBeDefined(); + }); + + it("should render with isCopyJobFlow=false (default behaviour)", () => { + const wrapper = shallow(); + expect(wrapper).toBeDefined(); + }); + }); }); diff --git a/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx index d598a3eb0..5cbbcb666 100644 --- a/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx +++ b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx @@ -21,6 +21,7 @@ import { getNewDatabaseSharedThroughputDefault } from "Common/DatabaseUtility"; import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils"; import { configContext, Platform } from "ConfigContext"; import * as DataModels from "Contracts/DataModels"; +import { AccountOverride } from "Contracts/DataModels"; import { FullTextPoliciesComponent } from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent"; import { VectorEmbeddingPoliciesComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent"; import { @@ -68,6 +69,8 @@ export interface AddCollectionPanelProps { isQuickstart?: boolean; isCopyJobFlow?: boolean; onSubmitSuccess?: (collectionData: { databaseId: string; collectionId: string }) => void; + targetAccountOverride?: AccountOverride; + externalDatabaseOptions?: IDropdownOption[]; } export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = { @@ -876,7 +879,7 @@ export class AddCollectionPanel extends React.Component this.props.explorer.openEnableSynapseLinkDialog()} + onClick={() => this.props.explorer.openEnableSynapseLinkDialog(this.props.targetAccountOverride)} style={{ height: 27, width: 80 }} styles={{ label: { fontSize: 12 } }} /> @@ -1062,6 +1065,9 @@ export class AddCollectionPanel extends React.Component ({ key: database.id(), text: database.id(), @@ -1149,6 +1155,10 @@ export class AddCollectionPanel extends React.Component database.id() === this.state.selectedDatabaseId); @@ -1439,13 +1449,16 @@ export class AddCollectionPanel extends React.Component