diff --git a/redisinsight/api/config/features-config.json b/redisinsight/api/config/features-config.json index c08ce5f0d9..e278cd243d 100644 --- a/redisinsight/api/config/features-config.json +++ b/redisinsight/api/config/features-config.json @@ -1,5 +1,5 @@ { - "version": 2.56, + "version": 2.57, "features": { "redisDataIntegration": { "flag": true, @@ -111,6 +111,10 @@ "data": { "strategy": "ioredis" } + }, + "enhancedCloudUI": { + "flag": true, + "perc": [[0, 50]] } } } diff --git a/redisinsight/api/migration/1733740794737-database-createdAt.ts b/redisinsight/api/migration/1733740794737-database-createdAt.ts new file mode 100644 index 0000000000..1b7d0c4517 --- /dev/null +++ b/redisinsight/api/migration/1733740794737-database-createdAt.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class DatabaseCreatedAt1733740794737 implements MigrationInterface { + name = 'DatabaseCreatedAt1733740794737' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "temporary_database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean, "verifyServerCert" boolean, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar DEFAULT ('[]'), "nameFromProvider" varchar, "sentinelMasterName" varchar, "sentinelMasterUsername" varchar, "sentinelMasterPassword" varchar, "provider" varchar DEFAULT ('UNKNOWN'), "modules" varchar NOT NULL DEFAULT ('[]'), "db" integer, "encryption" varchar, "tlsServername" varchar, "new" boolean, "ssh" boolean, "timeout" integer, "compressor" varchar NOT NULL DEFAULT ('NONE'), "version" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db", "encryption", "tlsServername", "new", "ssh", "timeout", "compressor", "version") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db", "encryption", "tlsServername", "new", "ssh", "timeout", "compressor", "version" FROM "database_instance"`); + await queryRunner.query(`DROP TABLE "database_instance"`); + await queryRunner.query(`ALTER TABLE "temporary_database_instance" RENAME TO "database_instance"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "database_instance" RENAME TO "temporary_database_instance"`); + await queryRunner.query(`CREATE TABLE "database_instance" ("id" varchar PRIMARY KEY NOT NULL, "host" varchar NOT NULL, "port" integer NOT NULL, "name" varchar NOT NULL, "username" varchar, "password" varchar, "tls" boolean, "verifyServerCert" boolean, "lastConnection" datetime, "caCertId" varchar, "clientCertId" varchar, "connectionType" varchar NOT NULL DEFAULT ('STANDALONE'), "nodes" varchar DEFAULT ('[]'), "nameFromProvider" varchar, "sentinelMasterName" varchar, "sentinelMasterUsername" varchar, "sentinelMasterPassword" varchar, "provider" varchar DEFAULT ('UNKNOWN'), "modules" varchar NOT NULL DEFAULT ('[]'), "db" integer, "encryption" varchar, "tlsServername" varchar, "new" boolean, "ssh" boolean, "timeout" integer, "compressor" varchar NOT NULL DEFAULT ('NONE'), "version" varchar, CONSTRAINT "FK_d1bc747b5938e22b4b708d8e9a5" FOREIGN KEY ("caCertId") REFERENCES "ca_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_3b9b625266c00feb2d66a9f36e4" FOREIGN KEY ("clientCertId") REFERENCES "client_certificate" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "database_instance"("id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db", "encryption", "tlsServername", "new", "ssh", "timeout", "compressor", "version") SELECT "id", "host", "port", "name", "username", "password", "tls", "verifyServerCert", "lastConnection", "caCertId", "clientCertId", "connectionType", "nodes", "nameFromProvider", "sentinelMasterName", "sentinelMasterUsername", "sentinelMasterPassword", "provider", "modules", "db", "encryption", "tlsServername", "new", "ssh", "timeout", "compressor", "version" FROM "temporary_database_instance"`); + await queryRunner.query(`DROP TABLE "temporary_database_instance"`); + } + +} diff --git a/redisinsight/api/migration/index.ts b/redisinsight/api/migration/index.ts index 8d18dff288..0e8b7c5d29 100644 --- a/redisinsight/api/migration/index.ts +++ b/redisinsight/api/migration/index.ts @@ -44,6 +44,7 @@ import { Rdi1716370509836 } from './1716370509836-rdi'; import { AiHistory1718260230164 } from './1718260230164-ai-history'; import { CloudSession1729085495444 } from './1729085495444-cloud-session'; import { CommandExecution1726058563737 } from './1726058563737-command-execution'; +import { DatabaseCreatedAt1733740794737 } from './1733740794737-database-createdAt'; export default [ initialMigration1614164490968, @@ -92,4 +93,5 @@ export default [ AiHistory1718260230164, CloudSession1729085495444, CommandExecution1726058563737, + DatabaseCreatedAt1733740794737, ]; diff --git a/redisinsight/api/src/modules/database/database.service.spec.ts b/redisinsight/api/src/modules/database/database.service.spec.ts index 06c9b13c2c..8a39c12f36 100644 --- a/redisinsight/api/src/modules/database/database.service.spec.ts +++ b/redisinsight/api/src/modules/database/database.service.spec.ts @@ -530,6 +530,8 @@ describe('DatabaseService', () => { mockSessionMetadata, omit({ ...mockDatabase, username: 'new-name', timeout: 40_000 }, ['sshOptions.id']), ); + expect(databaseRepository.get) + .toHaveBeenCalledWith(mockSessionMetadata, mockDatabase.id, false, ['id', 'sshOptions.id', 'createdAt']); }); it('should create new database with merged ssh options', async () => { diff --git a/redisinsight/api/src/modules/database/database.service.ts b/redisinsight/api/src/modules/database/database.service.ts index 6cdfddf594..778a4b0f97 100644 --- a/redisinsight/api/src/modules/database/database.service.ts +++ b/redisinsight/api/src/modules/database/database.service.ts @@ -294,7 +294,7 @@ export class DatabaseService { public async clone(sessionMetadata: SessionMetadata, id: string, dto: UpdateDatabaseDto): Promise { this.logger.log('Clone existing database'); const database = await this.merge( - await this.get(sessionMetadata, id, false, ['id', 'sshOptions.id']), + await this.get(sessionMetadata, id, false, ['id', 'sshOptions.id', 'createdAt']), dto, ); if (DatabaseService.isConnectionAffected(dto)) { diff --git a/redisinsight/api/src/modules/database/entities/database.entity.ts b/redisinsight/api/src/modules/database/entities/database.entity.ts index 48275d9ca8..4a161acb92 100644 --- a/redisinsight/api/src/modules/database/entities/database.entity.ts +++ b/redisinsight/api/src/modules/database/entities/database.entity.ts @@ -1,5 +1,5 @@ import { - Column, Entity, ManyToOne, OneToOne, PrimaryGeneratedColumn, + Column, CreateDateColumn, Entity, ManyToOne, OneToOne, PrimaryGeneratedColumn, } from 'typeorm'; import { CaCertificateEntity } from 'src/modules/certificate/entities/ca-certificate.entity'; import { ClientCertificateEntity } from 'src/modules/certificate/entities/client-certificate.entity'; @@ -172,6 +172,12 @@ export class DatabaseEntity { @Column({ type: 'datetime', nullable: true }) lastConnection: Date; + @CreateDateColumn({ + nullable: true, + }) + @Expose() + createdAt: Date; + @Expose() @Column({ nullable: true, @@ -231,4 +237,4 @@ export class DatabaseEntity { @Expose() @Column({ nullable: true }) version: string; -} \ No newline at end of file +} diff --git a/redisinsight/api/src/modules/database/models/database.ts b/redisinsight/api/src/modules/database/models/database.ts index e3697cafbc..4d583e5cbd 100644 --- a/redisinsight/api/src/modules/database/models/database.ts +++ b/redisinsight/api/src/modules/database/models/database.ts @@ -146,6 +146,13 @@ export class Database { @Expose() lastConnection: Date; + @ApiProperty({ + description: 'Date of creation', + type: Date, + }) + @Expose() + createdAt?: Date; + @ApiPropertyOptional({ description: 'Redis OSS Sentinel master group.', type: SentinelMaster, diff --git a/redisinsight/api/src/modules/database/repositories/local.database.repository.ts b/redisinsight/api/src/modules/database/repositories/local.database.repository.ts index 7769f80577..9d40447200 100644 --- a/redisinsight/api/src/modules/database/repositories/local.database.repository.ts +++ b/redisinsight/api/src/modules/database/repositories/local.database.repository.ts @@ -96,6 +96,7 @@ export class LocalDatabaseRepository extends DatabaseRepository { .select([ 'd.id', 'd.name', 'd.host', 'd.port', 'd.db', 'd.new', 'd.timeout', 'd.connectionType', 'd.modules', 'd.lastConnection', 'd.provider', 'd.version', 'cd', + 'd.createdAt', ]) .getMany(); @@ -262,4 +263,4 @@ export class LocalDatabaseRepository extends DatabaseRepository { } } } -} \ No newline at end of file +} diff --git a/redisinsight/api/src/modules/feature/constants/index.ts b/redisinsight/api/src/modules/feature/constants/index.ts index fc9bb1dd24..df8e858dc7 100644 --- a/redisinsight/api/src/modules/feature/constants/index.ts +++ b/redisinsight/api/src/modules/feature/constants/index.ts @@ -29,6 +29,7 @@ export enum KnownFeatures { DatabaseChat = 'databaseChat', Rdi = 'redisDataIntegration', HashFieldExpiration = 'hashFieldExpiration', + EnhancedCloudUI = 'enhancedCloudUI', } export interface IFeatureFlag { diff --git a/redisinsight/api/src/modules/feature/constants/known-features.ts b/redisinsight/api/src/modules/feature/constants/known-features.ts index 6c5c364093..4f090140c7 100644 --- a/redisinsight/api/src/modules/feature/constants/known-features.ts +++ b/redisinsight/api/src/modules/feature/constants/known-features.ts @@ -39,4 +39,8 @@ export const knownFeatures: Record = { name: KnownFeatures.Rdi, storage: FeatureStorage.Database, }, + [KnownFeatures.EnhancedCloudUI]: { + name: KnownFeatures.EnhancedCloudUI, + storage: FeatureStorage.Database, + }, }; 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 2c1d1f9495..f01964333a 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 @@ -60,6 +60,10 @@ export class FeatureFlagProvider { this.featuresConfigService, this.settingsService, )); + this.strategies.set(KnownFeatures.EnhancedCloudUI, new WithDataFlagStrategy( + this.featuresConfigService, + this.settingsService, + )); } getStrategy(name: string): FeatureFlagStrategy { diff --git a/redisinsight/api/test/api/database/GET-databases.test.ts b/redisinsight/api/test/api/database/GET-databases.test.ts index 7bb6bb1932..20f5520dbf 100644 --- a/redisinsight/api/test/api/database/GET-databases.test.ts +++ b/redisinsight/api/test/api/database/GET-databases.test.ts @@ -17,6 +17,7 @@ const responseSchema = Joi.array().items(Joi.object().keys({ compressor: Joi.string().valid('NONE', 'LZ4', 'GZIP', 'ZSTD', 'SNAPPY').allow(null), connectionType: Joi.string().valid('STANDALONE', 'SENTINEL', 'CLUSTER', 'NOT CONNECTED').required(), lastConnection: Joi.string().isoDate().allow(null).required(), + createdAt: Joi.string().isoDate(), version: Joi.string().allow(null).required(), modules: Joi.array().items(Joi.object().keys({ name: Joi.string().required(), diff --git a/redisinsight/api/test/api/database/PATCH-databases-id.test.ts b/redisinsight/api/test/api/database/PATCH-databases-id.test.ts index 33ba77f555..b612252c62 100644 --- a/redisinsight/api/test/api/database/PATCH-databases-id.test.ts +++ b/redisinsight/api/test/api/database/PATCH-databases-id.test.ts @@ -233,7 +233,7 @@ describe(`PATCH /databases/:id`, () => { after: async () => { newDatabase = await localDb.getInstanceById(constants.TEST_INSTANCE_ID_3); expect(newDatabase).to.contain({ - ..._.omit(oldDatabase, ['modules', 'provider', 'lastConnection', 'new', 'timeout', 'compressor', 'version']), + ..._.omit(oldDatabase, ['modules', 'provider', 'lastConnection', 'new', 'timeout', 'compressor', 'version', 'createdAt']), host: constants.TEST_REDIS_HOST, port: constants.TEST_REDIS_PORT, }); diff --git a/redisinsight/api/test/api/database/POST-databases-clone-id.test.ts b/redisinsight/api/test/api/database/POST-databases-clone-id.test.ts index acf6efdccf..09075be1d5 100644 --- a/redisinsight/api/test/api/database/POST-databases-clone-id.test.ts +++ b/redisinsight/api/test/api/database/POST-databases-clone-id.test.ts @@ -161,7 +161,7 @@ describe(`POST /databases/clone/:id`, () => { after: async () => { newDatabase = await localDb.getInstanceByName('some name'); expect(newDatabase).to.contain({ - ..._.omit(oldDatabase, ['id', 'modules', 'name', 'provider', 'lastConnection', 'new', 'timeout', 'compressor', 'version']), + ..._.omit(oldDatabase, ['id', 'modules', 'name', 'provider', 'lastConnection', 'new', 'timeout', 'compressor', 'version', 'createdAt']), host: constants.TEST_REDIS_HOST, port: constants.TEST_REDIS_PORT, }); diff --git a/redisinsight/api/test/api/database/constants.ts b/redisinsight/api/test/api/database/constants.ts index 68024ae1b0..c1129b033b 100644 --- a/redisinsight/api/test/api/database/constants.ts +++ b/redisinsight/api/test/api/database/constants.ts @@ -36,6 +36,7 @@ export const databaseSchema = Joi.object().keys({ compressor: Joi.string().valid('NONE', 'LZ4', 'GZIP', 'ZSTD', 'SNAPPY').required(), nameFromProvider: Joi.string().allow(null), lastConnection: Joi.string().isoDate().allow(null), + createdAt: Joi.string().isoDate(), provider: Joi.string().valid(...providers), new: Joi.boolean().allow(null), tls: Joi.boolean().allow(null), diff --git a/redisinsight/ui/src/assets/img/alarm.svg b/redisinsight/ui/src/assets/img/alarm.svg new file mode 100644 index 0000000000..edce553e5b --- /dev/null +++ b/redisinsight/ui/src/assets/img/alarm.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/icons/star.svg b/redisinsight/ui/src/assets/img/icons/star.svg new file mode 100644 index 0000000000..77fd137567 --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/redisinsight/ui/src/assets/img/oauth/cloud_centered.svg b/redisinsight/ui/src/assets/img/oauth/cloud_centered.svg new file mode 100644 index 0000000000..eb34fe4a81 --- /dev/null +++ b/redisinsight/ui/src/assets/img/oauth/cloud_centered.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/redisinsight/ui/src/components/form-dialog/FormDialog.spec.tsx b/redisinsight/ui/src/components/form-dialog/FormDialog.spec.tsx new file mode 100644 index 0000000000..22cf0d848c --- /dev/null +++ b/redisinsight/ui/src/components/form-dialog/FormDialog.spec.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { render, screen } from 'uiSrc/utils/test-utils' + +import FormDialog from './FormDialog' + +describe('FormDialog', () => { + it('should render', () => { + render( + )} + footer={(
)} + > +
+ + ) + + expect(screen.getByTestId('header')).toBeInTheDocument() + expect(screen.getByTestId('footer')).toBeInTheDocument() + expect(screen.getByTestId('body')).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/components/form-dialog/FormDialog.tsx b/redisinsight/ui/src/components/form-dialog/FormDialog.tsx new file mode 100644 index 0000000000..c9f8c8a064 --- /dev/null +++ b/redisinsight/ui/src/components/form-dialog/FormDialog.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { EuiModal, EuiModalBody, EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui' +import { Nullable } from 'uiSrc/utils' + +import styles from './styles.module.scss' + +export interface Props { + isOpen: boolean + onClose: () => void + header: Nullable + footer?: Nullable + children: Nullable +} + +const FormDialog = (props: Props) => { + const { isOpen, onClose, header, footer, children } = props + + if (!isOpen) return null + + return ( + + + + {header} + + + + {children} + + + {footer} + + + ) +} + +export default FormDialog diff --git a/redisinsight/ui/src/components/form-dialog/index.ts b/redisinsight/ui/src/components/form-dialog/index.ts new file mode 100644 index 0000000000..8bf4e08670 --- /dev/null +++ b/redisinsight/ui/src/components/form-dialog/index.ts @@ -0,0 +1,3 @@ +import FormDialog from './FormDialog' + +export default FormDialog diff --git a/redisinsight/ui/src/components/form-dialog/styles.module.scss b/redisinsight/ui/src/components/form-dialog/styles.module.scss new file mode 100644 index 0000000000..4afd9292a3 --- /dev/null +++ b/redisinsight/ui/src/components/form-dialog/styles.module.scss @@ -0,0 +1,95 @@ +.modal { + width: 900px !important; + height: 700px !important; + + max-width: calc(100vw - 120px) !important; + max-height: calc(100vh - 120px) !important; + + &:global(.euiModal) { + background-color: var(--euiColorEmptyShade) !important; + } + + :global { + .euiModalHeader { + padding: 18px 24px; + + .euiModalHeader__title .euiTitle { + font-size: 18px; + } + } + + .euiModalBody__overflow { + padding: 8px 30px; + overflow-y: hidden !important; + mask-image: none !important; + } + + .euiModal__closeIcon { + top: 16px !important; + right: 16px !important; + background: none; + } + + .euiModalFooter { + display: block; + margin-top: 12px; + } + + .footerAddDatabase { + display: flex; + align-items: center; + justify-content: flex-end; + } + } +} + +/* form override */ +.modal { + :global { + .form__divider { + padding: 18px 0; + } + + .euiFieldText, + .euiFieldNumber, + .euiFieldPassword, + .euiFieldSearch, + .euiSelect, + .euiSuperSelectControl, + .euiComboBox .euiComboBox__inputWrap, + .euiTextArea { + background-color: var(--browserTableRowEven) !important; + padding: 12px; + border-color: var(--separatorColor) !important; + } + + .euiTextArea { + min-height: 80px; + } + + .euiFormControlLayout--group { + border-color: var(--separatorColor) !important; + } + + .euiFormRow, .euiFormControlLayout { + max-width: none; + + .euiFormControlLayout:not(.euiFormControlLayout--compressed) { + height: 42px !important; + } + + .euiSuperSelectControl:not(.euiSuperSelectControl--compressed), + .euiSelect:not(.euiSelect--compressed), + .euiFieldText:not(.euiFieldText--compressed), + .euiFieldNumber:not(.euiFieldNumber--compressed), + .euiFieldPassword { + height: 40px !important; + } + } + + .euiCheckbox__input~.euiCheckbox__label { + line-height: 24px !important; + font-size: 14px !important; + } + } +} diff --git a/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx deleted file mode 100644 index 9425fb4682..0000000000 --- a/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import cx from 'classnames' -import React, { useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' - -import ImportFileModal from 'uiSrc/components/import-file-modal/ImportFileModal' -import { - fetchInstancesAction, - importInstancesSelector, - resetImportInstances, - uploadInstancesFile -} from 'uiSrc/slices/instances/instances' -import { ImportDatabasesData } from 'uiSrc/slices/interfaces' -import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' -import { Nullable } from 'uiSrc/utils' - -import ResultsLog from './components/ResultsLog' -import styles from './styles.module.scss' - -export interface Props { - onClose: (isCancelled: boolean) => void -} - -const MAX_MB_FILE = 10 -const MAX_FILE_SIZE = MAX_MB_FILE * 1024 * 1024 - -const ImportDatabasesDialog = ({ onClose }: Props) => { - const { loading, data, error } = useSelector(importInstancesSelector) - const [files, setFiles] = useState>(null) - const [isInvalid, setIsInvalid] = useState(false) - const [isSubmitDisabled, setIsSubmitDisabled] = useState(true) - - const dispatch = useDispatch() - - const onFileChange = (files: FileList | null) => { - setFiles(files) - setIsInvalid(!!files?.length && files?.[0].size > MAX_FILE_SIZE) - setIsSubmitDisabled(!files?.length || files[0].size > MAX_FILE_SIZE) - } - - const handleOnClose = () => { - if (data?.success?.length || data?.partial?.length) { - dispatch(fetchInstancesAction()) - } - onClose(!data) - dispatch(resetImportInstances()) - } - - const onSubmit = () => { - if (files) { - const formData = new FormData() - formData.append('file', files[0]) - - dispatch(uploadInstancesFile(formData)) - - sendEventTelemetry({ - event: TelemetryEvent.CONFIG_DATABASES_REDIS_IMPORT_SUBMITTED - }) - } - } - - return ( - - onClose={handleOnClose} - onFileChange={onFileChange} - onSubmit={onSubmit} - modalClassName={cx(styles.modal, { [styles.result]: !!data })} - title="Import Database Connections" - submitResults={} - loading={loading} - data={data} - error={error} - errorMessage="Failed to add database connections" - invalidMessage={`File should not exceed ${MAX_MB_FILE} MB`} - isInvalid={isInvalid} - isSubmitDisabled={isSubmitDisabled} - /> - ) -} - -export default ImportDatabasesDialog diff --git a/redisinsight/ui/src/components/import-databases-dialog/index.ts b/redisinsight/ui/src/components/import-databases-dialog/index.ts deleted file mode 100644 index 2a083df701..0000000000 --- a/redisinsight/ui/src/components/import-databases-dialog/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import ImportDatabasesDialog from './ImportDatabasesDialog' - -export default ImportDatabasesDialog diff --git a/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss b/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss deleted file mode 100644 index 1202ffd046..0000000000 --- a/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss +++ /dev/null @@ -1,19 +0,0 @@ -.modal { - &.result { - width: 500px !important; - - @media screen and (min-width: 1024px) { - width: 700px !important; - min-width: 700px !important; - } - } - - .result { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - - margin-top: 20px; - } -} diff --git a/redisinsight/ui/src/components/index.ts b/redisinsight/ui/src/components/index.ts index bd10bc65b4..d402cce966 100644 --- a/redisinsight/ui/src/components/index.ts +++ b/redisinsight/ui/src/components/index.ts @@ -19,7 +19,6 @@ import GlobalSubscriptions from './global-subscriptions' import MonitorWrapper from './monitor' import PagePlaceholder from './page-placeholder' import BulkActionsConfig from './bulk-actions-config' -import ImportDatabasesDialog from './import-databases-dialog' import OnboardingTour from './onboarding-tour' import CodeBlock from './code-block' import ShowChildByCondition from './show-child-by-condition' @@ -36,6 +35,7 @@ import { } from './recommendation' import { FormatedDate } from './formated-date' import { UploadWarning } from './upload-warning' +import FormDialog from './form-dialog' export { FullScreen } from './full-screen' @@ -67,7 +67,6 @@ export { ShortcutsFlyout, PagePlaceholder, BulkActionsConfig, - ImportDatabasesDialog, OnboardingTour, CodeBlock, ShowChildByCondition, @@ -83,4 +82,5 @@ export { RecommendationBadgesLegend, FormatedDate, UploadWarning, + FormDialog } diff --git a/redisinsight/ui/src/components/item-list/ItemList.tsx b/redisinsight/ui/src/components/item-list/ItemList.tsx index bf463a3957..539632e1d4 100644 --- a/redisinsight/ui/src/components/item-list/ItemList.tsx +++ b/redisinsight/ui/src/components/item-list/ItemList.tsx @@ -4,10 +4,11 @@ import { EuiTableFieldDataColumnType, EuiTableSelectionType, PropertySort, + EuiBasicTableProps } from '@elastic/eui' import cx from 'classnames' import React, { useEffect, useRef, useState } from 'react' -import { Maybe, Nullable } from 'uiSrc/utils' +import { Maybe } from 'uiSrc/utils' import { findColumn, getColumnWidth, hideColumn } from './utils' import { ActionBar, DeleteAction, ExportAction } from './components' @@ -16,13 +17,14 @@ import styles from './styles.module.scss' export interface Props { width: number - editedInstance: Nullable columns: EuiTableFieldDataColumnType[] columnsToHide?: string[] onDelete: (ids: T[]) => void hideExport?: boolean onExport?: (ids: T[], withSecrets: boolean) => void onWheel: () => void + rowProps?: EuiBasicTableProps['rowProps'] + getSelectableItems?: (item: T) => boolean loading: boolean data: T[] onTableChange: ({ sort, page }: Criteria) => void @@ -37,7 +39,8 @@ function ItemList({ hideExport = false, onExport, onWheel, - editedInstance, + rowProps, + getSelectableItems, loading, data: instances, onTableChange, @@ -143,7 +146,10 @@ function ItemList({ } const selectionValue: EuiTableSelectionType = { - onSelectionChange: (selected: T[]) => setSelection(selected) + selectable: (item) => (getSelectableItems ? getSelectableItems?.(item) : true), + onSelectionChange: (selected: T[]) => { + setSelection(selected) + } } const handleResetSelection = () => { @@ -159,12 +165,6 @@ function ItemList({ tableRef.current?.setSelection([]) } - const toggleSelectedRow = (instance: T) => ({ - className: cx({ - 'euiTableRow-isSelected': instance?.id === editedInstance?.id - }) - }) - const actionMsg = (action: string) => ` Selected ${' '} @@ -183,7 +183,7 @@ function ItemList({ loading={loading} message={message} columns={columns ?? []} - rowProps={toggleSelectedRow} + rowProps={rowProps} sorting={{ sort }} selection={selectionValue} onWheel={onWheel} diff --git a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx index e3fd904d75..c0a42eb832 100644 --- a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx +++ b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx @@ -158,6 +158,13 @@ describe('NavigationMenu', () => { expect(screen.getByTestId('settings-page-btn')).toBeTruthy() }) + it('should render cloud link', () => { + const { container } = render() + + const createCloudLink = container.querySelector('[data-test-subj="create-cloud-nav-link"]') + expect(createCloudLink).toBeTruthy() + }) + it('should render github btn with proper link', () => { (appInfoSelector as jest.Mock).mockImplementation(() => ({ ...mockAppInfoSelector, diff --git a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx index 9711375054..5df2dc34e0 100644 --- a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx +++ b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx @@ -43,6 +43,7 @@ import { FeatureFlagComponent } from 'uiSrc/components' import { appContextSelector } from 'uiSrc/slices/app/context' import { AppWorkspace } from 'uiSrc/slices/interfaces' +import CreateCloud from './components/create-cloud' import HelpMenu from './components/help-menu/HelpMenu' import NotificationMenu from './components/notifications-center' @@ -309,6 +310,7 @@ const NavigationMenu = () => {
+ diff --git a/redisinsight/ui/src/components/navigation-menu/components/create-cloud/CreateCloud.spec.tsx b/redisinsight/ui/src/components/navigation-menu/components/create-cloud/CreateCloud.spec.tsx new file mode 100644 index 0000000000..bfe6a5b108 --- /dev/null +++ b/redisinsight/ui/src/components/navigation-menu/components/create-cloud/CreateCloud.spec.tsx @@ -0,0 +1,71 @@ +import React from 'react' +import { cloneDeep } from 'lodash' +import { cleanup, mockedStore, render, fireEvent } from 'uiSrc/utils/test-utils' + +import { setSSOFlow } from 'uiSrc/slices/instances/cloud' +import { OAuthSocialAction, OAuthSocialSource } from 'uiSrc/slices/interfaces' +import { setSocialDialogState } from 'uiSrc/slices/oauth/cloud' +import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' +import { sendEventTelemetry } from 'uiSrc/telemetry' +import { HELP_LINKS } from 'uiSrc/pages/home/constants' +import CreateCloud from './CreateCloud' + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +jest.mock('uiSrc/slices/app/features', () => ({ + ...jest.requireActual('uiSrc/slices/app/features'), + appFeatureFlagsFeaturesSelector: jest.fn().mockReturnValue({ + cloudSso: { + flag: true + } + }), +})) + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('CreateCloud', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call proper actions on click cloud button', () => { + const { container } = render() + const createCloudLink = container.querySelector('[data-test-subj="create-cloud-nav-link"]') + + fireEvent.click(createCloudLink as Element) + + expect(store.getActions()).toEqual([ + setSSOFlow(OAuthSocialAction.Create), + setSocialDialogState(OAuthSocialSource.NavigationMenu) + ]) + }) + + it('should call proper telemetry when sso is disabled', () => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock); + (appFeatureFlagsFeaturesSelector as jest.Mock).mockReturnValueOnce({ + cloudSso: { + flag: false + } + }) + const { container } = render() + const createCloudLink = container.querySelector('[data-test-subj="create-cloud-nav-link"]') + + fireEvent.click(createCloudLink as Element) + + expect(sendEventTelemetry).toBeCalledWith({ + event: HELP_LINKS.cloud.event, + eventData: { + source: OAuthSocialSource.NavigationMenu + } + }) + }) +}) diff --git a/redisinsight/ui/src/components/navigation-menu/components/create-cloud/CreateCloud.tsx b/redisinsight/ui/src/components/navigation-menu/components/create-cloud/CreateCloud.tsx new file mode 100644 index 0000000000..c830d1ff45 --- /dev/null +++ b/redisinsight/ui/src/components/navigation-menu/components/create-cloud/CreateCloud.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import cx from 'classnames' +import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui' + +import { OAuthSsoHandlerDialog } from 'uiSrc/components' +import { OAuthSocialAction, OAuthSocialSource } from 'uiSrc/slices/interfaces' +import { EXTERNAL_LINKS } from 'uiSrc/constants/links' +import CloudIcon from 'uiSrc/assets/img/oauth/cloud_centered.svg?react' + +import { getUtmExternalLink } from 'uiSrc/utils/links' +import { sendEventTelemetry } from 'uiSrc/telemetry' +import { HELP_LINKS } from 'uiSrc/pages/home/constants' +import styles from '../../styles.module.scss' + +const CreateCloud = () => { + const onCLickLink = (isSSOEnabled: boolean) => { + if (isSSOEnabled) return + + sendEventTelemetry({ + event: HELP_LINKS.cloud.event, + eventData: { + source: OAuthSocialSource.NavigationMenu + } + }) + } + + return ( + + + + {(ssoCloudHandlerClick, isSSOEnabled) => ( + { + onCLickLink(isSSOEnabled) + ssoCloudHandlerClick(e, + { source: OAuthSocialSource.NavigationMenu, action: OAuthSocialAction.Create }) + }} + className={styles.cloudLink} + href={getUtmExternalLink(EXTERNAL_LINKS.tryFree, { campaign: 'navigation_menu' })} + target="_blank" + data-test-subj="create-cloud-nav-link" + > + + + )} + + + + ) +} + +export default CreateCloud diff --git a/redisinsight/ui/src/components/navigation-menu/components/create-cloud/index.ts b/redisinsight/ui/src/components/navigation-menu/components/create-cloud/index.ts new file mode 100644 index 0000000000..1ccca31561 --- /dev/null +++ b/redisinsight/ui/src/components/navigation-menu/components/create-cloud/index.ts @@ -0,0 +1,3 @@ +import CreateCloud from './CreateCloud' + +export default CreateCloud diff --git a/redisinsight/ui/src/components/navigation-menu/styles.module.scss b/redisinsight/ui/src/components/navigation-menu/styles.module.scss index 21581bcc2b..fdf40f56ae 100644 --- a/redisinsight/ui/src/components/navigation-menu/styles.module.scss +++ b/redisinsight/ui/src/components/navigation-menu/styles.module.scss @@ -182,3 +182,16 @@ $sideBarWidth: 60px; background-color: #465282 !important; } } + +.cloudLink { + border-radius: 8px; + border: 1px solid #8BA2FF; + max-width: 44px; + max-height: 44px; + + .cloudIcon { + fill:none; + max-width: 26px; + color: #BDC3D7; + } +} diff --git a/redisinsight/ui/src/components/oauth/oauth-sso/oauth-autodiscovery/OAuthAutodiscovery.tsx b/redisinsight/ui/src/components/oauth/oauth-sso/oauth-autodiscovery/OAuthAutodiscovery.tsx index 81f1ce3b33..58acba7d2b 100644 --- a/redisinsight/ui/src/components/oauth/oauth-sso/oauth-autodiscovery/OAuthAutodiscovery.tsx +++ b/redisinsight/ui/src/components/oauth/oauth-sso/oauth-autodiscovery/OAuthAutodiscovery.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import { EuiButton, EuiText } from '@elastic/eui' +import { EuiButton, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui' import { useDispatch, useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' import { find } from 'lodash' @@ -14,15 +14,22 @@ import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { Pages } from 'uiSrc/constants' import OAuthForm from 'uiSrc/components/oauth/shared/oauth-form' + +import CloudIcon from 'uiSrc/assets/img/oauth/cloud_centered.svg?react' + +import { OAuthSsoHandlerDialog } from 'uiSrc/components' +import { getUtmExternalLink } from 'uiSrc/utils/links' +import { EXTERNAL_LINKS } from 'uiSrc/constants/links' import styles from './styles.module.scss' export interface Props { inline?: boolean source?: OAuthSocialSource + onClose?: () => void } const OAuthAutodiscovery = (props: Props) => { - const { inline, source = OAuthSocialSource.Autodiscovery } = props + const { inline, source = OAuthSocialSource.Autodiscovery, onClose } = props const { data } = useSelector(oauthCloudUserSelector) const [isDiscoverDisabled, setIsDiscoverDisabled] = useState(false) @@ -94,6 +101,32 @@ const OAuthAutodiscovery = (props: Props) => { }) } + const CreateFreeDb = () => ( +
+
Start FREE with Redis Cloud
+ + {(ssoCloudHandlerClick) => ( + { + ssoCloudHandlerClick(e, { + source: OAuthSocialSource.DiscoveryForm, + action: OAuthSocialAction.Create + }) + onClose?.() + }} + > + Quick start + + )} + +
+ ) + return (
{ {(form: React.ReactNode) => ( <> - Auto-discover subscriptions and add your databases. -
+ Discover subscriptions and add your databases. A new Redis Cloud account will be created for you if you don’t have one.
+ + + + Get started with +

Redis Cloud account

+ {form} +
diff --git a/redisinsight/ui/src/components/oauth/oauth-sso/oauth-autodiscovery/styles.module.scss b/redisinsight/ui/src/components/oauth/oauth-sso/oauth-autodiscovery/styles.module.scss index 7cf31083f8..9486af2370 100644 --- a/redisinsight/ui/src/components/oauth/oauth-sso/oauth-autodiscovery/styles.module.scss +++ b/redisinsight/ui/src/components/oauth/oauth-sso/oauth-autodiscovery/styles.module.scss @@ -3,10 +3,6 @@ flex-direction: column; align-items: center; - background-color: var(--euiColorLightestShade); - padding: 16px; - border-radius: 4px; - .buttonsContainer { .button { margin: 0 10px; @@ -35,26 +31,50 @@ } } - .title, + .title { + font-size: 28px; + font-weight: 700 !important; + } + .text { - text-align: center; - font-size: 14px !important; font-style: normal; font-weight: 400 !important; line-height: 150% !important; color: var(--htmlColor) !important; - } - - .text { - font-size: 12px !important; + font-size: 13px !important; padding-bottom: 16px; + align-self: flex-start; } .containerAgreement { margin-top: 16px; text-align: left; + + :global(.euiCheckbox .euiCheckbox__input ~ .euiCheckbox__label) { + line-height: 18px !important; + font-size: 12px !important; + } } + .createDbSection { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + + border: 1px solid var(--controlsBorderColor); + border-radius: 8px; + padding: 8px 16px; + + .createDbTitle { + display: flex; + align-items: center; + + > svg { + margin-right: 8px; + } + } + } } .withAdvantagesWrapper { diff --git a/redisinsight/ui/src/constants/featureFlags.ts b/redisinsight/ui/src/constants/featureFlags.ts index b953774f15..e149f65548 100644 --- a/redisinsight/ui/src/constants/featureFlags.ts +++ b/redisinsight/ui/src/constants/featureFlags.ts @@ -7,4 +7,5 @@ export enum FeatureFlags { disabledByEnv = 'disabledByEnv', rdi = 'redisDataIntegration', hashFieldExpiration = 'hashFieldExpiration', + enhancedCloudUI = 'enhancedCloudUI', } diff --git a/redisinsight/ui/src/constants/links.ts b/redisinsight/ui/src/constants/links.ts index ad53464d86..f45bbe4229 100644 --- a/redisinsight/ui/src/constants/links.ts +++ b/redisinsight/ui/src/constants/links.ts @@ -24,6 +24,7 @@ export const UTM_CAMPAINGS: Record = { [OAuthSocialSource.Workbench]: 'redisinsight_workbench', [CloudSsoUtmCampaign.BrowserFilter]: 'browser_filter', [OAuthSocialSource.EmptyDatabasesList]: 'empty_db_list', + [OAuthSocialSource.AddDbForm]: 'add_db_form', PubSub: 'pub_sub', Main: 'main', } diff --git a/redisinsight/ui/src/contexts/ModalTitleProvider.tsx b/redisinsight/ui/src/contexts/ModalTitleProvider.tsx new file mode 100644 index 0000000000..dba68d6f10 --- /dev/null +++ b/redisinsight/ui/src/contexts/ModalTitleProvider.tsx @@ -0,0 +1,17 @@ +import React, { createContext, useContext } from 'react' +import { Nullable } from 'uiSrc/utils' + +interface ModalHeaderContextType { + modalHeader: Nullable + setModalHeader: (content: Nullable, withBack?: boolean) => void +} + +// Create a context +const ModalHeaderContext = createContext({ + modalHeader: null, + setModalHeader: () => {} +}) + +// Custom hook to access the header context +export const useModalHeader = () => useContext(ModalHeaderContext) +export const ModalHeaderProvider = ModalHeaderContext.Provider diff --git a/redisinsight/ui/src/pages/home/HomePage.spec.tsx b/redisinsight/ui/src/pages/home/HomePage.spec.tsx index a27fefa23e..bbc6ded944 100644 --- a/redisinsight/ui/src/pages/home/HomePage.spec.tsx +++ b/redisinsight/ui/src/pages/home/HomePage.spec.tsx @@ -1,21 +1,8 @@ import React from 'react' import { render, screen } from 'uiSrc/utils/test-utils' - -import { MOCK_EXPLORE_GUIDES } from 'uiSrc/constants/mocks/mock-explore-guides' +import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' import HomePage from './HomePage' -jest.mock('uiSrc/slices/content/create-redis-buttons', () => ({ - ...jest.requireActual('uiSrc/slices/content/create-redis-buttons'), - contentSelector: () => jest.fn().mockReturnValue({ data: {}, loading: false }), -})) - -jest.mock('uiSrc/slices/content/guide-links', () => ({ - ...jest.requireActual('uiSrc/slices/content/guide-links'), - guideLinksSelector: jest.fn().mockReturnValue({ - data: MOCK_EXPLORE_GUIDES - }) -})) - jest.mock('uiSrc/slices/panels/sidePanels', () => ({ ...jest.requireActual('uiSrc/slices/panels/sidePanels'), sidePanelsSelector: jest.fn().mockReturnValue({ @@ -23,6 +10,24 @@ jest.mock('uiSrc/slices/panels/sidePanels', () => ({ }), })) +jest.mock('uiSrc/slices/content/create-redis-buttons', () => ({ + ...jest.requireActual('uiSrc/slices/content/create-redis-buttons'), + contentSelector: jest.fn().mockReturnValue({ + data: { + cloud_list_of_databases: {} + } + }), +})) + +jest.mock('uiSrc/slices/app/features', () => ({ + ...jest.requireActual('uiSrc/slices/app/features'), + appFeatureFlagsFeaturesSelector: jest.fn().mockReturnValue({ + enhancedCloudUI: { + flag: false + } + }), +})) + /** * HomePage tests * @@ -33,12 +38,6 @@ describe('HomePage', () => { expect(await render()).toBeTruthy() }) - it('should render capability promotion section', async () => { - await render() - - expect(screen.getByTestId('capability-promotion')).toBeInTheDocument() - }) - it('should render insights trigger', async () => { await render() @@ -50,4 +49,26 @@ describe('HomePage', () => { expect(screen.getByTestId('side-panels-insights')).toBeInTheDocument() }) + + it('should not render free cloud db with feature flag disabled', async () => { + (appFeatureFlagsFeaturesSelector as jest.Mock).mockReturnValueOnce({ + enhancedCloudUI: { + flag: false + } + }) + await render() + + expect(screen.queryByTestId('db-row_create-free-cloud-db')).not.toBeInTheDocument() + }) + + it('should render free cloud db with feature flag enabled', async () => { + (appFeatureFlagsFeaturesSelector as jest.Mock).mockReturnValueOnce({ + enhancedCloudUI: { + flag: true + } + }) + await render() + + expect(screen.getByTestId('db-row_create-free-cloud-db')).toBeInTheDocument() + }) }) diff --git a/redisinsight/ui/src/pages/home/HomePage.tsx b/redisinsight/ui/src/pages/home/HomePage.tsx index 3fb7186947..2bd6150ad4 100644 --- a/redisinsight/ui/src/pages/home/HomePage.tsx +++ b/redisinsight/ui/src/pages/home/HomePage.tsx @@ -2,18 +2,13 @@ import { EuiPage, EuiPageBody, EuiPanel, - EuiResizableContainer, - EuiResizeObserver } from '@elastic/eui' -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' -import cx from 'classnames' -import { throttle } from 'lodash' -import DatabasePanel from 'uiSrc/pages/home/components/database-panel' import { clusterSelector, resetDataRedisCluster, resetInstancesRedisCluster, } from 'uiSrc/slices/instances/cluster' import { Nullable, setTitle } from 'uiSrc/utils' import { HomePageTemplate } from 'uiSrc/templates' -import { BrowserStorageItem } from 'uiSrc/constants' +import { BrowserStorageItem, FeatureFlags } from 'uiSrc/constants' import { resetKeys } from 'uiSrc/slices/browser/keys' import { resetCliHelperSettings, resetCliSettingsAction } from 'uiSrc/slices/cli/cli-settings' import { resetRedisearchKeysData } from 'uiSrc/slices/browser/redisearch' @@ -25,32 +20,36 @@ import { fetchEditedInstanceAction, fetchInstancesAction, instancesSelector, + resetImportInstances, setEditedInstance } from 'uiSrc/slices/instances/instances' import { localStorageService } from 'uiSrc/services' import { resetDataSentinel, sentinelSelector } from 'uiSrc/slices/instances/sentinel' -import { fetchContentAction as fetchCreateRedisButtonsAction } from 'uiSrc/slices/content/create-redis-buttons' +import { + contentSelector, + fetchContentAction as fetchCreateRedisButtonsAction +} from 'uiSrc/slices/content/create-redis-buttons' import { sendEventTelemetry, sendPageViewTelemetry, TelemetryEvent, TelemetryPageView } from 'uiSrc/telemetry' import { appRedirectionSelector, setUrlHandlingInitialState } from 'uiSrc/slices/app/url-handling' import { UrlHandlingActions } from 'uiSrc/slices/interfaces/urlHandling' -import { AddDbType } from 'uiSrc/pages/home/constants' +import { CREATE_CLOUD_DB_ID } from 'uiSrc/pages/home/constants' +import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' import DatabasesList from './components/database-list-component' import DatabaseListHeader from './components/database-list-header' import EmptyMessage from './components/empty-message/EmptyMessage' +import DatabasePanelDialog from './components/database-panel-dialog' import './styles.scss' import styles from './styles.module.scss' -enum RightPanelName { +enum OpenDialogName { AddDatabase = 'add', EditDatabase = 'edit' } const HomePage = () => { - const [width, setWidth] = useState(0) - const [openRightPanel, setOpenRightPanel] = useState>(null) - const initialDbTypeRef = useRef(AddDbType.manual) + const [openDialog, setOpenDialog] = useState>(null) const dispatch = useDispatch() @@ -58,6 +57,8 @@ const HomePage = () => { const { credentials: cloudCredentials } = useSelector(cloudSelector) const { instance: sentinelInstance } = useSelector(sentinelSelector) const { action, dbConnection } = useSelector(appRedirectionSelector) + const { data: createDbContent } = useSelector(contentSelector) + const { [FeatureFlags.enhancedCloudUI]: enhancedCloudUIFeature } = useSelector(appFeatureFlagsFeaturesSelector) const { loading, @@ -73,6 +74,11 @@ const HomePage = () => { const { contextInstanceId } = useSelector(appContextSelector) + const predefinedInstances = enhancedCloudUIFeature?.flag && createDbContent?.cloud_list_of_databases ? [ + { id: CREATE_CLOUD_DB_ID, ...createDbContent.cloud_list_of_databases } as Instance + ] : [] + const isInstanceExists = instances.length > 0 || predefinedInstances.length > 0 + useEffect(() => { setTitle('Redis databases') @@ -94,20 +100,20 @@ const HomePage = () => { useEffect(() => { if (isChangedInstance) { - setOpenRightPanel(null) + setOpenDialog(null) dispatch(setEditedInstance(null)) } }, [isChangedInstance]) useEffect(() => { if (clusterCredentials || cloudCredentials || sentinelInstance) { - setOpenRightPanel(RightPanelName.AddDatabase) + setOpenDialog(OpenDialogName.AddDatabase) } }, [clusterCredentials, cloudCredentials, sentinelInstance]) useEffect(() => { if (action === UrlHandlingActions.Connect) { - setOpenRightPanel(RightPanelName.AddDatabase) + setOpenDialog(OpenDialogName.AddDatabase) } }, [action, dbConnection]) @@ -141,7 +147,7 @@ const HomePage = () => { const closeEditDialog = () => { dispatch(setEditedInstance(null)) - setOpenRightPanel(null) + setOpenDialog(null) sendEventTelemetry({ event: TelemetryEvent.CONFIG_DATABASES_DATABASE_EDIT_CANCELLED_CLICKED, @@ -154,8 +160,9 @@ const HomePage = () => { const handleClose = () => { dispatch(resetDataRedisCluster()) dispatch(resetDataSentinel()) + dispatch(resetImportInstances()) - setOpenRightPanel(null) + setOpenDialog(null) dispatch(setEditedInstance(null)) if (action === UrlHandlingActions.Connect) { @@ -167,25 +174,24 @@ const HomePage = () => { }) } - const handleAddInstance = (addDbType = AddDbType.manual) => { - initialDbTypeRef.current = addDbType - setOpenRightPanel(RightPanelName.AddDatabase) + const handleAddInstance = () => { + setOpenDialog(OpenDialogName.AddDatabase) dispatch(setEditedInstance(null)) } const handleEditInstance = (editedInstance: Instance) => { if (editedInstance) { dispatch(fetchEditedInstanceAction(editedInstance)) - setOpenRightPanel(RightPanelName.EditDatabase) + setOpenDialog(OpenDialogName.EditDatabase) } } const handleDeleteInstances = (instances: Instance[]) => { if ( instances.find((instance) => instance.id === editedInstance?.id) - && openRightPanel === RightPanelName.EditDatabase + && openDialog === OpenDialogName.EditDatabase ) { dispatch(setEditedInstance(null)) - setOpenRightPanel(null) + setOpenDialog(null) } instances.forEach((instance) => { @@ -193,31 +199,6 @@ const HomePage = () => { }) } - const onResize = ({ width: innerWidth }: { width: number }) => { - setWidth(innerWidth) - } - const onResizeTrottled = useCallback(throttle(onResize, 100), []) - - const InstanceList = () => - (!instances.length && !loading && !loadingChanging ? ( - - - - ) : ( - - {(resizeRef) => ( -
- -
- )} -
- )) - return (
@@ -227,74 +208,38 @@ const HomePage = () => { key="instance-controls" onAddInstance={handleAddInstance} /> + {!!openDialog && ( + + )}
- - {(EuiResizablePanel, EuiResizableButton) => ( - <> - - - - - - - - {!!openRightPanel && ( - - )} -
- - - )} - + {(!isInstanceExists && !loading && !loadingChanging ? ( + + + + ) : ( + + ))}
diff --git a/redisinsight/ui/src/pages/home/components/capability-promotion/CapabilityPromotion.spec.tsx b/redisinsight/ui/src/pages/home/components/capability-promotion/CapabilityPromotion.spec.tsx deleted file mode 100644 index 81c60d65e1..0000000000 --- a/redisinsight/ui/src/pages/home/components/capability-promotion/CapabilityPromotion.spec.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React from 'react' -import { cloneDeep } from 'lodash' -import reactRouterDom from 'react-router-dom' -import { cleanup, fireEvent, mockedStore, render, screen } from 'uiSrc/utils/test-utils' - -import { changeSelectedTab, changeSidePanel, toggleSidePanel } from 'uiSrc/slices/panels/sidePanels' -import { InsightsPanelTabs, SidePanels } from 'uiSrc/slices/interfaces/insights' -import { sendEventTelemetry, TELEMETRY_EMPTY_VALUE, TelemetryEvent } from 'uiSrc/telemetry' -import { MOCK_EXPLORE_GUIDES } from 'uiSrc/constants/mocks/mock-explore-guides' -import { findTutorialPath } from 'uiSrc/utils' - -import { CapabilityPromotion } from './CapabilityPromotion' - -jest.mock('uiSrc/telemetry', () => ({ - ...jest.requireActual('uiSrc/telemetry'), - sendEventTelemetry: jest.fn(), -})) - -jest.mock('uiSrc/slices/content/guide-links', () => ({ - ...jest.requireActual('uiSrc/slices/content/guide-links'), - guideLinksSelector: jest.fn().mockReturnValue({ - data: MOCK_EXPLORE_GUIDES - }) -})) - -jest.mock('uiSrc/utils', () => ({ - ...jest.requireActual('uiSrc/utils'), - findTutorialPath: jest.fn(), -})) - -let store: typeof mockedStore -beforeEach(() => { - cleanup() - store = cloneDeep(mockedStore) - store.clearActions() -}) - -describe('CapabilityPromotion', () => { - it('should render', () => { - expect(render()).toBeTruthy() - }) - - it('should render capabilities', () => { - render() - - MOCK_EXPLORE_GUIDES.slice(0, 2).forEach(({ tutorialId }) => { - expect(screen.getByTestId(`capability-promotion-${tutorialId}`)).toBeInTheDocument() - }) - }) - - it('should call proper actions and history push on click capability', () => { - const pushMock = jest.fn() - reactRouterDom.useHistory = jest.fn().mockReturnValue({ push: pushMock }); - (findTutorialPath as jest.Mock).mockImplementation(() => '0/1/0') - - const id = MOCK_EXPLORE_GUIDES[0]?.tutorialId - render() - - fireEvent.click(screen.getByTestId(`capability-promotion-${id}`)) - - const expectedActions = [ - changeSelectedTab(InsightsPanelTabs.Explore), - changeSidePanel(SidePanels.Insights) - ] - - expect(store.getActions()).toEqual(expectedActions) - expect(pushMock).toHaveBeenCalledWith({ - search: 'path=tutorials/0/1/0' - }) - }) - - it('should call proper telemetry after click capability', () => { - const sendEventTelemetryMock = jest.fn(); - (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) - - const id = MOCK_EXPLORE_GUIDES[0]?.tutorialId - render() - - fireEvent.click(screen.getByTestId(`capability-promotion-${id}`)) - - expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.INSIGHTS_PANEL_OPENED, - eventData: { - databaseId: TELEMETRY_EMPTY_VALUE, - source: 'home page', - tutorialId: id - } - }); - - (sendEventTelemetry as jest.Mock).mockRestore() - }) - - it('should call proper actions after click explore redis', () => { - render() - - fireEvent.click(screen.getByTestId('explore-redis-btn')) - - const expectedActions = [ - changeSelectedTab(InsightsPanelTabs.Explore), - toggleSidePanel(SidePanels.Insights) - ] - - expect(store.getActions()).toEqual(expectedActions) - - expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.INSIGHTS_PANEL_OPENED, - eventData: { - databaseId: TELEMETRY_EMPTY_VALUE, - source: 'home page', - tab: InsightsPanelTabs.Explore, - } - }); - - (sendEventTelemetry as jest.Mock).mockRestore() - }) -}) diff --git a/redisinsight/ui/src/pages/home/components/capability-promotion/CapabilityPromotion.tsx b/redisinsight/ui/src/pages/home/components/capability-promotion/CapabilityPromotion.tsx deleted file mode 100644 index 4c1a750520..0000000000 --- a/redisinsight/ui/src/pages/home/components/capability-promotion/CapabilityPromotion.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import React from 'react' -import { EuiIcon, EuiText } from '@elastic/eui' -import cx from 'classnames' -import { useDispatch, useSelector } from 'react-redux' -import { useHistory } from 'react-router-dom' -import { filter } from 'lodash' - -import { - changeSelectedTab, - openTutorialByPath, - sidePanelsSelector, toggleSidePanel, -} from 'uiSrc/slices/panels/sidePanels' -import { sendEventTelemetry, TELEMETRY_EMPTY_VALUE, TelemetryEvent } from 'uiSrc/telemetry' -import { guideLinksSelector } from 'uiSrc/slices/content/guide-links' -import GUIDE_ICONS from 'uiSrc/components/explore-guides/icons' -import { findTutorialPath } from 'uiSrc/utils' -import { InsightsPanelTabs, SidePanels } from 'uiSrc/slices/interfaces/insights' -import { TutorialsIds } from 'uiSrc/constants' -import styles from './styles.module.scss' - -export interface Props { - mode?: 'reduced' | 'wide' - wrapperClassName?: string - capabilityIds?: string[] -} - -const displayedCapabilityIds = [TutorialsIds.IntroToSearch, TutorialsIds.IntroToJSON] - -const CapabilityPromotion = (props: Props) => { - const { mode = 'wide', wrapperClassName, capabilityIds = displayedCapabilityIds } = props - const { data: dataInit } = useSelector(guideLinksSelector) - const { openedPanel } = useSelector(sidePanelsSelector) - const isInsightsOpen = openedPanel === SidePanels.Insights - - const dispatch = useDispatch() - const history = useHistory() - - // display only RediSearch and JSON. In the future will be configured via github - const data = filter(dataInit, ({ tutorialId }) => capabilityIds.includes(tutorialId)) - - const sendTelemetry = (id?: string) => { - sendEventTelemetry({ - event: isInsightsOpen ? TelemetryEvent.INSIGHTS_PANEL_CLOSED : TelemetryEvent.INSIGHTS_PANEL_OPENED, - eventData: { - databaseId: TELEMETRY_EMPTY_VALUE, - source: 'home page', - tutorialId: id || undefined, - tab: id ? undefined : InsightsPanelTabs.Explore, - }, - }) - } - - const onClickTutorial = (id: string) => { - const tutorialPath = findTutorialPath({ id: id ?? '' }) - dispatch(openTutorialByPath(tutorialPath ?? '', history)) - - if (isInsightsOpen) { - return - } - - sendTelemetry(id) - } - - const onClickExplore = () => { - sendTelemetry() - dispatch(changeSelectedTab(InsightsPanelTabs.Explore)) - dispatch(toggleSidePanel(SidePanels.Insights)) - } - - if (!data?.length) { - return null - } - - return ( -
-
{}} - onClick={onClickExplore} - className={styles.exploreItem} - data-testid="explore-redis-btn" - > - Explore Redis -
-
- {data.map(({ title, tutorialId, icon }) => ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events -
onClickTutorial(tutorialId)} - className={cx('capabilities__item', styles.guideItem)} - data-testid={`capability-promotion-${tutorialId}`} - > - {icon in GUIDE_ICONS && ( - - )} - {title} -
- ))} -
-
- ) -} - -export { CapabilityPromotion } diff --git a/redisinsight/ui/src/pages/home/components/capability-promotion/index.ts b/redisinsight/ui/src/pages/home/components/capability-promotion/index.ts deleted file mode 100644 index 312f72adfd..0000000000 --- a/redisinsight/ui/src/pages/home/components/capability-promotion/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CapabilityPromotion } from './CapabilityPromotion' diff --git a/redisinsight/ui/src/pages/home/components/capability-promotion/styles.module.scss b/redisinsight/ui/src/pages/home/components/capability-promotion/styles.module.scss deleted file mode 100644 index d5d49c2646..0000000000 --- a/redisinsight/ui/src/pages/home/components/capability-promotion/styles.module.scss +++ /dev/null @@ -1,64 +0,0 @@ -.wrapper { - border-radius: 8px; - height: 48px; - min-height: 48px; - flex-grow: 1; - overflow: hidden; - - display: flex; - justify-content: flex-end; - - .guides { - display: flex; - align-items: center; - justify-content: flex-end; - - padding: 0 12px; - height: 100%; - } - - .exploreItem { - margin-right: 8px; - text-decoration: none; - display: flex; - align-items: center; - transition: transform 0.1s linear; - - &:hover { - transform: translateY(-1px) !important; - } - - &:focus, - &:hover { - text-decoration: underline; - } - } - - .guideItem { - display: flex; - align-items: center; - padding: 6px 10px; - margin-right: 6px; - height: 32px; - border-radius: 20px; - border: 1px solid var(--euiColorSecondary); - - cursor: pointer; - text-decoration: none; - - &:focus, - &:hover { - text-decoration: underline; - } - - &:nth-child(1) { .guideIcon { color: #FFAF2B; }} - &:nth-child(2) { .guideIcon { color: #4FDAE0; }} - &:nth-child(3) { .guideIcon { color: #F74B57; }} - &:nth-child(4) { .guideIcon { color: #9E7EE8; }} - &:nth-child(5) { .guideIcon { color: #5BC69B; }} - - .guideIcon { - margin-right: 4px; - } - } -} diff --git a/redisinsight/ui/src/pages/home/components/cloud-connection/CloudConnectionFormWrapper.tsx b/redisinsight/ui/src/pages/home/components/cloud-connection/CloudConnectionFormWrapper.tsx index 37b25e2f0f..f795667b8d 100644 --- a/redisinsight/ui/src/pages/home/components/cloud-connection/CloudConnectionFormWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/cloud-connection/CloudConnectionFormWrapper.tsx @@ -1,17 +1,17 @@ -import React, { useEffect, useRef } from 'react' +import React, { useEffect } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' +import { EuiTitle } from '@elastic/eui' import { Pages } from 'uiSrc/constants' import { cloudSelector, fetchSubscriptionsRedisCloud, setSSOFlow } from 'uiSrc/slices/instances/cloud' -import { useResizableFormField } from 'uiSrc/services' import { resetErrors } from 'uiSrc/slices/app/notifications' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { useModalHeader } from 'uiSrc/contexts/ModalTitleProvider' import CloudConnectionForm from './cloud-connection-form' export interface Props { - width: number onClose?: () => void } @@ -20,21 +20,25 @@ export interface ICloudConnectionSubmit { secretKey: string } -const CloudConnectionFormWrapper = ({ onClose, width }: Props) => { +const CloudConnectionFormWrapper = ({ onClose }: Props) => { const dispatch = useDispatch() - const formRef = useRef(null) const history = useHistory() const { loading, credentials } = useSelector(cloudSelector) - const [flexGroupClassName, flexItemClassName] = useResizableFormField(formRef, width) + const { setModalHeader } = useModalHeader() - useEffect( - () => () => { + useEffect(() => { + setModalHeader( +

Discover Cloud databases

, + true + ) + + return () => { + setModalHeader(null) dispatch(resetErrors()) - }, - [] - ) + } + }, []) const formSubmit = (credentials: ICloudConnectionSubmit) => { sendEventTelemetry({ @@ -49,17 +53,13 @@ const CloudConnectionFormWrapper = ({ onClose, width }: Props) => { } return ( -
- -
+ ) } diff --git a/redisinsight/ui/src/pages/home/components/cloud-connection/cloud-connection-form/CloudConnectionForm.spec.tsx b/redisinsight/ui/src/pages/home/components/cloud-connection/cloud-connection-form/CloudConnectionForm.spec.tsx index 5362dfe8d1..90ce30afad 100644 --- a/redisinsight/ui/src/pages/home/components/cloud-connection/cloud-connection-form/CloudConnectionForm.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/cloud-connection/cloud-connection-form/CloudConnectionForm.spec.tsx @@ -24,14 +24,11 @@ describe('CloudConnectionForm', () => { it('should not render cloud sso form by default', () => { render() - expect(screen.queryByTestId('use-cloud-account-accordion')).not.toBeInTheDocument() - expect(screen.queryByTestId('use-cloud-keys-accordion')).not.toBeInTheDocument() - expect(screen.getByTestId('access-key')).toBeInTheDocument() expect(screen.getByTestId('secret-key')).toBeInTheDocument() }) - it('should render cloud sso form and collapsible nav groups with feature flag', () => { + it('should render cloud sso form with feature flag', () => { (appFeatureFlagsFeaturesSelector as jest.Mock).mockReturnValue({ cloudSso: { flag: true @@ -40,10 +37,6 @@ describe('CloudConnectionForm', () => { render() - expect(screen.getByTestId('use-cloud-account-accordion')).toBeInTheDocument() - expect(screen.getByTestId('use-cloud-keys-accordion')).toBeInTheDocument() - - expect(screen.getByTestId('access-key')).toBeInTheDocument() - expect(screen.getByTestId('secret-key')).toBeInTheDocument() + expect(screen.getByTestId('oauth-container-social-buttons')).toBeInTheDocument() }) }) diff --git a/redisinsight/ui/src/pages/home/components/cloud-connection/cloud-connection-form/CloudConnectionForm.tsx b/redisinsight/ui/src/pages/home/components/cloud-connection/cloud-connection-form/CloudConnectionForm.tsx index 6549ae1cf0..734d2cc4e5 100644 --- a/redisinsight/ui/src/pages/home/components/cloud-connection/cloud-connection-form/CloudConnectionForm.tsx +++ b/redisinsight/ui/src/pages/home/components/cloud-connection/cloud-connection-form/CloudConnectionForm.tsx @@ -4,25 +4,29 @@ import { FormikErrors, useFormik } from 'formik' import { isEmpty } from 'lodash' import { EuiButton, - EuiCollapsibleNavGroup, EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiForm, EuiFormRow, - EuiLink, + EuiRadioGroup, + EuiSpacer, EuiText, EuiToolTip, EuiWindowEvent, keys, } from '@elastic/eui' +import { useSelector } from 'react-redux' import { validateField } from 'uiSrc/utils/validations' import validationErrors from 'uiSrc/constants/validationErrors' import { FeatureFlagComponent } from 'uiSrc/components' import { FeatureFlags } from 'uiSrc/constants' -import { OAuthAutodiscovery } from 'uiSrc/components/oauth/oauth-sso' +import { CloudConnectionOptions } from 'uiSrc/pages/home/constants' +import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' import { OAuthSocialSource } from 'uiSrc/slices/interfaces' +import { OAuthAutodiscovery } from 'uiSrc/components/oauth/oauth-sso' +import { MessageCloudApiKeys } from 'uiSrc/pages/home/components/form/Messages' import { ICloudConnectionSubmit } from '../CloudConnectionFormWrapper' import styles from '../styles.module.scss' @@ -30,21 +34,19 @@ import styles from '../styles.module.scss' export interface Props { accessKey: string secretKey: string - flexGroupClassName: string - flexItemClassName: string onClose?: () => void onSubmit: ({ accessKey, secretKey }: ICloudConnectionSubmit) => void loading: boolean } interface ISubmitButton { - onClick: () => void; - submitIsDisabled: boolean; + onClick: () => void + submitIsDisabled: boolean } interface Values { - accessKey: string; - secretKey: string; + accessKey: string + secretKey: string } const fieldDisplayNames: Values = { @@ -52,40 +54,29 @@ const fieldDisplayNames: Values = { secretKey: 'Enter API User Key', } -const Message = () => ( - <> - - {`Enter API keys to discover and add databases. - API keys can be enabled by following the steps - mentioned in the `} - - documentation. - - - -) +const options = [ + { id: CloudConnectionOptions.Account, label: 'Redis Cloud account' }, + { id: CloudConnectionOptions.ApiKeys, label: 'Redis Cloud API keys' }, +] const CloudConnectionForm = (props: Props) => { const { accessKey, secretKey, - flexGroupClassName, - flexItemClassName, onClose, onSubmit, loading, } = props + const { [FeatureFlags.cloudSso]: cloudSsoFeature } = useSelector(appFeatureFlagsFeaturesSelector) + const [domReady, setDomReady] = useState(false) const [errors, setErrors] = useState>( accessKey || secretKey ? {} : fieldDisplayNames ) + const [type, setType] = useState( + cloudSsoFeature?.flag ? CloudConnectionOptions.Account : CloudConnectionOptions.ApiKeys + ) useEffect(() => { setDomReady(true) @@ -124,10 +115,11 @@ const CloudConnectionForm = (props: Props) => { const CancelButton = ({ onClick }: { onClick: () => void }) => ( Cancel @@ -150,6 +142,7 @@ const CloudConnectionForm = (props: Props) => { > { } const CloudApiForm = ( -
- -
+
+ + - - + + { - - + + { ) return ( - <> -
- - Connect with: - - - - - - - {CloudApiForm} - - -
- +
+ + + Connect with: + + setType(id as CloudConnectionOptions)} + data-testid="cloud-options" + /> + + + + + {type === CloudConnectionOptions.Account && ( + + )} + {type === CloudConnectionOptions.ApiKeys && CloudApiForm} +
) } diff --git a/redisinsight/ui/src/pages/home/components/cloud-connection/styles.module.scss b/redisinsight/ui/src/pages/home/components/cloud-connection/styles.module.scss index 2bb64eae6c..492755fc01 100644 --- a/redisinsight/ui/src/pages/home/components/cloud-connection/styles.module.scss +++ b/redisinsight/ui/src/pages/home/components/cloud-connection/styles.module.scss @@ -1,9 +1,4 @@ .cloudApi { - margin-top: 5px; - padding: 16px; - background-color: var(--euiColorLightestShade); - border-radius: 4px; - :global(.euiTextColor), :global(.euiLink) { color: currentColor !important; @@ -52,33 +47,16 @@ } .message { - padding: 0 26px; - text-align: center; font-family: "Graphik", sans-serif; color: var(--euiTextSubduedColor) !important; } -.accordion { - background: var(--euiColorLightestShade); - border-radius: 4px; - padding: 12px 18px !important; - margin-top: 14px !important; - - :global { - .euiAccordion__triggerWrapper { - background: var(--euiColorLightestShade); - padding: 0 !important; - } - - .euiAccordion__triggerWrapper h3 { - font-size: 14px !important; - line-height: 14px !important; - font-weight: 400 !important; - height: auto !important; - } +.cloudOptions { + display: flex; + align-items: center; - .euiCollapsibleNavGroup__children { - padding: 12px 0 0 !important; - } + :global(.euiRadioGroup__item) { + margin-top: 0 !important; + margin-right: 12px; } } diff --git a/redisinsight/ui/src/pages/home/components/cluster-connection/ClusterConnectionFormWrapper.tsx b/redisinsight/ui/src/pages/home/components/cluster-connection/ClusterConnectionFormWrapper.tsx index 38379a24b7..735b7e7449 100644 --- a/redisinsight/ui/src/pages/home/components/cluster-connection/ClusterConnectionFormWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/cluster-connection/ClusterConnectionFormWrapper.tsx @@ -2,25 +2,25 @@ import React, { useEffect, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' +import { EuiTitle } from '@elastic/eui' import { clusterSelector, fetchInstancesRedisCluster, } from 'uiSrc/slices/instances/cluster' import { Pages } from 'uiSrc/constants' -import { useResizableFormField } from 'uiSrc/services' import { resetErrors } from 'uiSrc/slices/app/notifications' import { ICredentialsRedisCluster, InstanceType } from 'uiSrc/slices/interfaces' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { autoFillFormDetails } from 'uiSrc/pages/home/utils' +import { useModalHeader } from 'uiSrc/contexts/ModalTitleProvider' import ClusterConnectionForm from './cluster-connection-form/ClusterConnectionForm' export interface Props { - width: number; - onClose?: () => void; + onClose?: () => void } -const ClusterConnectionFormWrapper = ({ onClose, width }: Props) => { +const ClusterConnectionFormWrapper = ({ onClose }: Props) => { const [initialValues, setInitialValues] = useState({ host: '', port: '', @@ -30,22 +30,23 @@ const ClusterConnectionFormWrapper = ({ onClose, width }: Props) => { const history = useHistory() const dispatch = useDispatch() + const { setModalHeader } = useModalHeader() const formRef = useRef(null) const { loading, credentials } = useSelector(clusterSelector) - const [flexGroupClassName, flexItemClassName] = useResizableFormField( - formRef, - width - ) + useEffect(() => { + setModalHeader( +

Redis Software

, + true + ) - useEffect( - () => () => { + return () => { + setModalHeader(null) dispatch(resetErrors()) - }, - [] - ) + } + }, []) useEffect(() => { if (credentials) { @@ -83,8 +84,6 @@ const ClusterConnectionFormWrapper = ({ onClose, width }: Props) => { password={credentials?.password ?? ''} initialValues={initialValues} onHostNamePaste={handlePostHostName} - flexGroupClassName={flexGroupClassName} - flexItemClassName={flexItemClassName} onClose={onClose} onSubmit={formSubmit} loading={loading} diff --git a/redisinsight/ui/src/pages/home/components/cluster-connection/cluster-connection-form/ClusterConnectionForm.tsx b/redisinsight/ui/src/pages/home/components/cluster-connection/cluster-connection-form/ClusterConnectionForm.tsx index 74d06ba718..b679ec3336 100644 --- a/redisinsight/ui/src/pages/home/components/cluster-connection/cluster-connection-form/ClusterConnectionForm.tsx +++ b/redisinsight/ui/src/pages/home/components/cluster-connection/cluster-connection-form/ClusterConnectionForm.tsx @@ -12,8 +12,6 @@ import { EuiForm, EuiFormRow, EuiIcon, - EuiLink, - EuiText, EuiToolTip, EuiWindowEvent, keys, @@ -24,25 +22,22 @@ import { validateField, validatePortNumber, } from 'uiSrc/utils/validations' -import { APPLICATION_NAME } from 'uiSrc/constants' import { handlePasteHostName } from 'uiSrc/utils' import validationErrors from 'uiSrc/constants/validationErrors' import { ICredentialsRedisCluster } from 'uiSrc/slices/interfaces' -import styles from '../styles.module.scss' +import { MessageEnterpriceSoftware } from 'uiSrc/pages/home/components/form/Messages' export interface Props { - host: string; - port: string; - username: string; - password: string; - onHostNamePaste: (text: string) => boolean; - flexGroupClassName: string; - flexItemClassName: string; - onClose?: () => void; - initialValues: Values; - onSubmit: (values: ICredentialsRedisCluster) => void; - loading: boolean; + host: string + port: string + username: string + password: string + onHostNamePaste: (text: string) => boolean + onClose?: () => void + initialValues: Values + onSubmit: (values: ICredentialsRedisCluster) => void + loading: boolean } interface ISubmitButton { @@ -65,26 +60,6 @@ const fieldDisplayNames: Values = { password: 'Admin Password', } -const Message = () => ( - - Your Redis Enterprise databases can be automatically added. Enter the - connection details of your Redis Enterprise Cluster to automatically - discover your databases and add them to - {' '} - {APPLICATION_NAME} - .   - - Learn more here. - - -) - const ClusterConnectionForm = (props: Props) => { const { host, @@ -93,8 +68,6 @@ const ClusterConnectionForm = (props: Props) => { password, initialValues: initialValuesProp, onHostNamePaste, - flexGroupClassName, - flexItemClassName, onClose, onSubmit, loading, @@ -191,10 +164,11 @@ const ClusterConnectionForm = (props: Props) => { const CancelButton = ({ onClick }: { onClick: () => void }) => ( Cancel @@ -219,6 +193,7 @@ const ClusterConnectionForm = (props: Props) => { > { } return ( - <> -
- -
- - - - - - - ) => { - formik.setFieldValue( - e.target.name, - validateField(e.target.value.trim()) - ) - }} - onPaste={(event: React.ClipboardEvent) => - handlePasteHostName(onHostNamePaste, event)} - append={} - /> - - +
+ +
- - - ) => { - formik.setFieldValue( - e.target.name, - validatePortNumber(e.target.value.trim()) - ) - }} - type="text" - min={0} - max={MAX_PORT_NUMBER} - /> - - - + + + + + + ) => { + formik.setFieldValue( + e.target.name, + validateField(e.target.value.trim()) + ) + }} + onPaste={(event: React.ClipboardEvent) => + handlePasteHostName(onHostNamePaste, event)} + append={} + /> + + - - - - - - + + + ) => { + formik.setFieldValue( + e.target.name, + validatePortNumber(e.target.value.trim()) + ) + }} + type="text" + min={0} + max={MAX_PORT_NUMBER} + /> + + + - - - - - - - + + + + + + -
-
- + + + + + +
+
+
+
) } diff --git a/redisinsight/ui/src/pages/home/components/cluster-connection/styles.module.scss b/redisinsight/ui/src/pages/home/components/cluster-connection/styles.module.scss index f41cbcc0de..00727cf936 100644 --- a/redisinsight/ui/src/pages/home/components/cluster-connection/styles.module.scss +++ b/redisinsight/ui/src/pages/home/components/cluster-connection/styles.module.scss @@ -1,10 +1,6 @@ .message { - margin-top: 5px; - padding: 16px; - background-color: var(--euiColorLightestShade); font-family: 'Graphik', sans-serif; color: var(--euiTextSubduedColor) !important; - border: 1px solid var(--euiColorLightShade); :global(.euiTextColor), :global(.euiLink) { color: currentColor !important; diff --git a/redisinsight/ui/src/pages/home/components/connection-url/ConnectionUrl.spec.tsx b/redisinsight/ui/src/pages/home/components/connection-url/ConnectionUrl.spec.tsx new file mode 100644 index 0000000000..0c2053eac4 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/connection-url/ConnectionUrl.spec.tsx @@ -0,0 +1,101 @@ +import React from 'react' +import { mock } from 'ts-mockito' +import { cloneDeep } from 'lodash' +import { render, screen, fireEvent, mockedStore, cleanup, act } from 'uiSrc/utils/test-utils' + +import { defaultInstanceChanging } from 'uiSrc/slices/instances/instances' +import { AddDbType } from 'uiSrc/pages/home/constants' +import ConnectionUrl, { Props } from './ConnectionUrl' + +const mockedProps = mock() + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('ConnectionUrl', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call proper actions with empty connection url', async () => { + render() + + await act(async () => { + fireEvent.click(screen.getByTestId('btn-submit')) + }) + + expect(store.getActions()).toEqual([defaultInstanceChanging()]) + }) + + it('should disable test connection and submit buttons when connection url is invalid', async () => { + render() + + await act(async () => { + fireEvent.change( + screen.getByTestId('connection-url'), + { target: { value: 'q' } } + ) + }) + + expect(screen.getByTestId('btn-submit')).toBeDisabled() + expect(screen.getByTestId('btn-test-connection')).toBeDisabled() + }) + + it('should not disable buttons with proper connection url', async () => { + render() + + await act(async () => { + fireEvent.change( + screen.getByTestId('connection-url'), + { target: { value: 'redis://localhost:6322' } } + ) + }) + + expect(screen.getByTestId('btn-submit')).not.toBeDisabled() + expect(screen.getByTestId('btn-test-connection')).not.toBeDisabled() + }) + + it('should call proper actions after click manual settings', async () => { + const onSelectOptionMock = jest.fn() + render() + + await act(async () => { + fireEvent.change( + screen.getByTestId('connection-url'), + { target: { value: 'redis://localhost:6322' } } + ) + }) + + await act(async () => { + fireEvent.click(screen.getByTestId('btn-connection-settings')) + }) + + expect(onSelectOptionMock).toBeCalledWith( + AddDbType.manual, + { + db: undefined, + host: 'localhost', + name: 'localhost:6322', + password: undefined, + port: 6322, + tls: false, + username: 'default' + } + ) + }) + + it('should call proper actions after click connectivity option', async () => { + const onSelectOptionMock = jest.fn() + render() + + await act(async () => { + fireEvent.click(screen.getByTestId('option-btn-sentinel')) + }) + + expect(onSelectOptionMock).toBeCalledWith(AddDbType.sentinel, expect.any(Object)) + }) +}) diff --git a/redisinsight/ui/src/pages/home/components/connection-url/ConnectionUrl.tsx b/redisinsight/ui/src/pages/home/components/connection-url/ConnectionUrl.tsx new file mode 100644 index 0000000000..46d1b06e1e --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/connection-url/ConnectionUrl.tsx @@ -0,0 +1,213 @@ +import React, { useState } from 'react' +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiIcon, + EuiSpacer, + EuiTextArea, + EuiToolTip +} from '@elastic/eui' +import { useFormik } from 'formik' +import { useDispatch, useSelector } from 'react-redux' +import { useHistory } from 'react-router' +import { Nullable, parseRedisUrl } from 'uiSrc/utils' + +import { AddDbType } from 'uiSrc/pages/home/constants' +import { Instance } from 'uiSrc/slices/interfaces' +import { + createInstanceStandaloneAction, + instancesSelector, + testInstanceStandaloneAction +} from 'uiSrc/slices/instances/instances' +import { Pages } from 'uiSrc/constants' +import ConnectivityOptions from './components/connectivity-options' + +import styles from './styles.module.scss' + +export interface Props { + onSelectOption: (type: AddDbType, db: Nullable>) => void + onClose?: () => void +} + +const getPayload = (connectionUrl: string, returnOnError = false) => { + const details = parseRedisUrl(connectionUrl.trim()) + + if (!details && returnOnError) return null + + return { + name: details?.hostname || '127.0.0.1:6379', + host: details?.host || '127.0.0.1', + port: details?.port || 6379, + username: details?.username || 'default', + password: details?.password || undefined, + tls: details?.protocol === 'rediss', + db: details?.dbNumber, + } +} + +const ConnectionUrlError = ( + <> + The connection URL format provided is not supported. +
+ Try adding a database using a connection form. + +) + +const ConnectionUrl = (props: Props) => { + const { onSelectOption, onClose } = props + const [isInvalid, setIsInvalid] = useState(false) + const { loadingChanging: isLoading } = useSelector(instancesSelector) + + const dispatch = useDispatch() + const history = useHistory() + + const validate = (values: any) => { + const payload = getPayload(values.connectionURL, true) + setIsInvalid(!payload && values.connectionURL) + } + + const handleTestConnection = () => { + const payload = getPayload(formik.values.connectionURL) + dispatch(testInstanceStandaloneAction(payload as Instance)) + } + + const handleProceedForm = (type: AddDbType) => { + const details = getPayload(formik.values.connectionURL) + onSelectOption(type, details) + } + + const onSubmit = () => { + if (isInvalid) return + + const payload = getPayload(formik.values.connectionURL) + dispatch(createInstanceStandaloneAction(payload as Instance, () => { + history.push(Pages.sentinelDatabases) + })) + } + + const formik = useFormik({ + initialValues: { + connectionURL: 'redis://default@127.0.0.1:6379' + }, + validate, + enableReinitialize: true, + validateOnMount: true, + onSubmit + }) + + return ( +
+ + + + +
Connection URL
+ +
  • redis://[[username]:[password]]@host:port
  • +
  • rediss://[[username]:[password]]@host:port
  • +
  • host:port
  • + + )} + > + +
    +
    + )} + > + +
    +
    +
    + + + + {ConnectionUrlError}) : null + } + > + + Test Connection + + + + + + + handleProceedForm(AddDbType.manual)} + data-testid="btn-connection-settings" + > + Connection Settings + + + + {ConnectionUrlError}) : null + } + > + + Add Database + + + + + + +
    + +
    Or
    + + +
    + ) +} + +export default ConnectionUrl diff --git a/redisinsight/ui/src/pages/home/components/connection-url/components/connectivity-options/ConnectivityOptions.spec.tsx b/redisinsight/ui/src/pages/home/components/connection-url/components/connectivity-options/ConnectivityOptions.spec.tsx new file mode 100644 index 0000000000..576ab5f631 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/connection-url/components/connectivity-options/ConnectivityOptions.spec.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import { mock } from 'ts-mockito' +import { cloneDeep } from 'lodash' +import { cleanup, fireEvent, mockedStore, render, screen } from 'uiSrc/utils/test-utils' + +import { AddDbType } from 'uiSrc/pages/home/constants' +import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' +import { setSSOFlow } from 'uiSrc/slices/instances/cloud' +import { OAuthSocialAction, OAuthSocialSource } from 'uiSrc/slices/interfaces' +import { setSocialDialogState } from 'uiSrc/slices/oauth/cloud' +import ConnectivityOptions, { Props } from './ConnectivityOptions' + +const mockedProps = mock() + +jest.mock('uiSrc/slices/app/features', () => ({ + ...jest.requireActual('uiSrc/slices/app/features'), + appFeatureFlagsFeaturesSelector: jest.fn().mockReturnValue({ + cloudSso: { + flag: false, + } + }), +})) + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('ConnectivityOptions', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render all additional options', () => { + const onClickOption = jest.fn() + render() + + fireEvent.click(screen.getByTestId('option-btn-sentinel')) + expect(onClickOption).toBeCalledWith(AddDbType.sentinel) + + fireEvent.click(screen.getByTestId('option-btn-software')) + expect(onClickOption).toBeCalledWith(AddDbType.software) + + fireEvent.click(screen.getByTestId('option-btn-import')) + expect(onClickOption).toBeCalledWith(AddDbType.import) + + fireEvent.click(screen.getByTestId('discover-cloud-btn')) + expect(onClickOption).toBeCalledWith(AddDbType.cloud) + }) + + it('should not call any actions after click on create cloud btn', () => { + render() + + fireEvent.click(screen.getByTestId('create-free-db-btn')) + + expect(store.getActions()).toEqual([]) + }) + + it('should call proper actions after click on create cloud btn', () => { + (appFeatureFlagsFeaturesSelector as jest.Mock).mockReturnValueOnce({ + cloudSso: { + flag: true + } + }) + + const onClose = jest.fn() + render() + + fireEvent.click(screen.getByTestId('create-free-db-btn')) + + expect(store.getActions()).toEqual([ + setSSOFlow(OAuthSocialAction.Create), + setSocialDialogState(OAuthSocialSource.AddDbForm) + ]) + expect(onClose).toBeCalled() + }) +}) diff --git a/redisinsight/ui/src/pages/home/components/connection-url/components/connectivity-options/ConnectivityOptions.tsx b/redisinsight/ui/src/pages/home/components/connection-url/components/connectivity-options/ConnectivityOptions.tsx new file mode 100644 index 0000000000..c34e529b24 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/connection-url/components/connectivity-options/ConnectivityOptions.tsx @@ -0,0 +1,98 @@ +import React from 'react' +import { EuiButton, EuiFlexGrid, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui' +import cx from 'classnames' +import { AddDbType } from 'uiSrc/pages/home/constants' +import { OAuthSsoHandlerDialog } from 'uiSrc/components' +import { getUtmExternalLink } from 'uiSrc/utils/links' +import { EXTERNAL_LINKS, UTM_CAMPAINGS } from 'uiSrc/constants/links' +import { OAuthSocialAction, OAuthSocialSource } from 'uiSrc/slices/interfaces' + +import CloudIcon from 'uiSrc/assets/img/oauth/cloud_centered.svg?react' +import StarIcon from 'uiSrc/assets/img/icons/star.svg?react' + +import { CONNECTIVITY_OPTIONS } from '../../constants' + +import styles from './styles.module.scss' + +export interface Props { + onClickOption: (type: AddDbType) => void + onClose?: () => void +} + +const ConnectivityOptions = (props: Props) => { + const { onClickOption, onClose } = props + + return ( + <> +
    + + + Get started with Redis Cloud account + + + + + + onClickOption(AddDbType.cloud)} + data-testid="discover-cloud-btn" + > + Add Cloud databases + + + + + {(ssoCloudHandlerClick, isSSOEnabled) => ( + { + ssoCloudHandlerClick(e, { + source: OAuthSocialSource.AddDbForm, + action: OAuthSocialAction.Create + }) + isSSOEnabled && onClose?.() + }} + data-testid="create-free-db-btn" + > + + Create free database + + )} + + + + +
    + +
    + + More connectivity options + + + + {CONNECTIVITY_OPTIONS.map(({ id, type, title }) => ( + + onClickOption(type)} + data-testid={`option-btn-${id}`} + > + {title} + + + ))} + +
    + + ) +} + +export default ConnectivityOptions diff --git a/redisinsight/ui/src/pages/home/components/connection-url/components/connectivity-options/index.ts b/redisinsight/ui/src/pages/home/components/connection-url/components/connectivity-options/index.ts new file mode 100644 index 0000000000..3f308d9041 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/connection-url/components/connectivity-options/index.ts @@ -0,0 +1,3 @@ +import ConnectivityOptions from './ConnectivityOptions' + +export default ConnectivityOptions diff --git a/redisinsight/ui/src/pages/home/components/connection-url/components/connectivity-options/styles.module.scss b/redisinsight/ui/src/pages/home/components/connection-url/components/connectivity-options/styles.module.scss new file mode 100644 index 0000000000..20d4a68b0a --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/connection-url/components/connectivity-options/styles.module.scss @@ -0,0 +1,30 @@ +.sectionTitle { + font-weight: 400 !important; + display: flex; + align-items: center; + + .cloudIcon { + margin-right: 8px; + } +} + +.typeBtn { + width: 100%; + height: 76px !important; + + border-color: var(--separatorColorLight) !important; + color: var(--buttonSecondaryTextColor) !important; + box-shadow: none !important; + + &.primary { + border-color: var(--euiColorSecondary) !important; + } + + &:hover { + background-color: transparent !important; + } +} + +.star { + margin-right: 4px; +} diff --git a/redisinsight/ui/src/pages/home/components/connection-url/constants.ts b/redisinsight/ui/src/pages/home/components/connection-url/constants.ts new file mode 100644 index 0000000000..6aea20bf59 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/connection-url/constants.ts @@ -0,0 +1,23 @@ +import { AddDbType } from 'uiSrc/pages/home/constants' + +export interface Values { + connectionURL?: string +} + +export const CONNECTIVITY_OPTIONS = [ + { + id: 'sentinel', + title: 'Redis Sentinel', + type: AddDbType.sentinel + }, + { + id: 'software', + title: 'Redis Software', + type: AddDbType.software + }, + { + id: 'import', + title: 'Import from file', + type: AddDbType.import + } +] diff --git a/redisinsight/ui/src/pages/home/components/connection-url/index.ts b/redisinsight/ui/src/pages/home/components/connection-url/index.ts new file mode 100644 index 0000000000..cae92e1879 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/connection-url/index.ts @@ -0,0 +1,3 @@ +import ConnectionUrl from './ConnectionUrl' + +export default ConnectionUrl diff --git a/redisinsight/ui/src/pages/home/components/connection-url/styles.module.scss b/redisinsight/ui/src/pages/home/components/connection-url/styles.module.scss new file mode 100644 index 0000000000..bf69c0c02a --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/connection-url/styles.module.scss @@ -0,0 +1,34 @@ +.hr { + margin: 12px 0; + width: 100%; + text-align: center; + position: relative; + color: var(--euiTextSubduedColor); + + &:before, &:after { + content: ''; + display: block; + width: 45%; + height: 1px; + background: var(--controlsBorderColor); + position: absolute; + top: 50%; + } + + &:before { + left: 0; + } + + &:after { + right: 0; + } +} + +.connectionUrlInfo { + display: flex; + align-items: center; + + > :global(.euiToolTipAnchor) { + margin-left: 4px; + } +} diff --git a/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.spec.tsx index 902e7107ba..ad1b4b989b 100644 --- a/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.spec.tsx @@ -3,12 +3,12 @@ import React from 'react' import { instance, mock } from 'ts-mockito' import ItemList, { Props as ItemListProps } from 'uiSrc/components/item-list/ItemList' -import { errorHandlers } from 'uiSrc/mocks/res/responseComposition' -import { mswServer } from 'uiSrc/mocks/server' import { ConnectionType, Instance, RedisCloudSubscriptionType } from 'uiSrc/slices/interfaces' import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' import { act, fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import { mswServer } from 'uiSrc/mocks/server' +import { errorHandlers } from 'uiSrc/mocks/res/responseComposition' import DatabasesListWrapper, { Props } from './DatabasesListWrapper' const mockedProps = mock() @@ -121,25 +121,11 @@ describe('DatabasesListWrapper', () => { (ItemList as jest.Mock).mockImplementation(mockDatabasesList) }) - it('should render', () => { - expect(render()).toBeTruthy() - }) - - it('should show indicator for a new connection', () => { - const { queryByTestId } = render() - - const dbIdWithNewIndicator = mockInstances.find(({ new: newState }) => newState)?.id - const dbIdWithoutNewIndicator = mockInstances.find(({ new: newState }) => !newState)?.id - - expect(queryByTestId(`database-status-new-${dbIdWithNewIndicator}`)).toBeInTheDocument() - expect(queryByTestId(`database-status-new-${dbIdWithoutNewIndicator}`)).not.toBeInTheDocument() - }) - it('should call proper telemetry on success export', async () => { const sendEventTelemetryMock = jest.fn(); (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) - render() + render() await act(() => { fireEvent.click(screen.getByTestId('onExport-btn')) @@ -160,7 +146,7 @@ describe('DatabasesListWrapper', () => { const sendEventTelemetryMock = jest.fn(); (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) - render() + render() await act(() => { fireEvent.click(screen.getByTestId('onExport-btn')) @@ -176,53 +162,11 @@ describe('DatabasesListWrapper', () => { (sendEventTelemetry as jest.Mock).mockRestore() }) - it('should call proper telemetry on delete multiple databases', async () => { - const sendEventTelemetryMock = jest.fn(); - - (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) - render() - - await act(() => { - fireEvent.click(screen.getByTestId('onDelete-btn')) - }) - - expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.CONFIG_DATABASES_MULTIPLE_DATABASES_DELETE_CLICKED, - eventData: { - ids: ['a0db1bc8-a353-4c43-a856-b72f4811d2d4'] - } - }); - - (sendEventTelemetry as jest.Mock).mockRestore() - }) - - it('should show link to cloud console', () => { - render() - - expect(screen.queryByTestId(`cloud-link-${mockInstances[0].id}`)).not.toBeInTheDocument() - expect(screen.getByTestId(`cloud-link-${mockInstances[1].id}`)).toBeInTheDocument() - }) - - it('should call proper telemetry on click cloud console link', () => { - const sendEventTelemetryMock = jest.fn(); - - (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) - render() - - fireEvent.click(screen.getByTestId(`cloud-link-${mockInstances[1].id}`)) - - expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.CLOUD_LINK_CLICKED - }); - - (sendEventTelemetry as jest.Mock).mockRestore() - }) - it('should call proper telemetry on copy host:port', async () => { const sendEventTelemetryMock = jest.fn(); (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) - render() + render() await act(() => { const copyHostPortButtons = screen.getAllByLabelText(/Copy host:port/i) @@ -239,104 +183,41 @@ describe('DatabasesListWrapper', () => { (sendEventTelemetry as jest.Mock).mockRestore() }) - it('should call proper telemetry on open database', async () => { - const sendEventTelemetryMock = jest.fn(); - - (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) - render() - - await act(() => { - fireEvent.click(screen.getByTestId('instance-name-e37cc441-a4f2-402c-8bdb-fc2413cbbaff')) - }) - - expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.CONFIG_DATABASES_OPEN_DATABASE, - eventData: { - databaseId: 'e37cc441-a4f2-402c-8bdb-fc2413cbbaff', - provider: 'provider', - RediSearch: { - loaded: false - }, - RedisAI: { - loaded: false - }, - RedisBloom: { - loaded: false - }, - RedisGears: { - loaded: false - }, - RedisGraph: { - loaded: false - }, - RedisJSON: { - loaded: false - }, - RedisTimeSeries: { - loaded: false - }, - customModules: [] - } - }); - - (sendEventTelemetry as jest.Mock).mockRestore() - }) - - it('should call proper telemetry on delete database', async () => { + it('should call proper telemetry on list sort', async () => { const sendEventTelemetryMock = jest.fn(); (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) - render() + render( {}} />) await act(() => { - fireEvent.click(screen.getByTestId('delete-instance-a0db1bc8-a353-4c43-a856-b72f4811d2d4-icon')) + fireEvent.click(screen.getByTestId('onTableChange-btn')) }) expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.CONFIG_DATABASES_SINGLE_DATABASE_DELETE_CLICKED, - eventData: { - databaseId: 'a0db1bc8-a353-4c43-a856-b72f4811d2d4' - } + event: TelemetryEvent.CONFIG_DATABASES_DATABASE_LIST_SORTED, + eventData: { field: 'name', direction: 'asc' } }); (sendEventTelemetry as jest.Mock).mockRestore() }) - it('should call proper telemetry on edit database', async () => { + it('should call proper telemetry on delete multiple databases', async () => { const sendEventTelemetryMock = jest.fn(); (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) - render( {}} />) + render() await act(() => { - fireEvent.click(screen.getByTestId('edit-instance-a0db1bc8-a353-4c43-a856-b72f4811d2d4')) + fireEvent.click(screen.getByTestId('onDelete-btn')) }) expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.CONFIG_DATABASES_DATABASE_EDIT_CLICKED, + event: TelemetryEvent.CONFIG_DATABASES_MULTIPLE_DATABASES_DELETE_CLICKED, eventData: { - databaseId: 'a0db1bc8-a353-4c43-a856-b72f4811d2d4' + ids: ['a0db1bc8-a353-4c43-a856-b72f4811d2d4'] } }); (sendEventTelemetry as jest.Mock).mockRestore() }) - - it('should call proper telemetry on list sort', async () => { - const sendEventTelemetryMock = jest.fn(); - - (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) - render( {}} />) - - await act(() => { - fireEvent.click(screen.getByTestId('onTableChange-btn')) - }) - - expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.CONFIG_DATABASES_DATABASE_LIST_SORTED, - eventData: { field: 'name', direction: 'asc' } - }); - - (sendEventTelemetry as jest.Mock).mockRestore() - }) }) diff --git a/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.test.tsx b/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.test.tsx new file mode 100644 index 0000000000..a3f5e0b93c --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.test.tsx @@ -0,0 +1,231 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' + +import { cloneDeep } from 'lodash' +import { + ConnectionType, + Instance, + OAuthSocialAction, + OAuthSocialSource, + RedisCloudSubscriptionType +} from 'uiSrc/slices/interfaces' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { act, cleanup, fireEvent, mockedStore, render, screen } from 'uiSrc/utils/test-utils' + +import { CREATE_CLOUD_DB_ID } from 'uiSrc/pages/home/constants' +import { setSSOFlow } from 'uiSrc/slices/instances/cloud' +import { setSocialDialogState } from 'uiSrc/slices/oauth/cloud' +import DatabasesListWrapper, { Props } from './DatabasesListWrapper' + +const mockedProps = mock() + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn() +})) + +jest.mock('file-saver', () => ({ + saveAs: jest.fn() +})) + +jest.mock('uiSrc/slices/app/features', () => ({ + ...jest.requireActual('uiSrc/slices/app/features'), + appFeatureFlagsFeaturesSelector: jest.fn().mockReturnValue({ + cloudSso: { + flag: true + } + }), +})) + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +const mockInstances: Instance[] = [ + { + id: 'e37cc441-a4f2-402c-8bdb-fc2413cbbaff', + host: 'localhost', + port: 6379, + name: 'localhost', + username: null, + password: null, + connectionType: ConnectionType.Standalone, + nameFromProvider: null, + new: true, + modules: [], + version: null, + lastConnection: new Date('2021-04-22T09:03:56.917Z'), + provider: 'provider' + }, + { + id: 'a0db1bc8-a353-4c43-a856-b72f4811d2d4', + host: 'localhost', + port: 12000, + name: 'oea123123', + username: null, + password: null, + connectionType: ConnectionType.Standalone, + nameFromProvider: null, + tls: true, + modules: [], + version: null, + cloudDetails: { + cloudId: 1, + subscriptionType: RedisCloudSubscriptionType.Fixed + } + } +] + +/** + * DatabasesListWrapper tests + * + * @group component + */ +describe('DatabasesListWrapper', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should show indicator for a new connection', () => { + const { queryByTestId } = render() + + const dbIdWithNewIndicator = mockInstances.find(({ new: newState }) => newState)?.id + const dbIdWithoutNewIndicator = mockInstances.find(({ new: newState }) => !newState)?.id + + expect(queryByTestId(`database-status-new-${dbIdWithNewIndicator}`)).toBeInTheDocument() + expect(queryByTestId(`database-status-new-${dbIdWithoutNewIndicator}`)).not.toBeInTheDocument() + }) + + it('should render create free cloud row', () => { + render() + + expect(screen.getByTestId(`db-row_${CREATE_CLOUD_DB_ID}`)).toBeInTheDocument() + }) + + it('should call proper action on click cloud db', () => { + render() + + fireEvent.click(screen.getByTestId(`db-row_${CREATE_CLOUD_DB_ID}`)) + + expect(store.getActions()).toEqual([ + setSSOFlow(OAuthSocialAction.Create), + setSocialDialogState(OAuthSocialSource.DatabaseConnectionList) + ]) + }) + + it('should show link to cloud console', () => { + render() + + expect(screen.queryByTestId(`cloud-link-${mockInstances[0].id}`)).not.toBeInTheDocument() + expect(screen.getByTestId(`cloud-link-${mockInstances[1].id}`)).toBeInTheDocument() + }) + + it('should call proper telemetry on click cloud console link', () => { + const sendEventTelemetryMock = jest.fn(); + + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + render() + + fireEvent.click(screen.getByTestId(`cloud-link-${mockInstances[1].id}`)) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CLOUD_LINK_CLICKED + }); + + (sendEventTelemetry as jest.Mock).mockRestore() + }) + + it('should call proper telemetry on open database', async () => { + const sendEventTelemetryMock = jest.fn(); + + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + render() + + await act(() => { + fireEvent.click(screen.getByTestId('instance-name-e37cc441-a4f2-402c-8bdb-fc2413cbbaff')) + }) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CONFIG_DATABASES_OPEN_DATABASE, + eventData: { + databaseId: 'e37cc441-a4f2-402c-8bdb-fc2413cbbaff', + provider: 'provider', + RediSearch: { + loaded: false + }, + RedisAI: { + loaded: false + }, + RedisBloom: { + loaded: false + }, + RedisGears: { + loaded: false + }, + RedisGraph: { + loaded: false + }, + RedisJSON: { + loaded: false + }, + RedisTimeSeries: { + loaded: false + }, + customModules: [] + } + }); + + (sendEventTelemetry as jest.Mock).mockRestore() + }) + + it('should call proper telemetry on delete database', async () => { + const sendEventTelemetryMock = jest.fn(); + + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + render() + + await act(() => { + fireEvent.click(screen.getByTestId('delete-instance-a0db1bc8-a353-4c43-a856-b72f4811d2d4-icon')) + }) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CONFIG_DATABASES_SINGLE_DATABASE_DELETE_CLICKED, + eventData: { + databaseId: 'a0db1bc8-a353-4c43-a856-b72f4811d2d4' + } + }); + + (sendEventTelemetry as jest.Mock).mockRestore() + }) + + it('should call proper telemetry on edit database', async () => { + const sendEventTelemetryMock = jest.fn(); + + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + render( {}} />) + + await act(() => { + fireEvent.click(screen.getByTestId('edit-instance-a0db1bc8-a353-4c43-a856-b72f4811d2d4')) + }) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CONFIG_DATABASES_DATABASE_EDIT_CLICKED, + eventData: { + databaseId: 'a0db1bc8-a353-4c43-a856-b72f4811d2d4' + } + }); + + (sendEventTelemetry as jest.Mock).mockRestore() + }) +}) diff --git a/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx b/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx index efda01bd42..f7c7111cdd 100644 --- a/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx @@ -3,6 +3,7 @@ import { EuiButtonIcon, EuiIcon, EuiLink, + EuiResizeObserver, EuiTableFieldDataColumnType, EuiText, EuiTextColor, @@ -24,7 +25,7 @@ import RediStackLightLogo from 'uiSrc/assets/img/modules/redistack/RedisStackLog import CloudLinkIcon from 'uiSrc/assets/img/oauth/cloud_link.svg?react' import DatabaseListModules from 'uiSrc/components/database-list-modules/DatabaseListModules' import ItemList from 'uiSrc/components/item-list' -import { BrowserStorageItem, Pages, Theme } from 'uiSrc/constants' +import { BrowserStorageItem, FeatureFlags, Pages, Theme } from 'uiSrc/constants' import { EXTERNAL_LINKS } from 'uiSrc/constants/links' import { ThemeContext } from 'uiSrc/contexts/themeContext' import PopoverDelete from 'uiSrc/pages/browser/components/popover-delete/PopoverDelete' @@ -37,17 +38,32 @@ import { checkConnectToInstanceAction, deleteInstancesAction, exportInstancesAction, - instancesSelector, setConnectedInstanceId, } from 'uiSrc/slices/instances/instances' -import { CONNECTION_TYPE_DISPLAY, ConnectionType, Instance } from 'uiSrc/slices/interfaces' -import { TelemetryEvent, getRedisModulesSummary, sendEventTelemetry } from 'uiSrc/telemetry' -import { Nullable, formatLongName, getDbIndex, lastConnectionFormat, replaceSpaces } from 'uiSrc/utils' +import { + CONNECTION_TYPE_DISPLAY, + ConnectionType, + Instance, + OAuthSocialAction, + OAuthSocialSource +} from 'uiSrc/slices/interfaces' +import { getRedisModulesSummary, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { formatLongName, getDbIndex, lastConnectionFormat, Nullable, replaceSpaces } from 'uiSrc/utils' + +import { setSSOFlow } from 'uiSrc/slices/instances/cloud' +import { setSocialDialogState } from 'uiSrc/slices/oauth/cloud' +import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' +import { getUtmExternalLink } from 'uiSrc/utils/links' +import { CREATE_CLOUD_DB_ID, HELP_LINKS } from 'uiSrc/pages/home/constants' + +import DbStatus from '../db-status' import styles from './styles.module.scss' export interface Props { - width: number + instances: Instance[] + predefinedInstances?: Instance[] + loading: boolean editedInstance: Nullable onEditInstance: (instance: Instance) => void onDeleteInstances: (instances: Instance[]) => void @@ -55,16 +71,33 @@ export interface Props { const suffix = '_db_instance' const COLS_TO_HIDE = ['connectionType', 'modules', 'lastConnection'] +const isCreateCloudDb = (id?: string) => id === CREATE_CLOUD_DB_ID -const DatabasesListWrapper = ({ width, onEditInstance, editedInstance, onDeleteInstances }: Props) => { +const DatabasesListWrapper = (props: Props) => { + const { + instances, + predefinedInstances = [], + onEditInstance, + editedInstance, + onDeleteInstances, + loading + } = props const dispatch = useDispatch() const history = useHistory() const { search } = useLocation() const { theme } = useContext(ThemeContext) const { contextInstanceId } = useSelector(appContextSelector) - const instances = useSelector(instancesSelector) + const { [FeatureFlags.cloudSso]: cloudSsoFeature } = useSelector(appFeatureFlagsFeaturesSelector) + + const [width, setWidth] = useState(0) const [, forceRerender] = useState({}) + const sortingRef = useRef( + localStorageService.get(BrowserStorageItem.instancesSorting) ?? { + field: 'lastConnection', + direction: 'asc' + } + ) const deletingIdRef = useRef('') @@ -82,8 +115,8 @@ const DatabasesListWrapper = ({ width, onEditInstance, editedInstance, onDeleteI useEffect(() => { const editInstanceId = new URLSearchParams(search).get('editInstance') - if (editInstanceId && instances?.data?.length) { - const instance = instances.data.find((item: Instance) => item.id === editInstanceId) + if (editInstanceId && instances?.length) { + const instance = instances.find((item: Instance) => item.id === editInstanceId) if (instance) { handleClickEditInstance(instance) history.replace(Pages.home) @@ -91,10 +124,6 @@ const DatabasesListWrapper = ({ width, onEditInstance, editedInstance, onDeleteI } }, [instances, search]) - useEffect(() => { - closePopover() - }, [width]) - const handleCopy = (text = '', databaseId?: string) => { navigator.clipboard?.writeText(text) sendEventTelemetry({ @@ -205,12 +234,50 @@ const DatabasesListWrapper = ({ width, onEditInstance, editedInstance, onDeleteI ) } + const onResize = ({ width: innerWidth }: { width: number }) => { + setWidth(innerWidth) + } + const handleClickGoToCloud = () => { sendEventTelemetry({ event: TelemetryEvent.CLOUD_LINK_CLICKED, }) } + const handleClickFreeDb = () => { + if (cloudSsoFeature?.flag) { + dispatch(setSSOFlow(OAuthSocialAction.Create)) + dispatch(setSocialDialogState(OAuthSocialSource.DatabaseConnectionList)) + sendEventTelemetry({ + event: TelemetryEvent.CLOUD_FREE_DATABASE_CLICKED, + eventData: { source: OAuthSocialSource.DatabaseConnectionList }, + }) + return + } + + sendEventTelemetry({ + event: HELP_LINKS.cloud.event, + eventData: { source: HELP_LINKS.cloud.sources.databaseConnectionList }, + }) + + const link = document.createElement('a') + link.setAttribute('href', getUtmExternalLink(EXTERNAL_LINKS.tryFree, { campaign: 'list_of_databases' })) + link.setAttribute('target', '_blank') + + link.click() + link.remove() + } + + const getRowProps = (instance: Instance) => ({ + className: cx({ + 'euiTableRow-isSelected': instance?.id === editedInstance?.id, + cloudDbRow: isCreateCloudDb(instance?.id) + }), + onClick: isCreateCloudDb(instance?.id) ? handleClickFreeDb : undefined, + isSelectable: !isCreateCloudDb(instance?.id), + 'data-testid': `db-row_${instance?.id}` + }) + const columns: EuiTableFieldDataColumnType[] = [ { field: 'name', @@ -219,19 +286,30 @@ const DatabasesListWrapper = ({ width, onEditInstance, editedInstance, onDeleteI dataType: 'string', truncateText: true, 'data-test-subj': 'database-alias-column', - sortable: ({ name }) => name?.toLowerCase(), + sortable: ({ name, id }) => { + if (isCreateCloudDb(id)) return sortingRef.current.direction === 'asc' ? '' : false + return name?.toLowerCase() + }, width: '30%', render: function InstanceCell(name: string = '', instance: Instance) { - const { id, db, new: newStatus = false } = instance + if (isCreateCloudDb(instance.id)) { + return ( + {instance.name} + ) + } + + const { id, db, new: newStatus = false, lastConnection, createdAt, cloudDetails } = instance const cellContent = replaceSpaces(name.substring(0, 200)) return (
    - {newStatus && ( - -
    - - )} + `${host}:${port}`, - render: function HostPort(name: string, { port, id }: Instance) { + sortable: ({ host, port, id }) => { + if (isCreateCloudDb(id)) return sortingRef.current.direction === 'asc' ? '' : false + return `${host}:${port}` + }, + render: function HostPort(name: string, { host, port, id }: Instance) { + if (isCreateCloudDb(id)) return host + const text = `${name}:${port}` return (
    @@ -284,7 +367,10 @@ const DatabasesListWrapper = ({ width, onEditInstance, editedInstance, onDeleteI className: 'column_type', name: 'Connection Type', dataType: 'string', - sortable: true, + sortable: ({ id, connectionType }) => { + if (isCreateCloudDb(id)) return sortingRef.current.direction === 'asc' ? '' : false + return connectionType + }, width: '180px', truncateText: true, hideForMobile: true, @@ -340,8 +426,14 @@ const DatabasesListWrapper = ({ width, onEditInstance, editedInstance, onDeleteI dataType: 'date', align: 'right', width: '170px', - sortable: ({ lastConnection }) => (lastConnection ? -new Date(`${lastConnection}`) : -Infinity), - render: (date: Date) => lastConnectionFormat(date), + sortable: ({ lastConnection, id }) => { + if (isCreateCloudDb(id)) return sortingRef.current.direction === 'asc' ? -Infinity : +Infinity + return (lastConnection ? -new Date(`${lastConnection}`) : -Infinity) + }, + render: (date: Date, { id }) => { + if (id === CREATE_CLOUD_DB_ID) return null + return lastConnectionFormat(date) + }, }, { field: 'controls', @@ -349,6 +441,7 @@ const DatabasesListWrapper = ({ width, onEditInstance, editedInstance, onDeleteI width: '120px', name: '', render: function Actions(_act: any, instance: Instance) { + if (isCreateCloudDb(instance?.id)) return null return ( <> {instance.cloudDetails && ( @@ -393,6 +486,7 @@ const DatabasesListWrapper = ({ width, onEditInstance, editedInstance, onDeleteI const onTableChange = ({ sort, page }: Criteria) => { // calls also with page changing if (sort && !page) { + sortingRef.current = sort localStorageService.set(BrowserStorageItem.instancesSorting, sort) sendEventTelemetry({ event: TelemetryEvent.CONFIG_DATABASES_DATABASE_LIST_SORTED, @@ -401,27 +495,32 @@ const DatabasesListWrapper = ({ width, onEditInstance, editedInstance, onDeleteI } } - const sort: PropertySort = localStorageService.get(BrowserStorageItem.instancesSorting) ?? { - field: 'lastConnection', - direction: 'asc' - } + const listOfInstances = [ + ...predefinedInstances, + ...instances + ] return ( -
    - - width={width} - editedInstance={editedInstance} - columns={columns} - columnsToHide={COLS_TO_HIDE} - onDelete={handleDeleteInstances} - onExport={handleExportInstances} - onWheel={closePopover} - loading={instances.loading} - data={instances.data} - onTableChange={onTableChange} - sort={sort} - /> -
    + + {(resizeRef) => ( +
    + + width={width} + columns={columns} + columnsToHide={COLS_TO_HIDE} + onDelete={handleDeleteInstances} + onExport={handleExportInstances} + onWheel={closePopover} + loading={loading} + data={listOfInstances} + rowProps={getRowProps} + getSelectableItems={(item) => item.id !== 'create-free-cloud-db'} + onTableChange={onTableChange} + sort={sortingRef.current} + /> +
    + )} +
    ) } diff --git a/redisinsight/ui/src/pages/home/components/database-list-component/styles.module.scss b/redisinsight/ui/src/pages/home/components/database-list-component/styles.module.scss index 0952e5c66e..5811ee8358 100644 --- a/redisinsight/ui/src/pages/home/components/database-list-component/styles.module.scss +++ b/redisinsight/ui/src/pages/home/components/database-list-component/styles.module.scss @@ -76,21 +76,6 @@ $breakpoint-l: 1400px; } } -.newStatus { - background-color: var(--euiColorPrimary) !important; - cursor: pointer; - width: 11px !important; - min-width: 11px !important; - height: 11px !important; - border-radius: 6px; -} - -.newStatusAnchor { - margin-top: 20px; - margin-left: -19px; - position: absolute; -} - .container { // Database alias column height: 100%; @@ -98,6 +83,19 @@ $breakpoint-l: 1400px; tr > td:nth-child(2) { padding-left: 5px !important; } + + :global(.euiTableRow.cloudDbRow) { + cursor: pointer; + + :global(.euiTableRowCellCheckbox) { + background-image: url("uiSrc/assets/img/icons/star.svg"); + background-repeat: no-repeat; + background-position: center; + } + :global(.euiTableRowCellCheckbox .euiCheckbox) { + visibility: hidden; + } + } } .cloudIcon { @@ -107,9 +105,3 @@ $breakpoint-l: 1400px; } } } - -.table { - :global(.euiTableRow) { - border-color: var(--euiColorLightShade) !important; - } -} diff --git a/redisinsight/ui/src/pages/home/components/database-list-header/DatabaseListHeader.spec.tsx b/redisinsight/ui/src/pages/home/components/database-list-header/DatabaseListHeader.spec.tsx index 74c0068118..3bb3b81ea1 100644 --- a/redisinsight/ui/src/pages/home/components/database-list-header/DatabaseListHeader.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/database-list-header/DatabaseListHeader.spec.tsx @@ -1,29 +1,36 @@ -import { within } from '@testing-library/react' import React from 'react' import { instance, mock } from 'ts-mockito' -import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' -import { MOCK_EXPLORE_GUIDES } from 'uiSrc/constants/mocks/mock-explore-guides' +import { render, screen } from 'uiSrc/utils/test-utils' +import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' import DatabaseListHeader, { Props } from './DatabaseListHeader' const mockedProps = mock() -jest.mock('uiSrc/slices/content/create-redis-buttons', () => { - const defaultState = jest.requireActual('uiSrc/slices/content/create-redis-buttons').initialState - return { - contentSelector: () => jest.fn().mockReturnValue({ - ...defaultState, - loading: false, - data: { cloud: { title: 'Limited offer', description: 'Try Redis Cloud' } } - }), - } -}) +jest.mock('uiSrc/slices/app/features', () => ({ + ...jest.requireActual('uiSrc/slices/app/features'), + appFeatureFlagsFeaturesSelector: jest.fn().mockReturnValue({ + enhancedCloudUI: { + flag: false + } + }), +})) -jest.mock('uiSrc/slices/content/guide-links', () => ({ - ...jest.requireActual('uiSrc/slices/content/guide-links'), - guideLinksSelector: jest.fn().mockReturnValue({ - data: MOCK_EXPLORE_GUIDES - }) +jest.mock('uiSrc/slices/content/create-redis-buttons', () => ({ + ...jest.requireActual('uiSrc/slices/content/create-redis-buttons'), + contentSelector: jest.fn().mockReturnValue({ + data: { + cloud: { + title: 'Try Redis Cloud: your ultimate Redis starting point', + description: 'Includes native support for JSON, Search and Query, and more', + links: { + main: { + altText: 'Try Redis Cloud.', + url: 'https://redis.io/try-free/?utm_source=redisinsight&utm_medium=main&utm_campaign=main' + } + }, + } + } + }), })) jest.mock('uiSrc/telemetry', () => ({ @@ -35,34 +42,28 @@ describe('DatabaseListHeader', () => { it('should render', () => { expect(render()).toBeTruthy() }) - it('should open import dbs dialog', () => { - render() - - fireEvent.click(screen.getByTestId('import-from-file-btn')) - expect(screen.getByTestId('import-file-modal')).toBeInTheDocument() - }) - - it('should call proper telemetry on open and close import databases dialog', () => { - const sendEventTelemetryMock = jest.fn(); - (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + it('should not show promo cloud button with disabled feature flag', () => { + (appFeatureFlagsFeaturesSelector as jest.Mock).mockReturnValueOnce({ + enhancedCloudUI: { + flag: true + } + }) render() - fireEvent.click(screen.getByTestId('import-from-file-btn')) - - expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.CONFIG_DATABASES_REDIS_IMPORT_CLICKED - }); - - (sendEventTelemetry as jest.Mock).mockRestore() + expect(screen.queryByTestId('promo-btn')).not.toBeInTheDocument() + }) - fireEvent.click(within(screen.getByTestId('import-file-modal')).getByTestId('cancel-btn')) + it('should show promo cloud button with enabled feature flag', () => { + (appFeatureFlagsFeaturesSelector as jest.Mock).mockReturnValueOnce({ + enhancedCloudUI: { + flag: false + } + }) - expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.CONFIG_DATABASES_REDIS_IMPORT_CANCELLED - }); + render() - (sendEventTelemetry as jest.Mock).mockRestore() + expect(screen.getByTestId('promo-btn')).toBeInTheDocument() }) }) diff --git a/redisinsight/ui/src/pages/home/components/database-list-header/DatabaseListHeader.tsx b/redisinsight/ui/src/pages/home/components/database-list-header/DatabaseListHeader.tsx index d99e9670f2..870139d579 100644 --- a/redisinsight/ui/src/pages/home/components/database-list-header/DatabaseListHeader.tsx +++ b/redisinsight/ui/src/pages/home/components/database-list-header/DatabaseListHeader.tsx @@ -3,25 +3,25 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem, - EuiIcon, EuiSpacer, - EuiToolTip, } from '@elastic/eui' -import { isEmpty } from 'lodash' import { useSelector } from 'react-redux' +import { isEmpty } from 'lodash' import cx from 'classnames' -import { ImportDatabasesDialog, OAuthSsoHandlerDialog } from 'uiSrc/components' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { instancesSelector } from 'uiSrc/slices/instances/instances' +import { OAuthSocialAction, OAuthSocialSource } from 'uiSrc/slices/interfaces' import PromoLink from 'uiSrc/components/promo-link/PromoLink' -import { ThemeContext } from 'uiSrc/contexts/themeContext' -import { contentSelector } from 'uiSrc/slices/content/create-redis-buttons' -import { HELP_LINKS, IHelpGuide } from 'uiSrc/pages/home/constants/help-links' + +import { OAuthSsoHandlerDialog } from 'uiSrc/components' import { getPathToResource } from 'uiSrc/services/resourcesService' import { ContentCreateRedis } from 'uiSrc/slices/interfaces/content' -import { instancesSelector } from 'uiSrc/slices/instances/instances' -import { OAuthSocialAction, OAuthSocialSource } from 'uiSrc/slices/interfaces' -import { getContentByFeature } from 'uiSrc/utils/content' +import { HELP_LINKS } from 'uiSrc/pages/home/constants' +import { contentSelector } from 'uiSrc/slices/content/create-redis-buttons' import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' +import { getContentByFeature } from 'uiSrc/utils/content' +import { ThemeContext } from 'uiSrc/contexts/themeContext' +import { FeatureFlags } from 'uiSrc/constants' import SearchDatabasesList from '../search-databases-list' import styles from './styles.module.scss' @@ -31,13 +31,15 @@ export interface Props { } const DatabaseListHeader = ({ onAddInstance }: Props) => { - const { theme } = useContext(ThemeContext) const { data: instances } = useSelector(instancesSelector) const featureFlags = useSelector(appFeatureFlagsFeaturesSelector) const { loading, data } = useSelector(contentSelector) const [promoData, setPromoData] = useState() - const [isImportDialogOpen, setIsImportDialogOpen] = useState(false) + + const { theme } = useContext(ThemeContext) + const { [FeatureFlags.enhancedCloudUI]: enhancedCloudUIFeature } = featureFlags + const isShowPromoBtn = !enhancedCloudUIFeature?.flag useEffect(() => { if (loading || !data || isEmpty(data)) { @@ -70,20 +72,6 @@ const DatabaseListHeader = ({ onAddInstance }: Props) => { } } - const handleClickImportDbBtn = () => { - setIsImportDialogOpen(true) - sendEventTelemetry({ - event: TelemetryEvent.CONFIG_DATABASES_REDIS_IMPORT_CLICKED, - }) - } - - const handleCloseImportDb = (isCancelled: boolean) => { - setIsImportDialogOpen(false) - isCancelled && sendEventTelemetry({ - event: TelemetryEvent.CONFIG_DATABASES_REDIS_IMPORT_CANCELLED, - }) - } - const handleCreateDatabaseClick = ( event: TelemetryEvent, eventData: any = {}, @@ -92,37 +80,20 @@ const DatabaseListHeader = ({ onAddInstance }: Props) => { } const AddInstanceBtn = () => ( - <> - - + DATABASE - + Add Redis database - - - ) - - const ImportDatabasesBtn = () => ( - - - - - + + Add Redis database + ) const CreateBtn = ({ content }: { content: ContentCreateRedis }) => { + if (!isShowPromoBtn) return null + const { title, description, styles: stylesCss, links } = content // @ts-ignore const linkStyles = stylesCss ? stylesCss[theme] : {} @@ -155,36 +126,30 @@ const DatabaseListHeader = ({ onAddInstance }: Props) => { } return ( - <> - {isImportDialogOpen && } -
    - - - - - - - - {!loading && !isEmpty(data) && ( - - - {promoData && ( +
    + + + + + {!loading && !isEmpty(data) && ( + + + {promoData && ( - )} - - - )} - {instances.length > 0 && ( - - - - )} - - -
    - + )} +
    +
    + )} + {instances.length > 0 && ( + + + + )} +
    + +
    ) } diff --git a/redisinsight/ui/src/pages/home/components/database-list-header/styles.module.scss b/redisinsight/ui/src/pages/home/components/database-list-header/styles.module.scss index 10989cdd93..8376968371 100644 --- a/redisinsight/ui/src/pages/home/components/database-list-header/styles.module.scss +++ b/redisinsight/ui/src/pages/home/components/database-list-header/styles.module.scss @@ -1,67 +1,23 @@ .addInstanceBtn { - padding-left: 10px; - padding-right: 10px; + padding-left: 0; + padding-right: 0; height: 43px; margin: 0 auto; text-decoration: none !important; - :global(.euiButton__text) { - font-weight: 500 !important; + &:global(.euiButton) { + min-width: 54px !important; } -} - -.importDatabasesBtn:global(.euiButton) { - height: 43px; - min-width: auto !important; :global(.euiButton__text) { - display: flex; - align-items: center; + font-weight: 500 !important; } } -.followText { - padding-top: 4px; - font-size: 12px !important; - text-align: left; - color: var(--euiTextColor) !important; - opacity: 0.7; -} - .clearMarginFlexItem { margin-bottom: 0 !important; } -.links { - font-size: 14px; - a { - color: var(--euiTextColor); - text-decoration: underline; - - &:hover { - text-decoration: none; - } - } -} - -.containerDl { - .links { - margin-left: 6px; - position: relative; - min-width: 235px; - top: -5px; - - :global(.euiFlexItem) { - margin-left: 0px !important; - margin-right: 12px !important; - - &:last-of-type { - margin-right: 0 !important; - } - } - } -} - .contentDL { @include eui.euiBreakpoint("xs", "s") { & > div:first-of-type { @@ -78,7 +34,7 @@ height: 6px !important; } @include eui.euiBreakpoint("m", "l", "xl") { - height: 18px !important; + height: 12px !important; } } diff --git a/redisinsight/ui/src/pages/home/components/database-panel-dialog/DatabasePanelDialog.spec.tsx b/redisinsight/ui/src/pages/home/components/database-panel-dialog/DatabasePanelDialog.spec.tsx new file mode 100644 index 0000000000..270359e8d5 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/database-panel-dialog/DatabasePanelDialog.spec.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' + +import DatabasePanelDialog, { Props } from './DatabasePanelDialog' + +const mockedProps = mock() + +describe('DatabasePanelDialog', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render proper form by dfeault', () => { + render() + + expect(screen.getByTestId('connection-url')).toBeInTheDocument() + }) + + it('should change screen to cloud and render proper form', () => { + render() + + fireEvent.click(screen.getByTestId('discover-cloud-btn')) + + expect(screen.getByTestId('add-db_cloud-api')).toBeInTheDocument() + }) + + it('should change screen to software and render proper form', () => { + render() + + fireEvent.click(screen.getByTestId('option-btn-software')) + + expect(screen.getByTestId('add-db_cluster')).toBeInTheDocument() + }) + + it('should change tab to sentinel and render proper form', async () => { + render() + + fireEvent.click(screen.getByTestId('option-btn-sentinel')) + + expect(screen.getByTestId('add-db_sentinel')).toBeInTheDocument() + }) + + it('should change screen to import render proper form', async () => { + render() + + fireEvent.click(screen.getByTestId('option-btn-import')) + + expect(screen.getByTestId('add-db_import')).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/home/components/database-panel-dialog/DatabasePanelDialog.tsx b/redisinsight/ui/src/pages/home/components/database-panel-dialog/DatabasePanelDialog.tsx new file mode 100644 index 0000000000..04baa7cd70 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/database-panel-dialog/DatabasePanelDialog.tsx @@ -0,0 +1,184 @@ +import React, { useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { + EuiButtonIcon, EuiFlexGroup, + EuiFlexItem, + EuiTitle +} from '@elastic/eui' +import cx from 'classnames' +import { Nullable } from 'uiSrc/utils' +import { UrlHandlingActions } from 'uiSrc/slices/interfaces/urlHandling' +import { Instance } from 'uiSrc/slices/interfaces' +import { AddDbType } from 'uiSrc/pages/home/constants' +import { clusterSelector, resetDataRedisCluster } from 'uiSrc/slices/instances/cluster' +import { cloudSelector, resetDataRedisCloud } from 'uiSrc/slices/instances/cloud' +import { resetDataSentinel, sentinelSelector } from 'uiSrc/slices/instances/sentinel' +import { appRedirectionSelector, setUrlHandlingInitialState } from 'uiSrc/slices/app/url-handling' + +import ManualConnectionWrapper from 'uiSrc/pages/home/components/manual-connection' +import SentinelConnectionWrapper from 'uiSrc/pages/home/components/sentinel-connection' +import ConnectionUrlForm from 'uiSrc/pages/home/components/connection-url' + +import CloudConnectionFormWrapper from 'uiSrc/pages/home/components/cloud-connection' +import ImportDatabase from 'uiSrc/pages/home/components/import-database' +import { FormDialog } from 'uiSrc/components' +import { ModalHeaderProvider } from 'uiSrc/contexts/ModalTitleProvider' +import ClusterConnectionFormWrapper from 'uiSrc/pages/home/components/cluster-connection' +import styles from './styles.module.scss' + +export interface Props { + editMode: boolean + urlHandlingAction?: Nullable + editedInstance: Nullable + onClose: () => void + onDbEdited?: () => void + initConnectionType?: Nullable +} + +const DatabasePanelDialog = (props: Props) => { + const { + editMode, + onClose, + } = props + + const [initialValues, setInitialValues] = useState(null) + const [connectionType, setConnectionType] = useState>(null) + const [modalHeader, setModalHeader] = useState>(null) + + const { credentials: clusterCredentials } = useSelector(clusterSelector) + const { credentials: cloudCredentials } = useSelector(cloudSelector) + const { data: sentinelMasters } = useSelector(sentinelSelector) + const { action, dbConnection } = useSelector(appRedirectionSelector) + + const dispatch = useDispatch() + + useEffect(() => { + if (editMode) return + if (clusterCredentials) { + setConnectionType(AddDbType.software) + } + + if (cloudCredentials) { + setConnectionType(AddDbType.cloud) + } + + if (sentinelMasters.length) { + setConnectionType(AddDbType.sentinel) + } + }, []) + + useEffect(() => { + if (action === UrlHandlingActions.Connect) { + setConnectionType(AddDbType.manual) + setInitialValues(dbConnection) + } + }, [action, dbConnection]) + + useEffect(() => { + if (editMode) { + setConnectionType(AddDbType.manual) + } + }, [editMode]) + + useEffect(() => () => { + if (connectionType === AddDbType.manual) return + + switch (connectionType) { + case AddDbType.cloud: { + dispatch(resetDataRedisCluster()) + dispatch(resetDataSentinel()) + break + } + + case AddDbType.sentinel: { + dispatch(resetDataRedisCloud()) + dispatch(resetDataRedisCluster()) + break + } + + case AddDbType.software: { + dispatch(resetDataRedisCloud()) + dispatch(resetDataSentinel()) + break + } + default: + break + } + }, [connectionType]) + + const changeConnectionType = (connectionType: AddDbType, db: any) => { + dispatch(setUrlHandlingInitialState()) + setInitialValues(db) + setConnectionType(connectionType) + } + + const handleClickBack = () => { + setConnectionType(null) + } + + const Form = () => ( + <> + {connectionType === null && ( + + )} + {connectionType === AddDbType.manual && ( + + )} + {connectionType === AddDbType.cloud && ( + + )} + {connectionType === AddDbType.import && ()} + {connectionType === AddDbType.sentinel && ()} + {connectionType === AddDbType.software && ()} + + ) + + const handleSetModalHeader = (content: Nullable, withBack = false) => { + const header = withBack && content + ? ( + + + + + + {content} + + + ) + : content + + setModalHeader(header) + } + + return ( +

    Add Database

    )} + footer={
    } + > +
    +
    + + {Form()} + +
    +
    + + ) +} + +export default DatabasePanelDialog diff --git a/redisinsight/ui/src/pages/home/components/database-panel-dialog/index.ts b/redisinsight/ui/src/pages/home/components/database-panel-dialog/index.ts new file mode 100644 index 0000000000..2ad58bb04f --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/database-panel-dialog/index.ts @@ -0,0 +1,3 @@ +import DatabasePanelDialog from './DatabasePanelDialog' + +export default DatabasePanelDialog diff --git a/redisinsight/ui/src/pages/home/components/database-panel-dialog/styles.module.scss b/redisinsight/ui/src/pages/home/components/database-panel-dialog/styles.module.scss new file mode 100644 index 0000000000..8e88029104 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/database-panel-dialog/styles.module.scss @@ -0,0 +1,24 @@ +.bodyWrapper { + height: 100%; + overflow: hidden; + + display: flex; + flex-direction: column; +} + +.formWrapper { + @include eui.scrollBar; + flex-grow: 1; + overflow-y: auto; + padding: 16px 24px; + + .softwareTypes { + display: flex; + align-items: center; + + :global(.euiRadioGroup__item) { + margin-top: 0; + margin-right: 12px; + } + } +} diff --git a/redisinsight/ui/src/pages/home/components/database-panel/DatabasePanel.spec.tsx b/redisinsight/ui/src/pages/home/components/database-panel/DatabasePanel.spec.tsx deleted file mode 100644 index 75a451f387..0000000000 --- a/redisinsight/ui/src/pages/home/components/database-panel/DatabasePanel.spec.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react' -import { instance, mock } from 'ts-mockito' -import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' -import DatabasePanel, { Props } from './DatabasePanel' - -const mockedProps = mock() - -describe('DatabasePanel', () => { - it('should render', () => { - expect( - render() - ).toBeTruthy() - }) - - it('should render instance types after click on auto discover', () => { - render() - fireEvent.click(screen.getByTestId('add-auto')) - expect(screen.getByTestId('db-types')).toBeInTheDocument() - }) -}) diff --git a/redisinsight/ui/src/pages/home/components/database-panel/DatabasePanel.tsx b/redisinsight/ui/src/pages/home/components/database-panel/DatabasePanel.tsx deleted file mode 100644 index dc450d3f9b..0000000000 --- a/redisinsight/ui/src/pages/home/components/database-panel/DatabasePanel.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import { - EuiButtonIcon, - EuiFlexGroup, - EuiFlexItem, - EuiForm, - EuiRadioGroup, - EuiRadioGroupOption, - EuiText, - EuiTitle, - EuiToolTip, -} from '@elastic/eui' -import React, { useEffect, useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' -import cx from 'classnames' -import { Nullable } from 'uiSrc/utils' -import { cloudSelector, resetDataRedisCloud } from 'uiSrc/slices/instances/cloud' -import { clusterSelector, resetDataRedisCluster } from 'uiSrc/slices/instances/cluster' -import { Instance, InstanceType } from 'uiSrc/slices/interfaces' -import { sentinelSelector, resetDataSentinel } from 'uiSrc/slices/instances/sentinel' - -import { UrlHandlingActions } from 'uiSrc/slices/interfaces/urlHandling' -import { appRedirectionSelector, setUrlHandlingInitialState } from 'uiSrc/slices/app/url-handling' -import { AddDbType } from 'uiSrc/pages/home/constants' -import ClusterConnectionFormWrapper from 'uiSrc/pages/home/components/cluster-connection' -import CloudConnectionFormWrapper from 'uiSrc/pages/home/components/cloud-connection' -import SentinelConnectionWrapper from 'uiSrc/pages/home/components/sentinel-connection' -import ManualConnectionWrapper from 'uiSrc/pages/home/components/manual-connection' -import InstanceConnections from 'uiSrc/pages/home/components/database-panel/instance-connections' - -import styles from './styles.module.scss' - -export interface Props { - width: number - isResizablePanel?: boolean - editMode: boolean - urlHandlingAction?: Nullable - initialValues?: Nullable> - editedInstance: Nullable - onClose?: () => void - onDbEdited?: () => void - onAliasEdited?: (value: string) => void - isFullWidth?: boolean - initConnectionType?: AddDbType -} - -const DatabasePanel = React.memo((props: Props) => { - const { - editMode, - isResizablePanel, - onClose, - isFullWidth: isFullWidthProp = false, - initConnectionType = AddDbType.manual - } = props - - const [typeSelected, setTypeSelected] = useState( - InstanceType.RedisCloudPro - ) - const [connectionType, setConnectionType] = useState(initConnectionType) - const [isFullWidth, setIsFullWidth] = useState(isFullWidthProp) - - const { credentials: clusterCredentials } = useSelector(clusterSelector) - const { credentials: cloudCredentials } = useSelector(cloudSelector) - const { data: sentinelMasters } = useSelector(sentinelSelector) - const { action, dbConnection } = useSelector(appRedirectionSelector) - - const dispatch = useDispatch() - - useEffect(() => { - if (editMode) return - if (clusterCredentials) { - setConnectionType(AddDbType.auto) - setTypeSelected(InstanceType.RedisEnterpriseCluster) - } - - if (cloudCredentials) { - setConnectionType(AddDbType.auto) - setTypeSelected(InstanceType.RedisCloudPro) - } - - if (sentinelMasters.length) { - setConnectionType(AddDbType.auto) - setTypeSelected(InstanceType.Sentinel) - } - }, []) - - useEffect(() => { - if (action === UrlHandlingActions.Connect) { - setConnectionType(AddDbType.manual) - } - }, [action, dbConnection]) - - useEffect(() => { - if (editMode) { - setConnectionType(AddDbType.manual) - } - }, [editMode]) - - useEffect(() => - // ComponentWillUnmount - () => { - if (connectionType === AddDbType.manual) return - - switch (typeSelected) { - case InstanceType.Sentinel: { - dispatch(resetDataRedisCloud()) - dispatch(resetDataRedisCluster()) - break - } - case InstanceType.RedisCloudPro: { - dispatch(resetDataRedisCluster()) - dispatch(resetDataSentinel()) - break - } - case InstanceType.RedisEnterpriseCluster: { - dispatch(resetDataRedisCloud()) - dispatch(resetDataSentinel()) - break - } - default: - break - } - }, - [typeSelected]) - - useEffect(() => { - setIsFullWidth(isFullWidthProp) - }, [isFullWidthProp]) - - const typesFormStage: EuiRadioGroupOption[] = [ - { - id: InstanceType.RedisCloudPro, - label: InstanceType.RedisCloudPro, - 'data-test-subj': 'radio-btn-cloud-pro', - }, - { - id: InstanceType.RedisEnterpriseCluster, - label: InstanceType.RedisEnterpriseCluster, - 'data-test-subj': 'radio-btn-enterprise-cluster', - }, - { - id: InstanceType.Sentinel, - label: InstanceType.Sentinel, - 'data-test-subj': 'radio-btn-sentinel', - }, - ] - - const radioBtnLegend = isResizablePanel ? '' : Connect to: - - const onChange = (optionId: InstanceType) => { - setTypeSelected(optionId) - } - - const changeConnectionType = (connectionType: AddDbType) => { - dispatch(setUrlHandlingInitialState()) - setConnectionType(connectionType) - } - - const InstanceTypes = () => ( - - - - Connect to: - - - onChange(id as InstanceType)} - name="radio group" - legend={{ - children: radioBtnLegend, - }} - data-testid="db-types" - /> - - - - ) - - const Form = () => ( - <> - {connectionType === AddDbType.manual && ( - - )} - {connectionType === AddDbType.auto && ( - <> - {typeSelected === InstanceType.Sentinel && ( - - )} - {typeSelected === InstanceType.RedisEnterpriseCluster && ( - - )} - {typeSelected === InstanceType.RedisCloudPro && ( - - )} - - )} - - ) - - return ( -
    -
    - {!isFullWidth && onClose && ( - - - - )} - {!editMode && ( - <> - -

    Discover and Add Redis Databases

    -
    - - {connectionType === AddDbType.auto && } - - )} - {Form()} -
    -
    -
    - ) -}) - -export default DatabasePanel diff --git a/redisinsight/ui/src/pages/home/components/database-panel/index.ts b/redisinsight/ui/src/pages/home/components/database-panel/index.ts deleted file mode 100644 index 18eecda936..0000000000 --- a/redisinsight/ui/src/pages/home/components/database-panel/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import DatabasePanel from './DatabasePanel' - -export default DatabasePanel diff --git a/redisinsight/ui/src/pages/home/components/database-panel/instance-connections/InstanceConnections.spec.tsx b/redisinsight/ui/src/pages/home/components/database-panel/instance-connections/InstanceConnections.spec.tsx deleted file mode 100644 index 442d34bc49..0000000000 --- a/redisinsight/ui/src/pages/home/components/database-panel/instance-connections/InstanceConnections.spec.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react' -import { instance, mock } from 'ts-mockito' -import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' -import InstanceConnections, { Props } from './InstanceConnections' - -const mockedProps = mock() - -describe('InstanceConnections', () => { - it('should render', () => { - expect( - render() - ).toBeTruthy() - }) - - it('should call changeConnectionType after change connection type', () => { - const changeConnectionType = jest.fn() - render() - fireEvent.click(screen.getByTestId('add-auto')) - expect(changeConnectionType).toBeCalled() - }) -}) diff --git a/redisinsight/ui/src/pages/home/components/database-panel/instance-connections/InstanceConnections.tsx b/redisinsight/ui/src/pages/home/components/database-panel/instance-connections/InstanceConnections.tsx deleted file mode 100644 index a5703dcf5d..0000000000 --- a/redisinsight/ui/src/pages/home/components/database-panel/instance-connections/InstanceConnections.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import React, { useContext } from 'react' -import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui' -import cx from 'classnames' - -import { Theme } from 'uiSrc/constants' -import { ThemeContext } from 'uiSrc/contexts/themeContext' - -import ActiveManualSvg from 'uiSrc/assets/img/active_manual.svg' -import NotActiveManualSvg from 'uiSrc/assets/img/not_active_manual.svg' -import ActiveAutoSvg from 'uiSrc/assets/img/active_auto.svg' -import NotActiveAutoSvg from 'uiSrc/assets/img/not_active_auto.svg' -import LightActiveManualSvg from 'uiSrc/assets/img/light_theme/active_manual.svg' -import LightNotActiveManualSvg from 'uiSrc/assets/img/light_theme/n_active_manual.svg' -import LightActiveAutoSvg from 'uiSrc/assets/img/light_theme/active_auto.svg' -import LightNotActiveAutoSvg from 'uiSrc/assets/img/light_theme/n_active_auto.svg' -import { AddDbType } from 'uiSrc/pages/home/constants' - -import styles from '../styles.module.scss' - -export interface Props { - connectionType: AddDbType, - changeConnectionType: (connectionType: AddDbType) => void, - isFullWidth: boolean -} - -const InstanceConnections = React.memo((props: Props) => { - const { connectionType, changeConnectionType, isFullWidth } = props - const { theme } = useContext(ThemeContext) - - const AddDatabaseManually = () => ( -
    - Add Database Manually -
    - ) - - const AutoDiscoverDatabase = () => ( -
    - Autodiscover Databases -
    - ) - - const getProperManualImage = () => { - if (theme === Theme.Dark) { - return connectionType === AddDbType.manual ? ActiveManualSvg : NotActiveManualSvg - } - return connectionType === AddDbType.manual ? LightActiveManualSvg : LightNotActiveManualSvg - } - - const getProperAutoImage = () => { - if (theme === Theme.Dark) { - return connectionType === AddDbType.auto ? ActiveAutoSvg : NotActiveAutoSvg - } - return connectionType === AddDbType.auto ? LightActiveAutoSvg : LightNotActiveAutoSvg - } - - return ( -
    - - changeConnectionType(AddDbType.manual)} - grow={1} - data-testid="add-manual" - > - - - - - - - {!isFullWidth && ( - - - - )} - - - - {isFullWidth && ()} - - Use Host and Port to connect to your Redis Database - - - - - changeConnectionType(AddDbType.auto)} - grow={1} - data-testid="add-auto" - > - - - - - - - {!isFullWidth && ( - - - - )} - - - - {isFullWidth && ()} - - Use discovery tools to automatically discover and add your Redis Databases - - - - - -
    - ) -}) - -export default InstanceConnections diff --git a/redisinsight/ui/src/pages/home/components/database-panel/instance-connections/index.ts b/redisinsight/ui/src/pages/home/components/database-panel/instance-connections/index.ts deleted file mode 100644 index 162026205d..0000000000 --- a/redisinsight/ui/src/pages/home/components/database-panel/instance-connections/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import InstanceConnections from './InstanceConnections' - -export default InstanceConnections diff --git a/redisinsight/ui/src/pages/home/components/database-panel/styles.module.scss b/redisinsight/ui/src/pages/home/components/database-panel/styles.module.scss deleted file mode 100644 index cef767dbe5..0000000000 --- a/redisinsight/ui/src/pages/home/components/database-panel/styles.module.scss +++ /dev/null @@ -1,68 +0,0 @@ -.closeKeyTooltip { - position: absolute; - top: 8px; - right: 10px; - z-index: 1; - - svg { - width: 20px; - height: 20px; - } -} - -.connectionTypesContainer { - max-width: 752px; - margin-bottom: 25px; -} - -.connectionType { - background-color: var(--euiColorLightestShade); - padding: 20px 16px; - cursor: pointer; - - > div { - color: var(--euiTextSubduedColorHover) !important; - } -} - - -.selectedConnectionType { - outline: 1px solid var(--euiColorPrimary); -} - -.connectionTypeTitle { - font-size: 16px; - line-height: 20px; - font-weight: 500; - letter-spacing: 0; -} - -.connectionTypeTitleFullWidth { - margin-bottom: 6px; -} - -.connectionIcon { - width: 55px !important; - height: 60px !important; -} - -.descriptionNotFullWidth { - margin-top: -6px; -} - -.radioBtnText { - display: block; - margin-right: 10px; - flex-basis: 100% !important; - - div { - font-size: 13px !important; - } -} - -.radioBtnTextFullWidth { - flex-basis: auto !important; - justify-content: center; - padding-bottom: 6px; - margin-right: 20px !important; -} diff --git a/redisinsight/ui/src/pages/home/components/db-status/DbStatus.spec.tsx b/redisinsight/ui/src/pages/home/components/db-status/DbStatus.spec.tsx new file mode 100644 index 0000000000..de3b97b108 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/db-status/DbStatus.spec.tsx @@ -0,0 +1,70 @@ +import React from 'react' +import { mock } from 'ts-mockito' +import { act, fireEvent, render, screen, waitForEuiToolTipVisible } from 'uiSrc/utils/test-utils' + +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import DbStatus, { Props, WarningTypes } from './DbStatus' + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +const mockedProps = mock() +const daysToMs = (days: number) => days * 60 * 60 * 24 * 1000 + +describe('DbStatus', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should not render any status', () => { + render() + + expect(screen.queryByTestId('database-status-new-1')).not.toBeInTheDocument() + expect(screen.queryByTestId(`database-status-${WarningTypes.TryDatabase}-1`)).not.toBeInTheDocument() + expect(screen.queryByTestId(`database-status-${WarningTypes.CheckIfDeleted}-1`)).not.toBeInTheDocument() + }) + + it('should render TryDatabase status', () => { + const lastConnection = new Date(Date.now() - daysToMs(3)) + render() + + expect(screen.getByTestId(`database-status-${WarningTypes.TryDatabase}-1`)).toBeInTheDocument() + expect(screen.queryByTestId('database-status-new-1')).not.toBeInTheDocument() + expect(screen.queryByTestId(`database-status-${WarningTypes.CheckIfDeleted}-1`)).not.toBeInTheDocument() + }) + + it('should render CheckIfDeleted status', () => { + const lastConnection = new Date(Date.now() - daysToMs(16)) + render() + + expect(screen.getByTestId(`database-status-${WarningTypes.CheckIfDeleted}-1`)).toBeInTheDocument() + + expect(screen.queryByTestId('database-status-new-1')).not.toBeInTheDocument() + expect(screen.queryByTestId(`database-status-${WarningTypes.TryDatabase}-1`)).not.toBeInTheDocument() + }) + + it('should render new status', async () => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + + const lastConnection = new Date(Date.now() - daysToMs(3)) + render() + + await act(async () => { + fireEvent.mouseOver(screen.getByTestId(`database-status-${WarningTypes.TryDatabase}-1`)) + }) + + await waitForEuiToolTipVisible(1_000) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CLOUD_NOT_USED_DB_NOTIFICATION_VIEWED, + eventData: { + capability: expect.any(String), + databaseId: '1', + type: WarningTypes.TryDatabase + } + }) + }) +}) diff --git a/redisinsight/ui/src/pages/home/components/db-status/DbStatus.tsx b/redisinsight/ui/src/pages/home/components/db-status/DbStatus.tsx new file mode 100644 index 0000000000..f093a0a223 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/db-status/DbStatus.tsx @@ -0,0 +1,119 @@ +import React from 'react' +import { EuiIcon, EuiToolTip } from '@elastic/eui' +import cx from 'classnames' +import { differenceInDays } from 'date-fns' + +import { useSelector } from 'react-redux' +import { getTutorialCapability, Maybe } from 'uiSrc/utils' + +import { appContextCapability } from 'uiSrc/slices/app/context' + +import AlarmIcon from 'uiSrc/assets/img/alarm.svg' +import { isShowCapabilityTutorialPopover } from 'uiSrc/services' +import { sendEventTelemetry, TELEMETRY_EMPTY_VALUE, TelemetryEvent } from 'uiSrc/telemetry' +import { CHECK_CLOUD_DATABASE, WARNING_WITH_CAPABILITY, WARNING_WITHOUT_CAPABILITY } from './texts' +import styles from './styles.module.scss' + +export interface Props { + id: string + lastConnection: Maybe + createdAt: Maybe + isNew: boolean + isFree?: boolean +} + +export enum WarningTypes { + CheckIfDeleted = 'checkIfDeleted', + TryDatabase = 'tryDatabase', +} + +interface WarningTooltipProps { + id: string + content : React.ReactNode + capabilityTelemetry?: string + type?: string + isCapabilityNotShown?: boolean +} + +const LAST_CONNECTION_SM = 3 +const LAST_CONNECTION_L = 16 + +const DbStatus = (props: Props) => { + const { id, lastConnection, createdAt, isNew, isFree } = props + + const { source } = useSelector(appContextCapability) + const capability = getTutorialCapability(source!) + const isCapabilityNotShown = Boolean(isShowCapabilityTutorialPopover(isFree)) + let daysDiff = 0 + + try { + daysDiff = lastConnection + ? differenceInDays(new Date(), new Date(lastConnection)) + : createdAt ? differenceInDays(new Date(), new Date(createdAt)) : 0 + } catch { + // nothing to do + } + + const renderWarningTooltip = (content: React.ReactNode, type?: string) => ( + + )} + position="right" + className={styles.tooltip} + anchorClassName={cx(styles.statusAnchor, styles.warning)} + > +
    !
    +
    + ) + + if (isFree && daysDiff >= LAST_CONNECTION_L) { + return renderWarningTooltip(CHECK_CLOUD_DATABASE, 'checkIfDeleted') + } + + if (isFree && daysDiff >= LAST_CONNECTION_SM) { + return renderWarningTooltip( + isCapabilityNotShown && capability.name ? WARNING_WITH_CAPABILITY(capability.name) : WARNING_WITHOUT_CAPABILITY, + 'tryDatabase' + ) + } + + if (isNew) { + return ( + +
    + + ) + } + + return null +} + +// separated to send event when content is displayed +const WarningTooltipContent = (props: WarningTooltipProps) => { + const { id, content, capabilityTelemetry, type, isCapabilityNotShown } = props + + sendEventTelemetry({ + event: TelemetryEvent.CLOUD_NOT_USED_DB_NOTIFICATION_VIEWED, + eventData: { + databaseId: id, + capability: isCapabilityNotShown ? capabilityTelemetry : TELEMETRY_EMPTY_VALUE, + type + } + }) + + return ( +
    + +
    {content}
    +
    + ) +} + +export default DbStatus diff --git a/redisinsight/ui/src/pages/home/components/db-status/index.ts b/redisinsight/ui/src/pages/home/components/db-status/index.ts new file mode 100644 index 0000000000..b777907a83 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/db-status/index.ts @@ -0,0 +1,3 @@ +import DbStatus from './DbStatus' + +export default DbStatus diff --git a/redisinsight/ui/src/pages/home/components/db-status/styles.module.scss b/redisinsight/ui/src/pages/home/components/db-status/styles.module.scss new file mode 100644 index 0000000000..617fe08eb7 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/db-status/styles.module.scss @@ -0,0 +1,47 @@ +.status { + cursor: pointer; + width: 11px !important; + min-width: 11px !important; + height: 11px !important; + border-radius: 50%; + + &.new { + background-color: var(--euiColorPrimary) !important; + } + + &.warning { + width: 14px !important; + height: 14px !important; + background-color: var(--euiColorWarningLight) !important; + + line-height: 14px !important; + text-align: center; + color: var(--euiColorEmptyShade); + font-size: 11px; + } +} + +.statusAnchor { + margin-top: 20px; + margin-left: -19px; + position: absolute; + + &.warning { + margin-top: 17px; + margin-left: -21px; + } +} + +.tooltip { + min-width: 340px !important; +} + +.warningTooltipContent { + display: flex; + align-items: flex-start; + + :global(.euiIcon) { + margin-top: 4px; + margin-right: 12px; + } +} diff --git a/redisinsight/ui/src/pages/home/components/db-status/texts.tsx b/redisinsight/ui/src/pages/home/components/db-status/texts.tsx new file mode 100644 index 0000000000..466c15877e --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/db-status/texts.tsx @@ -0,0 +1,37 @@ +import { EuiSpacer, EuiTitle } from '@elastic/eui' +import React from 'react' + +export const CHECK_CLOUD_DATABASE = ( + <> + Check your Cloud database + +
    + Free Cloud databases are usually deleted after 15 days of inactivity. + + Check your Cloud database to proceed with learning more about Redis and its capabilities. +
    + +) + +export const WARNING_WITH_CAPABILITY = (capability: string) => ( + <> + {capability} + +
    + Hey, remember you expressed interest in {capability}? +
    + Try your Cloud database to get started. +
    + +
    Notice: free Cloud databases will be deleted after 15 days of inactivity.
    + +) +export const WARNING_WITHOUT_CAPABILITY = ( + <> + Try your Cloud database + +
    Hey, try your Cloud database to learn more about Redis.
    + +
    Notice: free Cloud databases will be deleted after 15 days of inactivity.
    + +) diff --git a/redisinsight/ui/src/pages/home/components/form/DatabaseForm.tsx b/redisinsight/ui/src/pages/home/components/form/DatabaseForm.tsx index f87fba83ec..d9d3ba0533 100644 --- a/redisinsight/ui/src/pages/home/components/form/DatabaseForm.tsx +++ b/redisinsight/ui/src/pages/home/components/form/DatabaseForm.tsx @@ -33,22 +33,20 @@ interface IShowFields { } export interface Props { - flexGroupClassName?: string - flexItemClassName?: string formik: FormikProps onHostNamePaste: (content: string) => boolean showFields: IShowFields autoFocus?: boolean + readyOnlyFields?: string[] } const DatabaseForm = (props: Props) => { const { - flexGroupClassName = '', - flexItemClassName = '', formik, onHostNamePaste, autoFocus = false, showFields, + readyOnlyFields = [] } = props const { server } = useSelector(appInfoSelector) @@ -89,64 +87,14 @@ const DatabaseForm = (props: Props) => { ) + const isShowPort = server?.buildType !== BuildType.RedisStack && showFields.port + const isFieldDisabled = (name: string) => readyOnlyFields.includes(name) + return ( <> - - {showFields.host && ( - - - ) => { - formik.setFieldValue( - 'host', - validateField(e.target.value.trim()) - ) - }} - onPaste={(event: React.ClipboardEvent) => handlePasteHostName(onHostNamePaste, event)} - onFocus={selectOnFocus} - append={} - /> - - - )} - {server?.buildType !== BuildType.RedisStack && showFields.port && ( - - - ) => { - formik.setFieldValue( - e.target.name, - validatePortNumber(e.target.value.trim()) - ) - }} - onFocus={selectOnFocus} - type="text" - min={0} - max={MAX_PORT_NUMBER} - /> - - - )} - - {showFields.alias && ( - - + + { value={formik.values.name ?? ''} maxLength={500} onChange={formik.handleChange} + disabled={isFieldDisabled('alias')} /> )} - - + {(showFields.host || isShowPort) && ( + + {showFields.host && ( + + + ) => { + formik.setFieldValue( + 'host', + validateField(e.target.value.trim()) + ) + }} + onPaste={(event: React.ClipboardEvent) => handlePasteHostName(onHostNamePaste, event)} + onFocus={selectOnFocus} + append={} + disabled={isFieldDisabled('host')} + /> + + + )} + {isShowPort && ( + + + ) => { + formik.setFieldValue( + e.target.name, + validatePortNumber(e.target.value.trim()) + ) + }} + onFocus={selectOnFocus} + type="text" + min={0} + max={MAX_PORT_NUMBER} + disabled={isFieldDisabled('port')} + /> + + + )} + + )} + + + { placeholder="Enter Username" value={formik.values.username ?? ''} onChange={formik.handleChange} + disabled={isFieldDisabled('username')} /> - + { }} dualToggleProps={{ color: 'text' }} autoComplete="new-password" + disabled={isFieldDisabled('password')} /> + - {showFields.timeout && ( - + {showFields.timeout && ( + + { type="text" min={1} max={MAX_TIMEOUT_NUMBER} + disabled={isFieldDisabled('timeout')} /> - )} - + + + )} ) } diff --git a/redisinsight/ui/src/pages/home/components/form/DbCompressor.tsx b/redisinsight/ui/src/pages/home/components/form/DbCompressor.tsx index 92511938bc..75264b429a 100644 --- a/redisinsight/ui/src/pages/home/components/form/DbCompressor.tsx +++ b/redisinsight/ui/src/pages/home/components/form/DbCompressor.tsx @@ -3,27 +3,23 @@ import { EuiCheckbox, EuiFlexGroup, EuiFlexItem, - EuiFormRow, + EuiFormRow, EuiSpacer, EuiSuperSelect, EuiSuperSelectOption, htmlIdGenerator, } from '@elastic/eui' -import cx from 'classnames' import { FormikProps } from 'formik' import { KeyValueCompressor } from 'uiSrc/constants' import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' import { NONE } from 'uiSrc/pages/home/constants' -import styles from '../styles.module.scss' export interface Props { - flexGroupClassName?: string - flexItemClassName?: string formik: FormikProps } const DbCompressor = (props: Props) => { - const { flexGroupClassName = '', flexItemClassName = '', formik } = props + const { formik } = props const optionsCompressor: EuiSuperSelectOption[] = [ { @@ -70,16 +66,8 @@ const DbCompressor = (props: Props) => { return ( <> - - + + { {formik.values.showCompressor && ( - - - - + + + + + { - formik.setFieldValue( - 'compressor', - value || NONE - ) - }} - data-testid="select-compressor" - /> - - - + options={optionsCompressor} + onChange={(value) => { + formik.setFieldValue( + 'compressor', + value || NONE + ) + }} + data-testid="select-compressor" + /> + + + + + )} ) diff --git a/redisinsight/ui/src/pages/home/components/form/DbIndex.tsx b/redisinsight/ui/src/pages/home/components/form/DbIndex.tsx index c209bb3be7..37724581a2 100644 --- a/redisinsight/ui/src/pages/home/components/form/DbIndex.tsx +++ b/redisinsight/ui/src/pages/home/components/form/DbIndex.tsx @@ -1,6 +1,13 @@ import React, { ChangeEvent } from 'react' -import { EuiCheckbox, EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiFormRow, htmlIdGenerator } from '@elastic/eui' -import cx from 'classnames' +import { + EuiCheckbox, + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSpacer, + htmlIdGenerator +} from '@elastic/eui' import { FormikProps } from 'formik' import { validateNumber } from 'uiSrc/utils' @@ -9,13 +16,11 @@ import { DbConnectionInfo } from 'uiSrc/pages/home/interfaces' import styles from '../styles.module.scss' export interface Props { - flexGroupClassName?: string - flexItemClassName?: string formik: FormikProps } const DbIndex = (props: Props) => { - const { flexGroupClassName = '', flexItemClassName = '', formik } = props + const { formik } = props const handleChangeDbIndexCheckbox = (e: ChangeEvent): void => { const isChecked = e.target.checked @@ -29,15 +34,11 @@ const DbIndex = (props: Props) => { return ( <> { {formik.values.showDb && ( - - - - ) => { - formik.setFieldValue( - e.target.name, - validateNumber(e.target.value.trim()) - ) - }} - type="text" - min={0} - /> - - - + <> + + + + + ) => { + formik.setFieldValue( + e.target.name, + validateNumber(e.target.value.trim()) + ) + }} + type="text" + min={0} + /> + + + + + )} ) diff --git a/redisinsight/ui/src/pages/home/components/form/Messages.tsx b/redisinsight/ui/src/pages/home/components/form/Messages.tsx index cce51f1b2a..1687ba5b72 100644 --- a/redisinsight/ui/src/pages/home/components/form/Messages.tsx +++ b/redisinsight/ui/src/pages/home/components/form/Messages.tsx @@ -1,49 +1,75 @@ import React from 'react' -import { EuiLink, EuiText } from '@elastic/eui' +import { EuiText } from '@elastic/eui' +import cx from 'classnames' import { APPLICATION_NAME } from 'uiSrc/constants' +import { getUtmExternalLink } from 'uiSrc/utils/links' +import { ExternalLink } from 'uiSrc/components' import styles from '../styles.module.scss' +const MessageCloudApiKeys = () => ( + + {'Enter Redis Cloud API keys to discover and add databases. API keys can be enabled by following the steps mentioned in the '} + + + documentation. + + +) + const MessageStandalone = () => ( - You can manually add your Redis databases. Enter Host and Port of your - Redis database to add it to + You can manually add your Redis databases. Enter host and port of your Redis database to add it to {' '} {APPLICATION_NAME} .   - Learn more here. - + ) const MessageSentinel = () => ( - You can automatically discover and add primary groups from your Redis - Sentinel. Enter Host and Port of your Redis Sentinel to automatically - discover your primary groups and add them to + You can automatically discover and add primary groups from your Redis Sentinel. + Enter host and port of your Redis Sentinel to automatically discover your primary groups and add them to + {' '} + {APPLICATION_NAME} + .   + + Learn more here. + + +) + +const MessageEnterpriceSoftware = () => ( + + Your Redis Software databases can be automatically added. Enter the connection details of your + Redis Software Cluster to automatically discover your databases and add them to {' '} {APPLICATION_NAME} .   - Learn more here. - + ) export { MessageStandalone, MessageSentinel, + MessageCloudApiKeys, + MessageEnterpriceSoftware, } diff --git a/redisinsight/ui/src/pages/home/components/form/SSHDetails.tsx b/redisinsight/ui/src/pages/home/components/form/SSHDetails.tsx index 9a7ef33918..4fe2cc11c0 100644 --- a/redisinsight/ui/src/pages/home/components/form/SSHDetails.tsx +++ b/redisinsight/ui/src/pages/home/components/form/SSHDetails.tsx @@ -8,7 +8,7 @@ import { EuiFlexItem, EuiFormRow, EuiRadioGroup, - EuiRadioGroupOption, + EuiRadioGroupOption, EuiSpacer, EuiTextArea, htmlIdGenerator } from '@elastic/eui' @@ -142,6 +142,7 @@ const SSHDetails = (props: Props) => { + diff --git a/redisinsight/ui/src/pages/home/components/form/TlsDetails.tsx b/redisinsight/ui/src/pages/home/components/form/TlsDetails.tsx index d08cdf06c2..d615174dff 100644 --- a/redisinsight/ui/src/pages/home/components/form/TlsDetails.tsx +++ b/redisinsight/ui/src/pages/home/components/form/TlsDetails.tsx @@ -5,6 +5,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, + EuiSpacer, EuiSuperSelect, EuiSuperSelectOption, EuiTextArea, @@ -31,8 +32,6 @@ import styles from '../styles.module.scss' const suffix = '_tls_details' export interface Props { - flexGroupClassName?: string - flexItemClassName?: string formik: FormikProps caCertificates?: { id: string; name: string }[] certificates?: { id: number; name: string }[] @@ -40,7 +39,7 @@ export interface Props { const TlsDetails = (props: Props) => { const dispatch = useDispatch() - const { flexGroupClassName = '', flexItemClassName = '', formik, caCertificates, certificates } = props + const { formik, caCertificates, certificates } = props const [activeCertId, setActiveCertId] = useState>(null) const handleDeleteCaCert = (id: string) => { @@ -100,7 +99,7 @@ const TlsDetails = (props: Props) => { value: cert.id, inputDisplay: cert.name, dropdownDisplay: ( -
    +
    {truncateText(cert.name, 25)}
    { value: `${cert.id}`, inputDisplay: cert.name, dropdownDisplay: ( -
    +
    {truncateText(cert.name, 25)}
    { return ( <> - - + + { data-testid="tls" /> + - {formik.values.tls && ( - <> - + {formik.values.tls && ( + <> + + + { data-testid="sni" /> - {formik.values.sni && ( - - - ) => - formik.setFieldValue( - e.target.name, - validateField(e.target.value.trim()) - )} - data-testid="sni-servername" - /> - - - )} - + + {formik.values.sni && ( + <> + + + + + ) => + formik.setFieldValue( + e.target.name, + validateField(e.target.value.trim()) + )} + data-testid="sni-servername" + /> + + + + + )} + + + { data-testid="verify-tls-cert" /> - - )} - + + + )} {formik.values.tls && (
    - - + + + { {formik.values.tls && formik.values.selectedCaCertName === ADD_NEW_CA_CERT && ( - + { {formik.values.tls && formik.values.selectedCaCertName === ADD_NEW_CA_CERT && ( - - + + { )} {formik.values.tls && ( - + { )} {formik.values.tls && formik.values.tlsClientAuthRequired && (
    - - + + { {formik.values.tls && formik.values.tlsClientAuthRequired && formik.values.selectedTlsClientCertId === 'ADD_NEW' && ( - + { && formik.values.tlsClientAuthRequired && formik.values.selectedTlsClientCertId === 'ADD_NEW' && ( <> - - + + { - - + + sentinelMaster?: SentinelMaster } const DbInfoSentinel = (props: Props) => { - const { connectionType, nameFromProvider, sentinelMaster } = props + const { connectionType, nameFromProvider, sentinelMaster, host, port } = props return ( { )} /> )} + + {host && port && ( + + )} ) } diff --git a/redisinsight/ui/src/pages/home/components/form/sentinel/SentinelHostPort.tsx b/redisinsight/ui/src/pages/home/components/form/sentinel/SentinelHostPort.tsx index 269b724aa8..164dd7b01b 100644 --- a/redisinsight/ui/src/pages/home/components/form/sentinel/SentinelHostPort.tsx +++ b/redisinsight/ui/src/pages/home/components/form/sentinel/SentinelHostPort.tsx @@ -1,6 +1,7 @@ import React from 'react' -import { EuiButtonIcon, EuiText, EuiTextColor, EuiToolTip } from '@elastic/eui' +import { EuiButtonIcon, EuiListGroupItem, EuiText, EuiTextColor, EuiToolTip } from '@elastic/eui' +import cx from 'classnames' import styles from '../../styles.module.scss' export interface Props { @@ -17,7 +18,7 @@ const SentinelHostPort = (props: Props) => { return ( - Host:Port: + Sentinel Host & Port:
    {`${host}:${port}`} ({ ...jest.requireActual('uiSrc/slices/instances/instances'), @@ -27,16 +27,21 @@ beforeEach(() => { store.clearActions() }) -describe('ImportDatabasesDialog', () => { +describe('ImportDatabase', () => { it('should render', () => { - expect(render()).toBeTruthy() + expect(render()).toBeTruthy() }) it('should call proper actions and send telemetry', async () => { const sendEventTelemetryMock = jest.fn(); (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) - render() + render( +
    + +
    +
    + ) const jsonString = JSON.stringify({}) const blob = new Blob([jsonString]) @@ -44,7 +49,7 @@ describe('ImportDatabasesDialog', () => { type: 'application/JSON', }) - await act(() => { + await act(async () => { fireEvent.change( screen.getByTestId('import-file-modal-filepicker'), { @@ -53,8 +58,8 @@ describe('ImportDatabasesDialog', () => { ) }) - expect(screen.getByTestId('submit-btn')).not.toBeDisabled() - fireEvent.click(screen.getByTestId('submit-btn')) + expect(screen.getByTestId('btn-submit')).not.toBeDisabled() + fireEvent.click(screen.getByTestId('btn-submit')) const expectedActions = [importInstancesFromFile()] expect(store.getActions()).toEqual(expectedActions) @@ -66,6 +71,26 @@ describe('ImportDatabasesDialog', () => { (sendEventTelemetry as jest.Mock).mockRestore() }) + it('should call proper actions on retry', async () => { + (importInstancesSelector as jest.Mock).mockImplementation(() => ({ + loading: false, + data: null, + error: 'Error message' + })) + + render( +
    + +
    +
    + ) + + fireEvent.click(screen.getByTestId('btn-retry')) + + const expectedActions = [resetImportInstances()] + expect(store.getActions()).toEqual(expectedActions) + }) + it('should render error message when 0 success databases added', () => { (importInstancesSelector as jest.Mock).mockImplementation(() => ({ loading: false, @@ -73,7 +98,7 @@ describe('ImportDatabasesDialog', () => { error: 'Error message' })) - render() + render() expect(screen.getByTestId('result-failed')).toBeInTheDocument() expect(screen.getByTestId('result-failed')).toHaveTextContent('Error message') }) diff --git a/redisinsight/ui/src/pages/home/components/import-database/ImportDatabase.tsx b/redisinsight/ui/src/pages/home/components/import-database/ImportDatabase.tsx new file mode 100644 index 0000000000..4ce50b0943 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/import-database/ImportDatabase.tsx @@ -0,0 +1,245 @@ +import React, { useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { + EuiButton, + EuiFilePicker, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLoadingSpinner, + EuiSpacer, + EuiText, + EuiTextColor, EuiTitle, EuiToolTip +} from '@elastic/eui' +import ReactDOM from 'react-dom' +import { + fetchInstancesAction, + importInstancesSelector, + resetImportInstances, + uploadInstancesFile +} from 'uiSrc/slices/instances/instances' +import { Nullable } from 'uiSrc/utils' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { UploadWarning } from 'uiSrc/components' +import { useModalHeader } from 'uiSrc/contexts/ModalTitleProvider' +import ResultsLog from './components/ResultsLog' + +import styles from './styles.module.scss' + +export interface Props { + onClose: () => void +} + +const MAX_MB_FILE = 10 +const MAX_FILE_SIZE = MAX_MB_FILE * 1024 * 1024 + +const ImportDatabase = (props: Props) => { + const { onClose } = props + const { loading, data, error } = useSelector(importInstancesSelector) + const [files, setFiles] = useState>(null) + const [isInvalid, setIsInvalid] = useState(false) + const [isSubmitDisabled, setIsSubmitDisabled] = useState(true) + const [domReady, setDomReady] = useState(false) + + const dispatch = useDispatch() + const { setModalHeader } = useModalHeader() + + useEffect(() => { + setDomReady(true) + + setModalHeader( +

    Import from file

    , + true + ) + + return () => { + setModalHeader(null) + } + }, []) + + const onFileChange = (files: FileList | null) => { + setFiles(files) + setIsInvalid(!!files?.length && files?.[0].size > MAX_FILE_SIZE) + setIsSubmitDisabled(!files?.length || files[0].size > MAX_FILE_SIZE) + } + + const handleOnClose = () => { + onClose() + dispatch(resetImportInstances()) + + if (!data) { + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_REDIS_IMPORT_CANCELLED, + }) + } + } + + const onClickRetry = () => { + dispatch(resetImportInstances()) + onFileChange(null) + } + + const onSubmit = () => { + if (files) { + const formData = new FormData() + formData.append('file', files[0]) + + dispatch(uploadInstancesFile( + formData, + (data) => { + if (data?.success?.length || data?.partial?.length) { + dispatch(fetchInstancesAction()) + } + } + )) + + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_REDIS_IMPORT_SUBMITTED + }) + } + } + + const Footer = () => { + const footerEl = document.getElementById('footerDatabaseForm') + if (!domReady || !footerEl) return null + + if (error) { + return ReactDOM.createPortal( +
    + + Retry + +
    , + footerEl + ) + } + + if (data) { + return ReactDOM.createPortal( +
    + + Ok + +
    , + footerEl + ) + } + + return ReactDOM.createPortal( +
    + + Cancel + + + + Submit + + +
    , + footerEl + ) + } + + const isShowForm = !loading && !data && !error + + return ( + <> +
    + + + {isShowForm && ( + <> + + Use a JSON file to import your database connections. + Ensure that you only use files from trusted sources to + prevent the risk of automatically executing malicious code. + + + + {isInvalid && ( + + {`File should not exceed ${MAX_MB_FILE} MB`} + + )} + + )} + {loading && ( +
    + + + Uploading... + +
    + )} + {error && ( +
    + + + Failed to add database connections + + {error} +
    + )} +
    + {isShowForm && ( + + + + )} +
    + {data && ( + + + + + + )} +
    +