From f90ee1e72c1f192c7609f4cb4a1771988c8c5e1d Mon Sep 17 00:00:00 2001 From: Damian Olszewski Date: Fri, 12 Apr 2024 12:30:48 +0200 Subject: [PATCH] RavenDB-22161 Add Azure Queue Storage Connection String to React views --- .../ConnectionStrings.spec.tsx | 29 +- .../ConnectionStrings.stories.tsx | 8 +- .../ConnectionStringsInfoHub.tsx | 14 +- .../ConnectionStringsPanels.tsx | 2 + .../EditConnectionStrings.tsx | 3 +- .../connectionStringsTypes.ts | 19 +- .../AzureQueueStorageConnectionString.tsx | 292 ++++++++++++++++++ .../ElasticSearchConnectionString.tsx | 1 + .../editForms/KafkaConnectionString.tsx | 1 + .../store/connectionStringsMapsFromDto.ts | 49 ++- .../store/connectionStringsMapsToDto.ts | 56 +++- .../store/connectionStringsSlice.ts | 7 +- 12 files changed, 446 insertions(+), 35 deletions(-) diff --git a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/ConnectionStrings.spec.tsx b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/ConnectionStrings.spec.tsx index 1b575471b466..1ff743234841 100644 --- a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/ConnectionStrings.spec.tsx +++ b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/ConnectionStrings.spec.tsx @@ -1,7 +1,7 @@ import React from "react"; import { composeStories } from "@storybook/react"; import * as stories from "./ConnectionStrings.stories"; -import { rtlRender, RtlScreen, waitForElementToBeRemoved } from "test/rtlTestUtils"; +import { rtlRender_WithWaitForLoad } from "test/rtlTestUtils"; const { DefaultConnectionStrings } = composeStories(stories); @@ -13,40 +13,32 @@ const selectors = { }; describe("ConnectionStrings", () => { - async function waitForLoad(screen: RtlScreen) { - await waitForElementToBeRemoved(screen.getByClassName("lazy-load")); - } - it("can render empty list", async () => { - const { screen } = rtlRender(); - await waitForLoad(screen); + const { screen } = await rtlRender_WithWaitForLoad(); expect(screen.getByText(selectors.emptyList)).toBeInTheDocument(); }); it("can render connection strings", async () => { - const { screen } = rtlRender(); - await waitForLoad(screen); + const { screen } = await rtlRender_WithWaitForLoad(); expect(screen.queryByText(selectors.emptyList)).not.toBeInTheDocument(); - expect(screen.queryAllByClassName("rich-panel-name")).toHaveLength(6); + expect(screen.queryAllByClassName("rich-panel-name")).toHaveLength(7); }); it("can render action buttons when has access database admin", async () => { - const { screen } = rtlRender(); - await waitForLoad(screen); + const { screen } = await rtlRender_WithWaitForLoad(); // one on the top + one per connection string - expect(screen.queryAllByRole("button", { name: selectors.addNew })).toHaveLength(7); + expect(screen.queryAllByRole("button", { name: selectors.addNew })).toHaveLength(8); // one per connection string - expect(screen.queryAllByRole("button", { name: selectors.edit })).toHaveLength(6); - expect(screen.queryAllByRole("button", { name: selectors.delete })).toHaveLength(6); + expect(screen.queryAllByRole("button", { name: selectors.edit })).toHaveLength(7); + expect(screen.queryAllByRole("button", { name: selectors.delete })).toHaveLength(7); }); it("can hide action buttons when has access below database admin", async () => { - const { screen } = rtlRender(); - await waitForLoad(screen); + const { screen } = await rtlRender_WithWaitForLoad(); expect(screen.queryAllByRole("button", { name: selectors.addNew })).toHaveLength(0); expect(screen.queryAllByRole("button", { name: selectors.edit })).toHaveLength(0); @@ -54,7 +46,7 @@ describe("ConnectionStrings", () => { }); it("can disable add button when no license", async () => { - const { screen } = rtlRender( + const { screen } = await rtlRender_WithWaitForLoad( { hasQueueEtl={false} /> ); - await waitForLoad(screen); expect(screen.getByRole("button", { name: selectors.addNew })).toBeDisabled(); }); diff --git a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/ConnectionStrings.stories.tsx b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/ConnectionStrings.stories.tsx index dbcc96bd7a0e..7a0683af55bf 100644 --- a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/ConnectionStrings.stories.tsx +++ b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/ConnectionStrings.stories.tsx @@ -10,10 +10,6 @@ import { SharedStubs } from "test/stubs/SharedStubs"; export default { title: "Pages/Database/Settings", decorators: [withStorybookContexts, withBootstrap5], - argTypes: { - licenseType: licenseArgType, - databaseAccess: databaseAccessArgType, - }, } satisfies Meta; interface DefaultConnectionStringsProps { @@ -83,6 +79,10 @@ export const DefaultConnectionStrings: StoryObj = hasElasticSearchEtl: true, hasQueueEtl: true, }, + argTypes: { + licenseType: licenseArgType, + databaseAccess: databaseAccessArgType, + }, }; function mockTestResults(isSuccess: boolean) { diff --git a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/ConnectionStringsInfoHub.tsx b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/ConnectionStringsInfoHub.tsx index 03684e2c4bb9..e63e905dd4dc 100644 --- a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/ConnectionStringsInfoHub.tsx +++ b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/ConnectionStringsInfoHub.tsx @@ -36,8 +36,10 @@ export function ConnectionStringsInfoHub() { featureName: defaultFeatureAvailability[5].featureName, value: features.hasQueueEtl, }, - - //TODO: azure + { + featureName: defaultFeatureAvailability[6].featureName, + value: features.hasQueueEtl, + }, ], }); @@ -119,5 +121,11 @@ const defaultFeatureAvailability: FeatureAvailabilityData[] = [ professional: { value: false }, enterprise: { value: true }, }, - //TODO: azure + { + featureName: "Azure Queue Storage ETL", + featureIcon: "azure-queue-storage-etl", + community: { value: false }, + professional: { value: false }, + enterprise: { value: true }, + }, ]; diff --git a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/ConnectionStringsPanels.tsx b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/ConnectionStringsPanels.tsx index e77ba9299e40..12dbcb0e0a59 100644 --- a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/ConnectionStringsPanels.tsx +++ b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/ConnectionStringsPanels.tsx @@ -59,6 +59,8 @@ function getTypeLabel(type: StudioEtlType): string { return "RavenDB"; case "Sql": return "SQL"; + case "AzureQueueStorage": + return "Azure Queue Storage"; default: return type; } diff --git a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/EditConnectionStrings.tsx b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/EditConnectionStrings.tsx index 37d360015a6e..3c84845da36e 100644 --- a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/EditConnectionStrings.tsx +++ b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/EditConnectionStrings.tsx @@ -25,6 +25,7 @@ import useConnectionStringsLicense, { ConnectionStringsLicenseFeatures } from ". import assertUnreachable from "components/utils/assertUnreachable"; import { databaseSelectors } from "components/common/shell/databaseSliceSelectors"; import { useAppSelector } from "components/store"; +import AzureQueueStorageConnectionString from "components/pages/database/settings/connectionStrings/editForms/AzureQueueStorageConnectionString"; export interface EditConnectionStringsProps { initialConnection?: Connection; @@ -181,7 +182,7 @@ function getEditConnectionStringComponent(type: StudioEtlType): (props: EditConn case "RabbitMQ": return RabbitMqConnectionString; case "AzureQueueStorage": - throw new Error("Not implemeted"); //TODO: + return AzureQueueStorageConnectionString; default: return null; } diff --git a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/connectionStringsTypes.ts b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/connectionStringsTypes.ts index 2908f16528ac..1f0b440abc69 100644 --- a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/connectionStringsTypes.ts +++ b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/connectionStringsTypes.ts @@ -2,6 +2,8 @@ import ElasticSearchConnectionStringDto = Raven.Client.Documents.Operations.ETL. import OlapConnectionStringDto = Raven.Client.Documents.Operations.ETL.OLAP.OlapConnectionString; import QueueConnectionStringDto = Raven.Client.Documents.Operations.ETL.Queue.QueueConnectionString; import RavenConnectionStringDto = Raven.Client.Documents.Operations.ETL.RavenConnectionString; +import AzureQueueStorageConnectionSettingsDto = Raven.Client.Documents.Operations.ETL.Queue.AzureQueueStorageConnectionSettings; + type SqlConnectionStringDto = SqlConnectionString; import { FormDestinations } from "components/common/formDestinations/utils/formDestinationsTypes"; @@ -68,7 +70,21 @@ export interface RabbitMqConnection extends ConnectionBase { export interface AzureQueueStorageConnection extends ConnectionBase { type: Extract; - connectionString?: string; //TODO: other props + authType?: AzureQueueStorageAuthenticationType; + settings?: { + connectionString?: { + connectionStringValue?: string; + }; + entraId?: { + clientId?: string; + clientSecret?: string; + storageAccountName?: string; + tenantId?: string; + }; + passwordless?: { + storageAccountName?: string; + }; + }; } export type Connection = @@ -86,6 +102,7 @@ export type ConnectionStringDto = Partial< | QueueConnectionStringDto | RavenConnectionStringDto | SqlConnectionStringDto + | AzureQueueStorageConnectionSettingsDto >; export interface EditConnectionStringFormProps { diff --git a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/editForms/AzureQueueStorageConnectionString.tsx b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/editForms/AzureQueueStorageConnectionString.tsx index e69de29bb2d1..fbbbb9b82739 100644 --- a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/editForms/AzureQueueStorageConnectionString.tsx +++ b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/editForms/AzureQueueStorageConnectionString.tsx @@ -0,0 +1,292 @@ +import React, { useEffect } from "react"; +import { + ConnectionFormData, + EditConnectionStringFormProps, + AzureQueueStorageConnection, +} from "../connectionStringsTypes"; +import { SelectOption } from "components/common/select/Select"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; +import { yupObjectSchema } from "components/utils/yupUtils"; +import { Control, SubmitHandler, useForm, useWatch } from "react-hook-form"; +import { useAppUrls } from "components/hooks/useAppUrls"; +import { FormInput, FormSelect } from "components/common/Form"; +import { Badge, Form, Label } from "reactstrap"; +import { useAsyncCallback } from "react-async-hook"; +import ButtonWithSpinner from "components/common/ButtonWithSpinner"; +import ConnectionStringUsedByTasks from "components/pages/database/settings/connectionStrings/editForms/shared/ConnectionStringUsedByTasks"; +import { useServices } from "components/hooks/useServices"; +import ConnectionTestResult from "components/common/connectionTests/ConnectionTestResult"; +import { useAppSelector } from "components/store"; +import { databaseSelectors } from "components/common/shell/databaseSliceSelectors"; +import { mapAzureQueueStorageConnectionStringSettingsToDto } from "components/pages/database/settings/connectionStrings/store/connectionStringsMapsToDto"; +import assertUnreachable from "components/utils/assertUnreachable"; +import { Icon } from "components/common/Icon"; + +type FormData = ConnectionFormData; + +export interface AzureQueueStorageConnectionStringProps extends EditConnectionStringFormProps { + initialConnection: AzureQueueStorageConnection; +} + +export default function AzureQueueStorageConnectionString({ + initialConnection, + isForNewConnection, + onSave, +}: AzureQueueStorageConnectionStringProps) { + const { control, handleSubmit, trigger } = useForm({ + mode: "all", + defaultValues: getDefaultValues(initialConnection, isForNewConnection), + resolver: (data, _, options) => + yupResolver(schema)( + data, + { + authType: data.authType, + }, + options + ), + }); + + const formValues = useWatch({ control }); + const { forCurrentDatabase } = useAppUrls(); + const { tasksService } = useServices(); + const databaseName = useAppSelector(databaseSelectors.activeDatabaseName); + + const asyncTest = useAsyncCallback(async () => { + const isValid = await trigger(`settings.${formValues.authType}`); + if (!isValid) { + return; + } + + return tasksService.testAzureQueueStorageServerConnection( + databaseName, + mapAzureQueueStorageConnectionStringSettingsToDto(formValues) + ); + }); + + // Clear test result after changing auth type + useEffect(() => { + asyncTest.set(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formValues.authType]); + + const handleSave: SubmitHandler = (formData: FormData) => { + onSave({ + type: "AzureQueueStorage", + ...formData, + } satisfies AzureQueueStorageConnection); + }; + + return ( +
+
+ + +
+
+ + +
+ + +
+ + Test connection + +
+ {asyncTest.result?.Error && ( +
+ +
+ )} + + + + ); +} + +interface SelectedAuthFieldsProps { + control: Control; + authMethod: AzureQueueStorageAuthenticationType; +} + +function SelectedAuthFields({ control, authMethod }: SelectedAuthFieldsProps) { + if (authMethod === "connectionString") { + return ( +
+ + +
+ ); + } + + if (authMethod === "entraId") { + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ); + } + + if (authMethod === "passwordless") { + return ( +
+ + +
+ ); + } + + assertUnreachable(authMethod); +} + +const authenticationOptions: SelectOption[] = [ + { + value: "connectionString", + label: "Connection String", + }, + { + value: "entraId", + label: "Entra ID", + }, + { + value: "passwordless", + label: "Passwordless", + }, +]; + +function getStringRequiredSchema(authType: AzureQueueStorageAuthenticationType) { + return yup + .string() + .nullable() + .when("$authType", { + is: authType, + then: (schema) => schema.required(), + }); +} + +const schema = yupObjectSchema({ + name: yup.string().nullable().required(), + authType: yup.string(), + settings: yupObjectSchema({ + connectionString: yupObjectSchema({ + connectionStringValue: getStringRequiredSchema("connectionString"), + }), + entraId: yupObjectSchema({ + clientId: getStringRequiredSchema("entraId"), + clientSecret: getStringRequiredSchema("entraId"), + storageAccountName: getStringRequiredSchema("entraId"), + tenantId: getStringRequiredSchema("entraId"), + }), + passwordless: yupObjectSchema({ + storageAccountName: getStringRequiredSchema("passwordless"), + }), + }), +}); + +function getDefaultValues(initialConnection: AzureQueueStorageConnection, isForNewConnection: boolean): FormData { + if (isForNewConnection) { + return { + authType: "connectionString", + settings: { + connectionString: { + connectionStringValue: null, + }, + entraId: { + clientId: null, + clientSecret: null, + storageAccountName: null, + tenantId: null, + }, + passwordless: { + storageAccountName: null, + }, + }, + }; + } + + return _.omit(initialConnection, "type", "usedByTasks"); +} diff --git a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/editForms/ElasticSearchConnectionString.tsx b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/editForms/ElasticSearchConnectionString.tsx index dbcb94a69c49..0d22df8533f8 100644 --- a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/editForms/ElasticSearchConnectionString.tsx +++ b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/editForms/ElasticSearchConnectionString.tsx @@ -108,6 +108,7 @@ export default function ElasticSearchConnectionString({ name="name" type="text" placeholder="Enter a name for the connection string" + disabled={!isForNewConnection} autoComplete="off" /> diff --git a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/editForms/KafkaConnectionString.tsx b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/editForms/KafkaConnectionString.tsx index 153f000c8693..e80259104068 100644 --- a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/editForms/KafkaConnectionString.tsx +++ b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/editForms/KafkaConnectionString.tsx @@ -89,6 +89,7 @@ export default function KafkaConnectionString({ name="name" type="text" placeholder="Enter a name for the connection string" + disabled={!isForNewConnection} autoComplete="off" /> diff --git a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/store/connectionStringsMapsFromDto.ts b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/store/connectionStringsMapsFromDto.ts index 2f94db2580b2..9811ca09a0f4 100644 --- a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/store/connectionStringsMapsFromDto.ts +++ b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/store/connectionStringsMapsFromDto.ts @@ -5,7 +5,7 @@ import { KafkaConnection, OlapConnection, RabbitMqConnection, - //TODO: azure + AzureQueueStorageConnection, RavenConnection, SqlConnection, } from "../connectionStringsTypes"; @@ -214,4 +214,49 @@ export function mapRabbitMqConnectionsFromDto( ); } -//TODO: map azure +function getAzureQueueStorageAuthType(dto: QueueConnectionStringDto): AzureQueueStorageAuthenticationType { + if (dto.AzureQueueStorageConnectionSettings.ConnectionString) { + return "connectionString"; + } + if (dto.AzureQueueStorageConnectionSettings.EntraId) { + return "entraId"; + } + if (dto.AzureQueueStorageConnectionSettings.Passwordless) { + return "passwordless"; + } +} + +export function mapAzureQueueStorageConnectionsFromDto( + connections: Record, + ongoingTasks: OngoingTaskForConnection[] +): AzureQueueStorageConnection[] { + const type: AzureQueueStorageConnection["type"] = "AzureQueueStorage"; + + return Object.values(connections) + .filter((x) => x.BrokerType === "AzureQueueStorage") + .map( + (connection) => + ({ + type, + name: connection.Name, + authType: getAzureQueueStorageAuthType(connection), + settings: { + connectionString: { + connectionStringValue: connection.AzureQueueStorageConnectionSettings.ConnectionString, + }, + entraId: { + clientId: connection.AzureQueueStorageConnectionSettings.EntraId?.ClientId, + clientSecret: connection.AzureQueueStorageConnectionSettings.EntraId?.ClientSecret, + storageAccountName: + connection.AzureQueueStorageConnectionSettings.EntraId?.StorageAccountName, + tenantId: connection.AzureQueueStorageConnectionSettings.EntraId?.TenantId, + }, + passwordless: { + storageAccountName: + connection.AzureQueueStorageConnectionSettings.Passwordless?.StorageAccountName, + }, + }, + usedByTasks: getConnectionStringUsedTasks(ongoingTasks, type, connection.Name), + }) satisfies AzureQueueStorageConnection + ); +} diff --git a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/store/connectionStringsMapsToDto.ts b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/store/connectionStringsMapsToDto.ts index ed6cba55a6e8..bad1205af32c 100644 --- a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/store/connectionStringsMapsToDto.ts +++ b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/store/connectionStringsMapsToDto.ts @@ -7,7 +7,7 @@ import { ElasticSearchConnection, KafkaConnection, RabbitMqConnection, - //TODO: azure + AzureQueueStorageConnection, OlapConnection, ConnectionFormData, } from "../connectionStringsTypes"; @@ -104,7 +104,57 @@ export function mapRabbitMqStringToDto(connection: RabbitMqConnection): Connecti }, }; } -//TODO: map azure + +export function mapAzureQueueStorageConnectionStringSettingsToDto( + connection: Omit +): Raven.Client.Documents.Operations.ETL.Queue.AzureQueueStorageConnectionSettings { + switch (connection.authType) { + case "connectionString": { + const connectionSettings = connection.settings[connection.authType]; + return { + ConnectionString: connectionSettings.connectionStringValue, + EntraId: null, + Passwordless: null, + }; + } + case "entraId": { + const connectionSettings = connection.settings[connection.authType]; + return { + ConnectionString: null, + EntraId: { + StorageAccountName: connectionSettings.storageAccountName, + TenantId: connectionSettings.tenantId, + ClientId: connectionSettings.clientId, + ClientSecret: connectionSettings.clientSecret, + }, + Passwordless: null, + }; + } + case "passwordless": { + const connectionSettings = connection.settings[connection.authType]; + return { + ConnectionString: null, + EntraId: null, + Passwordless: { + StorageAccountName: connectionSettings.storageAccountName, + }, + }; + } + default: + return assertUnreachable(connection.authType); + } +} + +export function mapAzureQueueStorageConnectionStringToDto( + connection: AzureQueueStorageConnection +): ConnectionStringDto { + return { + Type: "Queue", + BrokerType: "AzureQueueStorage", + Name: connection.name, + AzureQueueStorageConnectionSettings: mapAzureQueueStorageConnectionStringSettingsToDto(connection), + }; +} export function mapConnectionStringToDto(connection: Connection): ConnectionStringDto { const type = connection.type; @@ -123,7 +173,7 @@ export function mapConnectionStringToDto(connection: Connection): ConnectionStri case "RabbitMQ": return mapRabbitMqStringToDto(connection); case "AzureQueueStorage": - throw new Error("NOT IMPLETED"); //TODO: + return mapAzureQueueStorageConnectionStringToDto(connection); default: return assertUnreachable(type); } diff --git a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/store/connectionStringsSlice.ts b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/store/connectionStringsSlice.ts index 55e5b0beb19c..785ce39b5873 100644 --- a/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/store/connectionStringsSlice.ts +++ b/src/Raven.Studio/typescript/components/pages/database/settings/connectionStrings/store/connectionStringsSlice.ts @@ -9,7 +9,7 @@ import { mapKafkaConnectionsFromDto, mapOlapConnectionsFromDto, mapRabbitMqConnectionsFromDto, - //TODO azure + mapAzureQueueStorageConnectionsFromDto, mapRavenConnectionsFromDto, mapSqlConnectionsFromDto, } from "./connectionStringsMapsFromDto"; @@ -111,7 +111,10 @@ export const connectionStringsSlice = createSlice({ ongoingTasks ); - //TODO: map azure + connections.AzureQueueStorage = mapAzureQueueStorageConnectionsFromDto( + connectionStringsDto.QueueConnectionStrings, + ongoingTasks + ); state.loadStatus = "success";