diff --git a/redisinsight/api/config/features-config.json b/redisinsight/api/config/features-config.json index 4785af0ecc..bfd81316e9 100644 --- a/redisinsight/api/config/features-config.json +++ b/redisinsight/api/config/features-config.json @@ -1,5 +1,5 @@ { - "version": 2.3402, + "version": 2.3403, "features": { "insightsRecommendations": { "flag": true, @@ -47,6 +47,22 @@ } } }, + "cloudSsoRecommendedSettings": { + "flag": true, + "perc": [[0, 50]], + "filters": [ + { + "name": "config.server.buildType", + "value": "ELECTRON", + "cond": "eq" + }, + { + "name": "agreements.analytics", + "value": true, + "cond": "eq" + } + ] + }, "redisModuleFilter": { "flag": true, "perc": [[0, 100]], diff --git a/redisinsight/api/src/__mocks__/feature.ts b/redisinsight/api/src/__mocks__/feature.ts index d60490f0bd..8476cdb5de 100644 --- a/redisinsight/api/src/__mocks__/feature.ts +++ b/redisinsight/api/src/__mocks__/feature.ts @@ -182,12 +182,12 @@ export const mockFeatureSso = Object.assign(new Feature(), { redisStackPreview: [ { provider: 'AWS', - regions: ['us-east-2', 'ap-southeast-1', 'sa-east-1'] + regions: ['us-east-2', 'ap-southeast-1', 'sa-east-1'], }, { provider: 'GCP', - regions: ['asia-northeast1', 'europe-west1', 'us-central1'] - } + regions: ['asia-northeast1', 'europe-west1', 'us-central1'], + }, ], }, }, diff --git a/redisinsight/api/src/modules/cloud/job/cloud-job.factory.ts b/redisinsight/api/src/modules/cloud/job/cloud-job.factory.ts index a256b85b26..92f379e0b5 100644 --- a/redisinsight/api/src/modules/cloud/job/cloud-job.factory.ts +++ b/redisinsight/api/src/modules/cloud/job/cloud-job.factory.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { CloudJob, CreateFreeSubscriptionAndDatabaseCloudJob, - ImportFreeDatabaseCloudJob + ImportFreeDatabaseCloudJob, } from 'src/modules/cloud/job/jobs'; import { CloudJobName } from 'src/modules/cloud/job/constants'; import { CreateFreeDatabaseCloudJob } from 'src/modules/cloud/job/jobs/create-free-database.cloud-job'; @@ -16,6 +16,7 @@ import { DatabaseService } from 'src/modules/database/database.service'; import { CloudDatabaseAnalytics } from 'src/modules/cloud/database/cloud-database.analytics'; import { CloudRequestUtm } from 'src/modules/cloud/common/models'; import { CloudCapiKeyService } from 'src/modules/cloud/capi-key/cloud-capi-key.service'; +import { CloudSubscriptionApiService } from 'src/modules/cloud/subscription/cloud-subscription.api.service'; @Injectable() export class CloudJobFactory { @@ -26,6 +27,7 @@ export class CloudJobFactory { private readonly cloudDatabaseAnalytics: CloudDatabaseAnalytics, private readonly databaseService: DatabaseService, private readonly cloudCapiKeyService: CloudCapiKeyService, + private readonly cloudSubscriptionApiService: CloudSubscriptionApiService, ) {} async create( @@ -53,6 +55,7 @@ export class CloudJobFactory { cloudDatabaseAnalytics: this.cloudDatabaseAnalytics, databaseService: this.databaseService, cloudCapiKeyService: this.cloudCapiKeyService, + cloudSubscriptionApiService: this.cloudSubscriptionApiService, }, ); case CloudJobName.CreateFreeDatabase: diff --git a/redisinsight/api/src/modules/cloud/job/dto/create-subscription-and-database.cloud-job.data.dto.ts b/redisinsight/api/src/modules/cloud/job/dto/create-subscription-and-database.cloud-job.data.dto.ts index 6ff973bf5f..7ce9fb206a 100644 --- a/redisinsight/api/src/modules/cloud/job/dto/create-subscription-and-database.cloud-job.data.dto.ts +++ b/redisinsight/api/src/modules/cloud/job/dto/create-subscription-and-database.cloud-job.data.dto.ts @@ -1,12 +1,21 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsNumber } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsNotEmpty, IsNumber, IsOptional, ValidateIf } from 'class-validator'; export class CreateSubscriptionAndDatabaseCloudJobDataDto { @ApiProperty({ description: 'Plan id for create a subscription.', type: Number, }) + @ValidateIf((object) => !object.isRecommendedSettings) @IsNumber() @IsNotEmpty() planId: number; + + @ApiPropertyOptional({ + description: 'Use recommended settings', + type: Boolean, + }) + @IsBoolean() + @IsOptional() + isRecommendedSettings?: boolean; } diff --git a/redisinsight/api/src/modules/cloud/job/jobs/create-free-subscription-and-database.cloud-job.ts b/redisinsight/api/src/modules/cloud/job/jobs/create-free-subscription-and-database.cloud-job.ts index 27f7191f4c..e594cbb97c 100644 --- a/redisinsight/api/src/modules/cloud/job/jobs/create-free-subscription-and-database.cloud-job.ts +++ b/redisinsight/api/src/modules/cloud/job/jobs/create-free-subscription-and-database.cloud-job.ts @@ -1,3 +1,4 @@ +import { sortBy } from 'lodash'; import { CloudJob, CloudJobOptions, CreateFreeDatabaseCloudJob } from 'src/modules/cloud/job/jobs'; import { CloudTaskCapiService } from 'src/modules/cloud/task/cloud-task.capi.service'; import { CloudSubscriptionCapiService } from 'src/modules/cloud/subscription/cloud-subscription.capi.service'; @@ -10,6 +11,8 @@ import { Database } from 'src/modules/database/models/database'; import { CloudDatabaseAnalytics } from 'src/modules/cloud/database/cloud-database.analytics'; import { CloudCapiKeyService } from 'src/modules/cloud/capi-key/cloud-capi-key.service'; import { CloudSubscription } from 'src/modules/cloud/subscription/models'; +import { CloudSubscriptionApiService } from '../../subscription/cloud-subscription.api.service'; +import { CloudSubscriptionPlanResponse } from '../../subscription/dto'; export class CreateFreeSubscriptionAndDatabaseCloudJob extends CloudJob { protected name = CloudJobName.CreateFreeSubscriptionAndDatabase; @@ -17,8 +20,9 @@ export class CreateFreeSubscriptionAndDatabaseCloudJob extends CloudJob { constructor( readonly options: CloudJobOptions, - private readonly data: { - planId: number, + private data: { + planId?: number, + isRecommendedSettings?: boolean, }, protected readonly dependencies: { @@ -28,12 +32,15 @@ export class CreateFreeSubscriptionAndDatabaseCloudJob extends CloudJob { cloudDatabaseAnalytics: CloudDatabaseAnalytics, databaseService: DatabaseService, cloudCapiKeyService: CloudCapiKeyService, + cloudSubscriptionApiService: CloudSubscriptionApiService, }, ) { super(options); } async iteration(): Promise { + let planId = this.data?.planId; + this.logger.log('Create free subscription and database'); this.checkSignal(); @@ -42,9 +49,15 @@ export class CreateFreeSubscriptionAndDatabaseCloudJob extends CloudJob { this.logger.debug('Get or create free subscription'); + if (this.data?.isRecommendedSettings) { + const plans = await this.dependencies.cloudSubscriptionApiService.getSubscriptionPlans(this.options.sessionMetadata); + + planId = this.getRecommendedPlanId(plans); + } + const freeSubscription: CloudSubscription = await this.runChildJob( CreateFreeSubscriptionCloudJob, - this.data, + { planId }, this.options, ); @@ -72,4 +85,9 @@ export class CreateFreeSubscriptionAndDatabaseCloudJob extends CloudJob { return database; } + + private getRecommendedPlanId(plans: CloudSubscriptionPlanResponse[]) { + const defaultPlan = sortBy(plans, ['details.displayOrder']); + return defaultPlan[0]?.id; + } } diff --git a/redisinsight/api/src/modules/cloud/subscription/cloud-subscription.module.ts b/redisinsight/api/src/modules/cloud/subscription/cloud-subscription.module.ts index 669913a684..bd1e1925ca 100644 --- a/redisinsight/api/src/modules/cloud/subscription/cloud-subscription.module.ts +++ b/redisinsight/api/src/modules/cloud/subscription/cloud-subscription.module.ts @@ -25,6 +25,7 @@ import { CloudSubscriptionCapiProvider } from './providers/cloud-subscription.ca ], exports: [ CloudSubscriptionCapiService, + CloudSubscriptionApiService, ], }) export class CloudSubscriptionModule {} diff --git a/redisinsight/api/src/modules/feature/constants/index.ts b/redisinsight/api/src/modules/feature/constants/index.ts index 1586eb54e0..6912f3f762 100644 --- a/redisinsight/api/src/modules/feature/constants/index.ts +++ b/redisinsight/api/src/modules/feature/constants/index.ts @@ -22,6 +22,7 @@ export enum FeatureConfigConfigDestination { export enum KnownFeatures { InsightsRecommendations = 'insightsRecommendations', CloudSso = 'cloudSso', + CloudSsoRecommendedSettings = 'cloudSsoRecommendedSettings', RedisModuleFilter = 'redisModuleFilter', } diff --git a/redisinsight/api/src/modules/feature/constants/known-features.ts b/redisinsight/api/src/modules/feature/constants/known-features.ts index 36d4910879..de16c17fc2 100644 --- a/redisinsight/api/src/modules/feature/constants/known-features.ts +++ b/redisinsight/api/src/modules/feature/constants/known-features.ts @@ -11,6 +11,10 @@ export const knownFeatures: Record = { storage: FeatureStorage.Database, factory: CloudSsoFeatureFlag.getFeature, }, + [KnownFeatures.CloudSsoRecommendedSettings]: { + name: KnownFeatures.CloudSsoRecommendedSettings, + storage: FeatureStorage.Database, + }, [KnownFeatures.RedisModuleFilter]: { name: KnownFeatures.RedisModuleFilter, storage: FeatureStorage.Database, diff --git a/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.spec.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.spec.ts index 85dbd87722..0d873e9576 100644 --- a/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.spec.ts +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.spec.ts @@ -9,8 +9,8 @@ import { FeatureFlagProvider } from 'src/modules/feature/providers/feature-flag/ import { SettingsService } from 'src/modules/settings/settings.service'; import { KnownFeatures } from 'src/modules/feature/constants'; import { - InsightsRecommendationsFlagStrategy, -} from 'src/modules/feature/providers/feature-flag/strategies/insights-recommendations.flag.strategy'; + CommonFlagStrategy, +} from 'src/modules/feature/providers/feature-flag/strategies/common.flag.strategy'; import { DefaultFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/default.flag.strategy'; import { knownFeatures } from 'src/modules/feature/constants/known-features'; @@ -37,9 +37,13 @@ describe('FeatureFlagProvider', () => { }); describe('getStrategy', () => { - it('should return insights strategy', async () => { + it('should return common strategy', async () => { expect(await service.getStrategy(KnownFeatures.InsightsRecommendations)) - .toBeInstanceOf(InsightsRecommendationsFlagStrategy); + .toBeInstanceOf(CommonFlagStrategy); + }); + it('should return common strategy', async () => { + expect(await service.getStrategy(KnownFeatures.CloudSsoRecommendedSettings)) + .toBeInstanceOf(CommonFlagStrategy); }); it('should return default strategy when directly called', async () => { expect(await service.getStrategy('default')) @@ -54,7 +58,7 @@ describe('FeatureFlagProvider', () => { describe('calculate', () => { it('should calculate ', async () => { jest.spyOn(service, 'getStrategy') - .mockReturnValue(mockInsightsRecommendationsFlagStrategy as unknown as InsightsRecommendationsFlagStrategy); + .mockReturnValue(mockInsightsRecommendationsFlagStrategy as unknown as CommonFlagStrategy); expect(await service.calculate( knownFeatures[KnownFeatures.InsightsRecommendations], diff --git a/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts index 8299f70636..d7ce9cc392 100644 --- a/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts @@ -1,15 +1,15 @@ import { Injectable } from '@nestjs/common'; import { FeatureFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy'; import { - InsightsRecommendationsFlagStrategy, -} from 'src/modules/feature/providers/feature-flag/strategies/insights-recommendations.flag.strategy'; + CommonFlagStrategy, +} from 'src/modules/feature/providers/feature-flag/strategies/common.flag.strategy'; import { DefaultFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/default.flag.strategy'; import { FeaturesConfigService } from 'src/modules/feature/features-config.service'; import { SettingsService } from 'src/modules/settings/settings.service'; import { IFeatureFlag, KnownFeatures } from 'src/modules/feature/constants'; import { CloudSsoFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/cloud-sso.flag.strategy'; import { Feature } from 'src/modules/feature/model/feature'; -import { SimpleFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/simple.flag.strategy'; +import { WithDataFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/with-data.flag.strategy'; @Injectable() export class FeatureFlagProvider { @@ -23,7 +23,7 @@ export class FeatureFlagProvider { this.featuresConfigService, this.settingsService, )); - this.strategies.set(KnownFeatures.InsightsRecommendations, new InsightsRecommendationsFlagStrategy( + this.strategies.set(KnownFeatures.InsightsRecommendations, new CommonFlagStrategy( this.featuresConfigService, this.settingsService, )); @@ -31,7 +31,11 @@ export class FeatureFlagProvider { this.featuresConfigService, this.settingsService, )); - this.strategies.set(KnownFeatures.RedisModuleFilter, new SimpleFlagStrategy( + this.strategies.set(KnownFeatures.CloudSsoRecommendedSettings, new CommonFlagStrategy( + this.featuresConfigService, + this.settingsService, + )); + this.strategies.set(KnownFeatures.RedisModuleFilter, new WithDataFlagStrategy( this.featuresConfigService, this.settingsService, )); diff --git a/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/insights-recommendations.flag.strategy.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/common.flag.strategy.ts similarity index 87% rename from redisinsight/api/src/modules/feature/providers/feature-flag/strategies/insights-recommendations.flag.strategy.ts rename to redisinsight/api/src/modules/feature/providers/feature-flag/strategies/common.flag.strategy.ts index bed82daed6..1ad873d55e 100644 --- a/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/insights-recommendations.flag.strategy.ts +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/common.flag.strategy.ts @@ -2,7 +2,7 @@ import { FeatureFlagStrategy } from 'src/modules/feature/providers/feature-flag/ import { Feature } from 'src/modules/feature/model/feature'; import { IFeatureFlag } from 'src/modules/feature/constants'; -export class InsightsRecommendationsFlagStrategy extends FeatureFlagStrategy { +export class CommonFlagStrategy extends FeatureFlagStrategy { async calculate(knownFeature: IFeatureFlag, featureConfig: any): Promise { const isInRange = await this.isInTargetRange(featureConfig?.perc); diff --git a/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy.spec.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy.spec.ts index 6943833acc..69a0a3b30f 100644 --- a/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy.spec.ts +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy.spec.ts @@ -12,8 +12,8 @@ import { SettingsService } from 'src/modules/settings/settings.service'; import { FeaturesConfigService } from 'src/modules/feature/features-config.service'; import { FeatureFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy'; import { - InsightsRecommendationsFlagStrategy, -} from 'src/modules/feature/providers/feature-flag/strategies/insights-recommendations.flag.strategy'; + CommonFlagStrategy, +} from 'src/modules/feature/providers/feature-flag/strategies/common.flag.strategy'; import { FeatureConfigFilter, FeatureConfigFilterAnd, @@ -45,7 +45,7 @@ describe('FeatureFlagStrategy', () => { settingsService = module.get(SettingsService); featuresConfigService = module.get(FeaturesConfigService); - service = new InsightsRecommendationsFlagStrategy( + service = new CommonFlagStrategy( featuresConfigService as unknown as FeaturesConfigService, settingsService as unknown as SettingsService, ); diff --git a/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/simple.flag.strategy.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/with-data.flag.strategy.ts similarity index 90% rename from redisinsight/api/src/modules/feature/providers/feature-flag/strategies/simple.flag.strategy.ts rename to redisinsight/api/src/modules/feature/providers/feature-flag/strategies/with-data.flag.strategy.ts index 0d4c0c0794..02fcf51a89 100644 --- a/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/simple.flag.strategy.ts +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/strategies/with-data.flag.strategy.ts @@ -2,7 +2,7 @@ import { FeatureFlagStrategy } from 'src/modules/feature/providers/feature-flag/ import { Feature } from 'src/modules/feature/model/feature'; import { IFeatureFlag } from 'src/modules/feature/constants'; -export class SimpleFlagStrategy extends FeatureFlagStrategy { +export class WithDataFlagStrategy extends FeatureFlagStrategy { async calculate(knownFeature: IFeatureFlag, featureConfig: any): Promise { const isInRange = await this.isInTargetRange(featureConfig?.perc); diff --git a/redisinsight/ui/src/components/oauth/oauth-select-account-dialog/OAuthSelectAccountDialog.tsx b/redisinsight/ui/src/components/oauth/oauth-select-account-dialog/OAuthSelectAccountDialog.tsx index 7dcf44cb67..14dd3c1eda 100644 --- a/redisinsight/ui/src/components/oauth/oauth-select-account-dialog/OAuthSelectAccountDialog.tsx +++ b/redisinsight/ui/src/components/oauth/oauth-select-account-dialog/OAuthSelectAccountDialog.tsx @@ -15,6 +15,7 @@ import { useHistory } from 'react-router-dom' import { activateAccount, + createFreeDbJob, fetchPlans, oauthCloudPlanSelector, oauthCloudSelector, @@ -26,8 +27,9 @@ import { Nullable } from 'uiSrc/utils' import { cloudSelector, fetchSubscriptionsRedisCloud } from 'uiSrc/slices/instances/cloud' import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' import { Pages } from 'uiSrc/constants' -import { removeInfiniteNotification } from 'uiSrc/slices/app/notifications' -import { InfiniteMessagesIds } from 'uiSrc/components/notifications/components' +import { addInfiniteNotification, removeInfiniteNotification } from 'uiSrc/slices/app/notifications' +import { INFINITE_MESSAGES, InfiniteMessagesIds } from 'uiSrc/components/notifications/components' +import { CloudJobName, CloudJobStep } from 'uiSrc/electron/constants' import styles from './styles.module.scss' @@ -36,7 +38,7 @@ interface FormValues { } const OAuthSelectAccountDialog = () => { - const { isAutodiscoverySSO } = useSelector(cloudSelector) + const { isAutodiscoverySSO, isRecommendedSettings } = useSelector(cloudSelector) const { accounts = [], currentAccountId } = useSelector(oauthCloudUserDataSelector) ?? {} const { isOpenSelectAccountDialog } = useSelector(oauthCloudSelector) const { loading } = useSelector(oauthCloudUserSelector) @@ -75,6 +77,20 @@ const OAuthSelectAccountDialog = () => { } )) dispatch(setSelectAccountDialogState(false)) + } else if (isRecommendedSettings) { + dispatch(createFreeDbJob({ + name: CloudJobName.CreateFreeSubscriptionAndDatabase, + resources: { + isRecommendedSettings + }, + onSuccessAction: () => { + dispatch(setSelectAccountDialogState(false)) + dispatch(addInfiniteNotification(INFINITE_MESSAGES.PENDING_CREATE_DB(CloudJobStep.Credentials))) + }, + onFailAction: () => { + dispatch(removeInfiniteNotification(InfiniteMessagesIds.oAuthProgress)) + } + })) } else { dispatch(fetchPlans()) } @@ -86,7 +102,7 @@ const OAuthSelectAccountDialog = () => { accountsCount: accounts.length }, }) - }, [isAutodiscoverySSO, accounts]) + }, [isAutodiscoverySSO, isRecommendedSettings, accounts]) const onActivateAccountFail = useCallback((error: string) => { sendEventTelemetry({ diff --git a/redisinsight/ui/src/components/oauth/oauth-social/OAuthSocial.spec.tsx b/redisinsight/ui/src/components/oauth/oauth-social/OAuthSocial.spec.tsx index a06c5fc860..65fa6daeab 100644 --- a/redisinsight/ui/src/components/oauth/oauth-social/OAuthSocial.spec.tsx +++ b/redisinsight/ui/src/components/oauth/oauth-social/OAuthSocial.spec.tsx @@ -4,8 +4,10 @@ import { cleanup, fireEvent, mockedStore, render, waitForEuiToolTipVisible, act import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' import { CloudAuthSocial, IpcInvokeEvent } from 'uiSrc/electron/constants' import { setOAuthCloudSource, signIn, oauthCloudPAgreementSelector } from 'uiSrc/slices/oauth/cloud' -import { setIsAutodiscoverySSO } from 'uiSrc/slices/instances/cloud' +import { setIsAutodiscoverySSO, setIsRecommendedSettingsSSO } from 'uiSrc/slices/instances/cloud' import { OAuthSocialSource } from 'uiSrc/slices/interfaces' +import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' +import { FeatureFlags } from 'uiSrc/constants' import OAuthSocial, { OAuthSocialType } from './OAuthSocial' jest.mock('uiSrc/telemetry', () => ({ @@ -21,6 +23,12 @@ jest.mock('uiSrc/slices/oauth/cloud', () => ({ oauthCloudPAgreementSelector: jest.fn().mockReturnValue(true), })) +jest.mock('uiSrc/slices/app/features', () => ({ + ...jest.requireActual('uiSrc/slices/app/features'), + appFeatureFlagsFeaturesSelector: jest.fn().mockReturnValue({ + }), +})) + let store: typeof mockedStore const invokeMock = jest.fn() beforeEach(() => { @@ -50,13 +58,14 @@ describe('OAuthSocial', () => { eventData: { accountOption: 'Google', action: 'create', + recommendedSettings: 'not displayed' } }) expect(invokeMock).toBeCalledTimes(1) expect(invokeMock).toBeCalledWith(IpcInvokeEvent.cloudOauth, { action: 'create', strategy: CloudAuthSocial.Google }) - const expectedActions = [signIn(), setIsAutodiscoverySSO(false)] + const expectedActions = [signIn(), setIsAutodiscoverySSO(false), setIsRecommendedSettingsSSO(undefined)] expect(store.getActions()).toEqual(expectedActions) invokeMock.mockRestore(); @@ -76,6 +85,7 @@ describe('OAuthSocial', () => { eventData: { accountOption: 'GitHub', action: 'create', + recommendedSettings: 'not displayed' } }) @@ -83,13 +93,77 @@ describe('OAuthSocial', () => { expect(invokeMock).toBeCalledWith(IpcInvokeEvent.cloudOauth, { action: 'create', strategy: CloudAuthSocial.Github }) invokeMock.mockRestore() - const expectedActions = [signIn(), setIsAutodiscoverySSO(false)] + const expectedActions = [signIn(), setIsAutodiscoverySSO(false), setIsRecommendedSettingsSSO(undefined)] expect(store.getActions()).toEqual(expectedActions) invokeMock.mockRestore(); (sendEventTelemetry as jest.Mock).mockRestore() }) + describe('Recommended Settings Enabled', () => { + beforeEach(() => { + (appFeatureFlagsFeaturesSelector as jest.Mock).mockReturnValue({ + [FeatureFlags.cloudSsoRecommendedSettings]: { + flag: true + } + }) + }) + it('should send telemetry after click on google btn', async () => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + + const { queryByTestId } = render() + + fireEvent.click(queryByTestId('google-oauth') as HTMLButtonElement) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CLOUD_SIGN_IN_SOCIAL_ACCOUNT_SELECTED, + eventData: { + accountOption: 'Google', + action: 'create', + recommendedSettings: 'enabled' + } + }) + + expect(invokeMock).toBeCalledTimes(1) + expect(invokeMock).toBeCalledWith(IpcInvokeEvent.cloudOauth, { action: 'create', strategy: CloudAuthSocial.Google }) + + const expectedActions = [signIn(), setIsAutodiscoverySSO(false), setIsRecommendedSettingsSSO(true)] + expect(store.getActions()).toEqual(expectedActions) + + invokeMock.mockRestore(); + (sendEventTelemetry as jest.Mock).mockRestore() + }) + + it('should send telemetry after click on github btn', async () => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + + const { queryByTestId } = render() + + fireEvent.click(queryByTestId('github-oauth') as HTMLButtonElement) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CLOUD_SIGN_IN_SOCIAL_ACCOUNT_SELECTED, + eventData: { + accountOption: 'GitHub', + action: 'create', + recommendedSettings: 'enabled' + } + }) + + expect(invokeMock).toBeCalledTimes(1) + expect(invokeMock).toBeCalledWith(IpcInvokeEvent.cloudOauth, { action: 'create', strategy: CloudAuthSocial.Github }) + invokeMock.mockRestore() + + const expectedActions = [signIn(), setIsAutodiscoverySSO(false), setIsRecommendedSettingsSSO(true)] + expect(store.getActions()).toEqual(expectedActions) + + invokeMock.mockRestore(); + (sendEventTelemetry as jest.Mock).mockRestore() + }) + }) + describe('Autodiscovery', () => { it('should send telemetry after click on google btn', async () => { const sendEventTelemetryMock = jest.fn(); diff --git a/redisinsight/ui/src/components/oauth/oauth-social/OAuthSocial.tsx b/redisinsight/ui/src/components/oauth/oauth-social/OAuthSocial.tsx index e0a2afe6f7..3f2960f1b5 100644 --- a/redisinsight/ui/src/components/oauth/oauth-social/OAuthSocial.tsx +++ b/redisinsight/ui/src/components/oauth/oauth-social/OAuthSocial.tsx @@ -1,20 +1,22 @@ -import React from 'react' -import { EuiButtonIcon, EuiText, EuiTitle, EuiToolTip } from '@elastic/eui' +import React, { useState } from 'react' +import { EuiButtonIcon, EuiCheckbox, EuiIcon, EuiText, EuiTitle, EuiToolTip } from '@elastic/eui' import cx from 'classnames' import { useDispatch, useSelector } from 'react-redux' import { ipcAuthGithub, ipcAuthGoogle } from 'uiSrc/electron/utils' import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' import { setOAuthCloudSource, signIn, oauthCloudPAgreementSelector } from 'uiSrc/slices/oauth/cloud' -import { OAuthAgreement } from 'uiSrc/components' +import { FeatureFlagComponent, OAuthAgreement } from 'uiSrc/components' +import { setIsRecommendedSettingsSSO, setIsAutodiscoverySSO } from 'uiSrc/slices/instances/cloud' +import { OAuthSocialSource } from 'uiSrc/slices/interfaces' +import { FeatureFlags } from 'uiSrc/constants' +import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' import { ReactComponent as GoogleIcon } from 'uiSrc/assets/img/oauth/google.svg' import { ReactComponent as GithubIcon } from 'uiSrc/assets/img/oauth/github.svg' import { ReactComponent as GoogleSmallIcon } from 'uiSrc/assets/img/oauth/google_small.svg' import { ReactComponent as GithubSmallIcon } from 'uiSrc/assets/img/oauth/github_small.svg' -import { setIsAutodiscoverySSO } from 'uiSrc/slices/instances/cloud' -import { OAuthSocialSource } from 'uiSrc/slices/interfaces' import styles from './styles.module.scss' export enum OAuthSocialType { @@ -29,6 +31,10 @@ interface Props { const OAuthSocial = ({ type = OAuthSocialType.Modal, hideTitle = false }: Props) => { const agreement = useSelector(oauthCloudPAgreementSelector) + const { + [FeatureFlags.cloudSsoRecommendedSettings]: isRecommendedFeatureEnabled + } = useSelector(appFeatureFlagsFeaturesSelector) + const [isRecommended, setIsRecommended] = useState(isRecommendedFeatureEnabled?.flag ? true : undefined) const dispatch = useDispatch() const isAutodiscovery = type === OAuthSocialType.Autodiscovery @@ -39,9 +45,23 @@ const OAuthSocial = ({ type = OAuthSocialType.Modal, hideTitle = false }: Props) eventData: { accountOption, action: getAction(), + recommendedSettings: isAutodiscovery + ? undefined + : (!isRecommendedFeatureEnabled?.flag + ? 'not displayed' + : (isRecommended ? 'enabled' : 'disabled')) } }) + const handleClickSso = () => { + dispatch(signIn()) + dispatch(setIsAutodiscoverySSO(isAutodiscovery)) + isAutodiscovery && dispatch(setOAuthCloudSource(OAuthSocialSource.Autodiscovery)) + if (!isAutodiscovery) { + dispatch(setIsRecommendedSettingsSSO(isRecommended)) + } + } + const socialLinks = [ { className: styles.googleButton, @@ -76,9 +96,7 @@ const OAuthSocial = ({ type = OAuthSocialType.Modal, hideTitle = false }: Props) disabled={!agreement} className={cx(styles.button, className)} onClick={() => { - dispatch(signIn()) - dispatch(setIsAutodiscoverySSO(isAutodiscovery)) - isAutodiscovery && dispatch(setOAuthCloudSource(OAuthSocialSource.Autodiscovery)) + handleClickSso() onButtonClick() }} data-testid={label} @@ -87,11 +105,40 @@ const OAuthSocial = ({ type = OAuthSocialType.Modal, hideTitle = false }: Props) )) + const RecommendedSettingsCheckBox = () => ( + +
+ setIsRecommended(e.target.checked)} + data-testid="oauth-recommended-settings-checkbox" + /> + + The database will be automatically created using a pre-selected provider and region. +
+ You can change it by signing in to Redis Cloud. + + )} + position="top" + anchorClassName={styles.recommendedSettingsToolTip} + > + +
+
+
+ ) + if (!isAutodiscovery) { return (
{buttons}
+
diff --git a/redisinsight/ui/src/components/oauth/oauth-social/styles.module.scss b/redisinsight/ui/src/components/oauth/oauth-social/styles.module.scss index 2122845eeb..b503b0bc4c 100644 --- a/redisinsight/ui/src/components/oauth/oauth-social/styles.module.scss +++ b/redisinsight/ui/src/components/oauth/oauth-social/styles.module.scss @@ -76,3 +76,38 @@ margin-top: 16px; text-align: left; } + +.recommendedSettings { + display: flex; + align-items: center; + + .recommendedSettingsToolTip { + display: inline-flex; + margin-left: 4px; + margin-bottom: 4px; + + :global { + svg { + width: 14px; + height: 14px; + } + } + } + + :global(.euiCheckbox) { + margin-bottom: 6px; + + :global(.euiCheckbox__label) { + font: normal normal normal 10px/15px Graphik, sans-serif !important; + color: var(--htmlColor) !important; + padding-left: 16px !important; + } + + :global(.euiCheckbox__square) { + width: 12px; + height: 12px; + padding: 0 !important; + border-width: 1px !important; + } + } +} diff --git a/redisinsight/ui/src/constants/featureFlags.ts b/redisinsight/ui/src/constants/featureFlags.ts index 7a6fd066a8..7bd18a8346 100644 --- a/redisinsight/ui/src/constants/featureFlags.ts +++ b/redisinsight/ui/src/constants/featureFlags.ts @@ -1,4 +1,5 @@ export enum FeatureFlags { insightsRecommendations = 'insightsRecommendations', cloudSso = 'cloudSso', + cloudSsoRecommendedSettings = 'cloudSsoRecommendedSettings', } diff --git a/redisinsight/ui/src/electron/components/ConfigOAuth/ConfigOAuth.spec.tsx b/redisinsight/ui/src/electron/components/ConfigOAuth/ConfigOAuth.spec.tsx index 82892a8d6b..d87484796d 100644 --- a/redisinsight/ui/src/electron/components/ConfigOAuth/ConfigOAuth.spec.tsx +++ b/redisinsight/ui/src/electron/components/ConfigOAuth/ConfigOAuth.spec.tsx @@ -1,10 +1,163 @@ import React from 'react' -import { render } from 'uiSrc/utils/test-utils' +import { cloneDeep } from 'lodash' +import { cleanup, mockedStore, render } from 'uiSrc/utils/test-utils' +import { CloudAuthStatus, CloudJobName, CloudJobStep } from 'uiSrc/electron/constants' +import { + addFreeDb, + fetchUserInfo, + getPlans, + getUserInfo, + setJob, + setOAuthCloudSource, + setSignInDialogState, + setSocialDialogState, + showOAuthProgress, + signInFailure +} from 'uiSrc/slices/oauth/cloud' +import { cloudSelector, loadSubscriptionsRedisCloud, setIsAutodiscoverySSO } from 'uiSrc/slices/instances/cloud' +import { addErrorNotification, addInfiniteNotification } from 'uiSrc/slices/app/notifications' +import { INFINITE_MESSAGES } from 'uiSrc/components/notifications/components' import ConfigOAuth from './ConfigOAuth' +jest.mock('uiSrc/slices/oauth/cloud', () => ({ + ...jest.requireActual('uiSrc/slices/oauth/cloud'), + fetchUserInfo: jest.fn().mockImplementation( + jest.requireActual('uiSrc/slices/oauth/cloud').fetchUserInfo + ) +})) + +jest.mock('uiSrc/slices/instances/cloud', () => ({ + ...jest.requireActual('uiSrc/slices/instances/cloud'), + cloudSelector: jest.fn().mockReturnValue({ + ...jest.requireActual('uiSrc/slices/instances/cloud').initialState + }) +})) + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() + window.app = { + cloudOauthCallback: jest.fn() + } as any +}) + describe('ConfigOAuth', () => { it('should render', () => { expect(render()).toBeTruthy() }) + + it('should call proper actions on success', () => { + window.app?.cloudOauthCallback.mockImplementation((cb: any) => cb(undefined, { status: CloudAuthStatus.Succeed })) + render() + + const expectedActions = [ + setJob({ id: '', name: CloudJobName.CreateFreeSubscriptionAndDatabase, status: '' }), + showOAuthProgress(true), + addInfiniteNotification(INFINITE_MESSAGES.PENDING_CREATE_DB(CloudJobStep.Credentials)), + setSignInDialogState(null), + setSocialDialogState(null), + getUserInfo() + ] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should call proper actions on failed', () => { + window.app?.cloudOauthCallback.mockImplementation((cb: any) => + cb( + undefined, { + status: CloudAuthStatus.Failed, + error: 'error' + } + )) + render() + + const expectedActions = [ + setOAuthCloudSource(null), + signInFailure('error'), + addErrorNotification({ + response: { + data: { + message: 'error' + }, + status: 500 + } + } as any), + setIsAutodiscoverySSO(false) + ] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should fetch plans by defaul', () => { + const fetchUserInfoMock = jest.fn().mockImplementation((onSuccessAction: () => void) => () => onSuccessAction()); + (fetchUserInfo as jest.Mock).mockImplementation(fetchUserInfoMock) + + window.app?.cloudOauthCallback.mockImplementation((cb: any) => cb(undefined, { status: CloudAuthStatus.Succeed })) + render() + + const afterCallbackActions = [ + setJob({ id: '', name: CloudJobName.CreateFreeSubscriptionAndDatabase, status: '' }), + showOAuthProgress(true), + addInfiniteNotification(INFINITE_MESSAGES.PENDING_CREATE_DB(CloudJobStep.Credentials)), + setSignInDialogState(null), + setSocialDialogState(null), + ] + + const expectedActions = [ + getPlans() + ] + expect(store.getActions()).toEqual([...afterCallbackActions, ...expectedActions]) + }) + + it('should call fetch subscriptions with autodiscovery flow', () => { + (cloudSelector as jest.Mock).mockReturnValue({ + isAutodiscoverySSO: true + }) + + const fetchUserInfoMock = jest.fn().mockImplementation((onSuccessAction: () => void) => () => onSuccessAction()); + (fetchUserInfo as jest.Mock).mockImplementation(fetchUserInfoMock) + + window.app?.cloudOauthCallback.mockImplementation((cb: any) => cb(undefined, { status: CloudAuthStatus.Succeed })) + render() + + const afterCallbackActions = [ + setJob({ id: '', name: CloudJobName.CreateFreeSubscriptionAndDatabase, status: '' }), + showOAuthProgress(true), + addInfiniteNotification(INFINITE_MESSAGES.PENDING_CREATE_DB(CloudJobStep.Credentials)), + setSignInDialogState(null), + setSocialDialogState(null), + ] + + const expectedActions = [ + loadSubscriptionsRedisCloud() + ] + expect(store.getActions()).toEqual([...afterCallbackActions, ...expectedActions]) + }) + + it('should call create free job after success with recommended settings', () => { + (cloudSelector as jest.Mock).mockReturnValue({ + isRecommendedSettings: true + }) + + const fetchUserInfoMock = jest.fn().mockImplementation((onSuccessAction: () => void) => () => onSuccessAction()); + (fetchUserInfo as jest.Mock).mockImplementation(fetchUserInfoMock) + + window.app?.cloudOauthCallback.mockImplementation((cb: any) => cb(undefined, { status: CloudAuthStatus.Succeed })) + render() + + const afterCallbackActions = [ + setJob({ id: '', name: CloudJobName.CreateFreeSubscriptionAndDatabase, status: '' }), + showOAuthProgress(true), + addInfiniteNotification(INFINITE_MESSAGES.PENDING_CREATE_DB(CloudJobStep.Credentials)), + setSignInDialogState(null), + setSocialDialogState(null), + ] + + const expectedActions = [ + addFreeDb() + ] + expect(store.getActions()).toEqual([...afterCallbackActions, ...expectedActions]) + }) }) diff --git a/redisinsight/ui/src/electron/components/ConfigOAuth/ConfigOAuth.tsx b/redisinsight/ui/src/electron/components/ConfigOAuth/ConfigOAuth.tsx index 3888e2dc3c..d1a52310b5 100644 --- a/redisinsight/ui/src/electron/components/ConfigOAuth/ConfigOAuth.tsx +++ b/redisinsight/ui/src/electron/components/ConfigOAuth/ConfigOAuth.tsx @@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' import { + createFreeDbJob, fetchPlans, fetchUserInfo, setJob, @@ -21,9 +22,10 @@ import { INFINITE_MESSAGES, InfiniteMessagesIds } from 'uiSrc/components/notific import { localStorageService } from 'uiSrc/services' const ConfigOAuth = () => { - const { isAutodiscoverySSO } = useSelector(cloudSelector) + const { isAutodiscoverySSO, isRecommendedSettings } = useSelector(cloudSelector) const isAutodiscoverySSORef = useRef(isAutodiscoverySSO) + const isRecommendedSettingsRef = useRef(isRecommendedSettings) const history = useHistory() const dispatch = useDispatch() @@ -39,6 +41,10 @@ const ConfigOAuth = () => { isAutodiscoverySSORef.current = isAutodiscoverySSO }, [isAutodiscoverySSO]) + useEffect(() => { + isRecommendedSettingsRef.current = isRecommendedSettings + }, [isRecommendedSettings]) + const fetchUserInfoSuccess = (isMultiAccount: boolean) => { if (isMultiAccount) return @@ -51,9 +57,25 @@ const ConfigOAuth = () => { }, closeInfinityNotification, )) - } else { - dispatch(fetchPlans()) + return + } + + if (isRecommendedSettingsRef.current) { + dispatch(createFreeDbJob({ + name: CloudJobName.CreateFreeSubscriptionAndDatabase, + resources: { + isRecommendedSettings: isRecommendedSettingsRef.current + }, + onSuccessAction: () => { + dispatch(addInfiniteNotification(INFINITE_MESSAGES.PENDING_CREATE_DB(CloudJobStep.Credentials))) + }, + onFailAction: closeInfinityNotification + })) + + return } + + dispatch(fetchPlans()) } const closeInfinityNotification = () => { @@ -74,7 +96,7 @@ const ConfigOAuth = () => { if (status === CloudAuthStatus.Failed) { const err = parseCloudOAuthError(error || message || '') dispatch(setOAuthCloudSource(null)) - dispatch(signInFailure(err?.message)) + dispatch(signInFailure(err?.response?.data?.message || message)) dispatch(addErrorNotification(err)) dispatch(setIsAutodiscoverySSO(false)) } diff --git a/redisinsight/ui/src/slices/app/features.ts b/redisinsight/ui/src/slices/app/features.ts index 767eeca39c..e79aeb4ae8 100644 --- a/redisinsight/ui/src/slices/app/features.ts +++ b/redisinsight/ui/src/slices/app/features.ts @@ -29,6 +29,9 @@ export const initialState: StateAppFeatures = { [FeatureFlags.cloudSso]: { flag: false }, + [FeatureFlags.cloudSsoRecommendedSettings]: { + flag: false + }, } } } diff --git a/redisinsight/ui/src/slices/instances/cloud.ts b/redisinsight/ui/src/slices/instances/cloud.ts index 67806dd498..a297c0c192 100644 --- a/redisinsight/ui/src/slices/instances/cloud.ts +++ b/redisinsight/ui/src/slices/instances/cloud.ts @@ -30,6 +30,7 @@ export const initialState: InitialStateCloud = { subscriptions: null, credentials: null, isAutodiscoverySSO: false, + isRecommendedSettings: undefined, account: { error: '', data: null, @@ -151,6 +152,9 @@ const cloudSlice = createSlice({ setIsAutodiscoverySSO: (state, { payload }: PayloadAction) => { state.isAutodiscoverySSO = payload }, + setIsRecommendedSettingsSSO: (state, { payload }: PayloadAction>) => { + state.isRecommendedSettings = payload + }, }, }) @@ -172,6 +176,7 @@ export const { resetSubscriptionsRedisCloud, resetLoadedRedisCloud, setIsAutodiscoverySSO, + setIsRecommendedSettingsSSO } = cloudSlice.actions // A selector diff --git a/redisinsight/ui/src/slices/interfaces/instances.ts b/redisinsight/ui/src/slices/interfaces/instances.ts index a52c6f83c5..028f16ee78 100644 --- a/redisinsight/ui/src/slices/interfaces/instances.ts +++ b/redisinsight/ui/src/slices/interfaces/instances.ts @@ -384,6 +384,7 @@ export interface InitialStateCloud { credentials: Nullable subscriptions: Nullable isAutodiscoverySSO: boolean + isRecommendedSettings: Maybe account: { data: Nullable error: string diff --git a/redisinsight/ui/src/slices/oauth/cloud.ts b/redisinsight/ui/src/slices/oauth/cloud.ts index c1d4c7ae58..42736d6767 100644 --- a/redisinsight/ui/src/slices/oauth/cloud.ts +++ b/redisinsight/ui/src/slices/oauth/cloud.ts @@ -317,6 +317,7 @@ export function createFreeDbJob({ subscriptionId?: number, region?: string, provider?: string, + isRecommendedSettings?: boolean } onSuccessAction?: () => void, onFailAction?: () => void diff --git a/redisinsight/ui/src/slices/tests/instances/cloud.spec.ts b/redisinsight/ui/src/slices/tests/instances/cloud.spec.ts index 72b02cd830..3b162bddd7 100644 --- a/redisinsight/ui/src/slices/tests/instances/cloud.spec.ts +++ b/redisinsight/ui/src/slices/tests/instances/cloud.spec.ts @@ -20,6 +20,7 @@ import reducer, { loadAccountRedisCloudSuccess, loadAccountRedisCloud, setIsAutodiscoverySSO, + setIsRecommendedSettingsSSO, fetchSubscriptionsRedisCloud, fetchAccountRedisCloud, loadInstancesRedisCloud, @@ -584,6 +585,28 @@ describe('cloud slice', () => { }) }) + describe('setIsRecommendedSettingsSSO', () => { + it('should properly set state', () => { + // Arrange + const data = true + const state = { + ...initialState, + isRecommendedSettings: true, + } + + // Act + const nextState = reducer(initialState, setIsRecommendedSettingsSSO(data)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + connections: { + cloud: nextState, + }, + }) + expect(cloudSelector(rootState)).toEqual(state) + }) + }) + describe('thunks', () => { describe('fetchSubscriptionsRedisCloud', () => { it('call fetchSubscriptionsRedisCloud, loadSubscriptionsRedisCloud, and loadSubscriptionsRedisCloudSuccess when fetch is successed', async () => {