From 5e2e83dd32ad3b7c476369394965c99d277e8cfe Mon Sep 17 00:00:00 2001 From: Artem Date: Wed, 18 Oct 2023 11:10:34 +0300 Subject: [PATCH 001/566] base implementation of wrapper --- redisinsight/api/config/ormconfig.ts | 4 +- redisinsight/api/src/app.module.ts | 2 + .../src/modules/rdi/client/api.rdi.client.ts | 34 ++++++ .../api/src/modules/rdi/client/rdi.client.ts | 54 ++++++++++ .../api/src/modules/rdi/decorators/index.ts | 1 + .../request.rdi.client.metadata.decorator.ts | 26 +++++ .../api/src/modules/rdi/dto/create.rdi.dto.ts | 16 +++ redisinsight/api/src/modules/rdi/dto/index.ts | 2 + .../api/src/modules/rdi/dto/update.rdi.dto.ts | 6 ++ .../src/modules/rdi/entities/rdi.entity.ts | 42 ++++++++ .../api/src/modules/rdi/models/index.ts | 4 + .../api/src/modules/rdi/models/rdi-job.ts | 4 + .../src/modules/rdi/models/rdi-pipeline.ts | 9 ++ .../modules/rdi/models/rdi.client.metadata.ts | 15 +++ .../api/src/modules/rdi/models/rdi.ts | 101 ++++++++++++++++++ .../rdi/providers/rdi.client.factory.ts | 29 +++++ .../rdi/providers/rdi.client.provider.ts | 33 ++++++ .../rdi/providers/rdi.client.storage.ts | 54 ++++++++++ .../modules/rdi/rdi-pipeline.controller.ts | 29 +++++ .../src/modules/rdi/rdi-pipeline.service.ts | 20 ++++ .../api/src/modules/rdi/rdi.controller.ts | 68 ++++++++++++ .../api/src/modules/rdi/rdi.module.ts | 36 +++++++ .../api/src/modules/rdi/rdi.service.ts | 37 +++++++ .../rdi/repository/local.rdi.repository.ts | 83 ++++++++++++++ .../modules/rdi/repository/rdi.repository.ts | 37 +++++++ 25 files changed, 745 insertions(+), 1 deletion(-) create mode 100644 redisinsight/api/src/modules/rdi/client/api.rdi.client.ts create mode 100644 redisinsight/api/src/modules/rdi/client/rdi.client.ts create mode 100644 redisinsight/api/src/modules/rdi/decorators/index.ts create mode 100644 redisinsight/api/src/modules/rdi/decorators/request.rdi.client.metadata.decorator.ts create mode 100644 redisinsight/api/src/modules/rdi/dto/create.rdi.dto.ts create mode 100644 redisinsight/api/src/modules/rdi/dto/index.ts create mode 100644 redisinsight/api/src/modules/rdi/dto/update.rdi.dto.ts create mode 100644 redisinsight/api/src/modules/rdi/entities/rdi.entity.ts create mode 100644 redisinsight/api/src/modules/rdi/models/index.ts create mode 100644 redisinsight/api/src/modules/rdi/models/rdi-job.ts create mode 100644 redisinsight/api/src/modules/rdi/models/rdi-pipeline.ts create mode 100644 redisinsight/api/src/modules/rdi/models/rdi.client.metadata.ts create mode 100644 redisinsight/api/src/modules/rdi/models/rdi.ts create mode 100644 redisinsight/api/src/modules/rdi/providers/rdi.client.factory.ts create mode 100644 redisinsight/api/src/modules/rdi/providers/rdi.client.provider.ts create mode 100644 redisinsight/api/src/modules/rdi/providers/rdi.client.storage.ts create mode 100644 redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts create mode 100644 redisinsight/api/src/modules/rdi/rdi-pipeline.service.ts create mode 100644 redisinsight/api/src/modules/rdi/rdi.controller.ts create mode 100644 redisinsight/api/src/modules/rdi/rdi.module.ts create mode 100644 redisinsight/api/src/modules/rdi/rdi.service.ts create mode 100644 redisinsight/api/src/modules/rdi/repository/local.rdi.repository.ts create mode 100644 redisinsight/api/src/modules/rdi/repository/rdi.repository.ts diff --git a/redisinsight/api/config/ormconfig.ts b/redisinsight/api/config/ormconfig.ts index 3afb6d7097..3351e995f0 100644 --- a/redisinsight/api/config/ormconfig.ts +++ b/redisinsight/api/config/ormconfig.ts @@ -18,9 +18,10 @@ import { CustomTutorialEntity } from 'src/modules/custom-tutorial/entities/custo import { FeatureEntity } from 'src/modules/feature/entities/feature.entity'; import { FeaturesConfigEntity } from 'src/modules/feature/entities/features-config.entity'; import { CloudDatabaseDetailsEntity } from 'src/modules/cloud/database/entities/cloud-database-details.entity'; +import { CloudCapiKeyEntity } from 'src/modules/cloud/capi-key/entity/cloud-capi-key.entity'; +import { RdiEntity } from 'src/modules/rdi/entities/rdi.entity'; import migrations from '../migration'; import * as config from '../src/utils/config'; -import { CloudCapiKeyEntity } from 'src/modules/cloud/capi-key/entity/cloud-capi-key.entity'; const dbConfig = config.get('db'); @@ -48,6 +49,7 @@ const ormConfig = { FeaturesConfigEntity, CloudDatabaseDetailsEntity, CloudCapiKeyEntity, + RdiEntity, ], migrations, }; diff --git a/redisinsight/api/src/app.module.ts b/redisinsight/api/src/app.module.ts index 275e84d954..52d2099db2 100644 --- a/redisinsight/api/src/app.module.ts +++ b/redisinsight/api/src/app.module.ts @@ -23,6 +23,7 @@ import { DatabaseImportModule } from 'src/modules/database-import/database-impor import { SingleUserAuthMiddleware } from 'src/common/middlewares/single-user-auth.middleware'; import { CustomTutorialModule } from 'src/modules/custom-tutorial/custom-tutorial.module'; import { CloudModule } from 'src/modules/cloud/cloud.module'; +import { RdiModule } from 'src/modules/rdi/rdi.module'; import { BrowserModule } from './modules/browser/browser.module'; import { RedisEnterpriseModule } from './modules/redis-enterprise/redis-enterprise.module'; import { RedisSentinelModule } from './modules/redis-sentinel/redis-sentinel.module'; @@ -60,6 +61,7 @@ const PATH_CONFIG = config.get('dir_path'); DatabaseImportModule, TriggeredFunctionsModule, CloudModule.register(), + RdiModule.register(), ...(SERVER_CONFIG.staticContent ? [ ServeStaticModule.forRoot({ diff --git a/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts b/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts new file mode 100644 index 0000000000..98d2d25b9a --- /dev/null +++ b/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts @@ -0,0 +1,34 @@ +import { RdiJob, RdiPipeline, RdiType } from 'src/modules/rdi/models'; +import { RdiClient } from 'src/modules/rdi/client/rdi.client'; +import { AxiosInstance } from 'axios'; + +export class ApiRdiClient extends RdiClient { + public type = RdiType.API; + + protected readonly client: AxiosInstance; + + async isConnected(): Promise { + // todo: check if needed and possible + return true; + } + + async getSchema(): Promise { + return {}; + } + + async getPipeline(): Promise { + return null; + } + + async deploy(pipeline: RdiPipeline): Promise { + return null; + } + + async deployJob(job: RdiJob): Promise { + return null; + } + + async disconnect(): Promise { + return undefined; + } +} diff --git a/redisinsight/api/src/modules/rdi/client/rdi.client.ts b/redisinsight/api/src/modules/rdi/client/rdi.client.ts new file mode 100644 index 0000000000..bdc18726b0 --- /dev/null +++ b/redisinsight/api/src/modules/rdi/client/rdi.client.ts @@ -0,0 +1,54 @@ +import { + RdiClientMetadata, RdiJob, RdiPipeline, RdiType, +} from 'src/modules/rdi/models'; + +export abstract class RdiClient { + abstract type: RdiType; + + public readonly id: string; + + public lastUsed: number = Date.now(); + + constructor( + protected readonly metadata: RdiClientMetadata, + protected readonly client: unknown, + ) { + this.id = RdiClient.generateId(this.metadata); + } + + abstract isConnected(): Promise; + + abstract getSchema(): Promise; + + abstract getPipeline(): Promise; + + abstract deploy(pipeline: RdiPipeline): Promise; + + abstract deployJob(job: RdiJob): Promise; + + abstract disconnect(): Promise; + + public setLastUsed(): void { + this.lastUsed = Date.now(); + } + + static generateId(cm: RdiClientMetadata): string { + const empty = '(nil)'; + const separator = '_'; + + const id = [ + cm.id, + ].join(separator); + + const uId = [ + cm.sessionMetadata?.userId || empty, + cm.sessionMetadata?.sessionId || empty, + cm.sessionMetadata?.uniqueId || empty, + ].join(separator); + + return [ + id, + uId, + ].join(separator); + } +} diff --git a/redisinsight/api/src/modules/rdi/decorators/index.ts b/redisinsight/api/src/modules/rdi/decorators/index.ts new file mode 100644 index 0000000000..30f9dc838f --- /dev/null +++ b/redisinsight/api/src/modules/rdi/decorators/index.ts @@ -0,0 +1 @@ +export * from './request.rdi.client.metadata.decorator'; diff --git a/redisinsight/api/src/modules/rdi/decorators/request.rdi.client.metadata.decorator.ts b/redisinsight/api/src/modules/rdi/decorators/request.rdi.client.metadata.decorator.ts new file mode 100644 index 0000000000..4f42dc1c88 --- /dev/null +++ b/redisinsight/api/src/modules/rdi/decorators/request.rdi.client.metadata.decorator.ts @@ -0,0 +1,26 @@ +import { BadRequestException, createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { sessionMetadataFromRequestFactory } from 'src/common/decorators'; +import { plainToClass } from 'class-transformer'; +import { RdiClientMetadata } from 'src/modules/rdi/models'; +import { Validator } from 'class-validator'; + +const validator = new Validator(); + +export const RequestRdiClientMetadata = createParamDecorator((data: unknown, ctx: ExecutionContext) => { + const req = ctx.switchToHttp().getRequest(); + + const rdiClientMetadata = plainToClass(RdiClientMetadata, { + id: req.params?.['id'], + sessionMetadata: sessionMetadataFromRequestFactory(undefined, ctx), + }); + + const errors = validator.validateSync(rdiClientMetadata, { + whitelist: false, // we need this to allow additional fields if needed for flexibility + }); + + if (errors?.length) { + throw new BadRequestException(Object.values(errors[0].constraints) || 'Bad request'); + } + + return rdiClientMetadata; +}); diff --git a/redisinsight/api/src/modules/rdi/dto/create.rdi.dto.ts b/redisinsight/api/src/modules/rdi/dto/create.rdi.dto.ts new file mode 100644 index 0000000000..47839fdad9 --- /dev/null +++ b/redisinsight/api/src/modules/rdi/dto/create.rdi.dto.ts @@ -0,0 +1,16 @@ +import { OmitType } from '@nestjs/swagger'; +import { Rdi, RdiType } from 'src/modules/rdi/models'; +import { ValidateIf } from 'class-validator'; + +export class CreateRdiDto extends OmitType(Rdi, [ + 'id', 'lastConnection', +] as const) { + @ValidateIf(({ type }) => type === RdiType.API) + url?: string; + + @ValidateIf(({ type }) => type === RdiType.GEARS) + host?: string; + + @ValidateIf(({ type }) => type === RdiType.GEARS) + port?: number; +} diff --git a/redisinsight/api/src/modules/rdi/dto/index.ts b/redisinsight/api/src/modules/rdi/dto/index.ts new file mode 100644 index 0000000000..ece52f347e --- /dev/null +++ b/redisinsight/api/src/modules/rdi/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create.rdi.dto'; +export * from './update.rdi.dto'; diff --git a/redisinsight/api/src/modules/rdi/dto/update.rdi.dto.ts b/redisinsight/api/src/modules/rdi/dto/update.rdi.dto.ts new file mode 100644 index 0000000000..b41c36b706 --- /dev/null +++ b/redisinsight/api/src/modules/rdi/dto/update.rdi.dto.ts @@ -0,0 +1,6 @@ +import { OmitType, PartialType } from '@nestjs/swagger'; +import { Rdi } from 'src/modules/rdi/models'; + +export class UpdateRdiDto extends PartialType(OmitType(Rdi, [ + 'id', 'lastConnection', +] as const)) {} diff --git a/redisinsight/api/src/modules/rdi/entities/rdi.entity.ts b/redisinsight/api/src/modules/rdi/entities/rdi.entity.ts new file mode 100644 index 0000000000..3aa11f31ea --- /dev/null +++ b/redisinsight/api/src/modules/rdi/entities/rdi.entity.ts @@ -0,0 +1,42 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { Expose } from 'class-transformer'; +import { RdiType } from 'src/modules/rdi/models'; + +@Entity('rdi') +export class RdiEntity { + @Expose() + @PrimaryGeneratedColumn('uuid') + id: string; + + @Expose() + @Column({ nullable: false }) + type: RdiType; + + @Expose() + @Column({ nullable: true }) + url: string; + + @Expose() + @Column({ nullable: true }) + host: string; + + @Expose() + @Column({ nullable: true }) + port: number; + + @Expose() + @Column({ nullable: false }) + name: string; + + @Expose() + @Column({ nullable: true }) + username: string; + + @Expose() + @Column({ nullable: true }) + password: string; + + @Expose() + @Column({ type: 'datetime', nullable: true }) + lastConnection: Date; +} diff --git a/redisinsight/api/src/modules/rdi/models/index.ts b/redisinsight/api/src/modules/rdi/models/index.ts new file mode 100644 index 0000000000..40afc5799b --- /dev/null +++ b/redisinsight/api/src/modules/rdi/models/index.ts @@ -0,0 +1,4 @@ +export * from './rdi.client.metadata'; +export * from './rdi'; +export * from './rdi-job'; +export * from './rdi-pipeline'; diff --git a/redisinsight/api/src/modules/rdi/models/rdi-job.ts b/redisinsight/api/src/modules/rdi/models/rdi-job.ts new file mode 100644 index 0000000000..d89cd41c7d --- /dev/null +++ b/redisinsight/api/src/modules/rdi/models/rdi-job.ts @@ -0,0 +1,4 @@ +export class RdiJob { + // todo: define props + name: string; +} diff --git a/redisinsight/api/src/modules/rdi/models/rdi-pipeline.ts b/redisinsight/api/src/modules/rdi/models/rdi-pipeline.ts new file mode 100644 index 0000000000..c1953632d9 --- /dev/null +++ b/redisinsight/api/src/modules/rdi/models/rdi-pipeline.ts @@ -0,0 +1,9 @@ +import { RdiJob } from 'src/modules/rdi/models/rdi-job'; + +export class RdiPipeline { + // todo: defined high-level schema. not sure if we need it at all since we are not going to validate it or we are? + + connection: unknown; + + jobs: RdiJob[]; +} diff --git a/redisinsight/api/src/modules/rdi/models/rdi.client.metadata.ts b/redisinsight/api/src/modules/rdi/models/rdi.client.metadata.ts new file mode 100644 index 0000000000..882738469b --- /dev/null +++ b/redisinsight/api/src/modules/rdi/models/rdi.client.metadata.ts @@ -0,0 +1,15 @@ +import { Session, SessionMetadata } from 'src/common/models/session'; +import { Type } from 'class-transformer'; +import { + IsNotEmpty, IsString, +} from 'class-validator'; + +export class RdiClientMetadata { + @IsNotEmpty() + @Type(() => Session) + sessionMetadata: SessionMetadata; + + @IsNotEmpty() + @IsString() + id: string; +} diff --git a/redisinsight/api/src/modules/rdi/models/rdi.ts b/redisinsight/api/src/modules/rdi/models/rdi.ts new file mode 100644 index 0000000000..898dd1cd9e --- /dev/null +++ b/redisinsight/api/src/modules/rdi/models/rdi.ts @@ -0,0 +1,101 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { + IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, MaxLength, ValidateIf +} from 'class-validator'; + +export enum RdiType { + API = 'api', + GEARS = 'gears', +} + +export class Rdi { + @ApiProperty({ + description: 'RDI id.', + type: String, + }) + @Expose() + id: string; + + @ApiProperty({ + description: 'RDI type', + enum: RdiType, + }) + @Expose() + @IsNotEmpty() + @IsEnum(RdiType, { + message: `Type must be a valid enum value from: ${Object.values(RdiType)}.`, + }) + type: RdiType; + + @ApiPropertyOptional({ + description: 'Base url of API to connect to (for API type only)', + example: 'https://example.com', + type: String, + }) + @IsOptional() + @Expose() + @IsNotEmpty() + @IsString() + url?: string; + + @ApiPropertyOptional({ + description: 'The hostname of RDI database, for example redis.acme.com. (for GEARS type only)', + type: String, + }) + @IsOptional() + @Expose() + @IsNotEmpty() + @IsString() + host?: string; + + @ApiPropertyOptional({ + description: 'The port RDI database is available on. (for GEARS type only)', + type: Number, + default: 6379, + }) + @IsOptional() + @Expose() + @IsNotEmpty() + @IsInt() + port?: number; + + @ApiProperty({ + description: 'A name to associate with RDI', + type: String, + }) + @Expose() + @IsString() + @IsNotEmpty() + @MaxLength(500) + name: string; + + @ApiPropertyOptional({ + description: 'RDI or API username', + type: String, + }) + @Expose() + @IsString() + @IsNotEmpty() + @IsOptional() + username?: string; + + @ApiPropertyOptional({ + description: 'RDI or API password', + type: String, + }) + @Expose() + @IsString() + @IsNotEmpty() + @IsOptional() + password?: string; + + @ApiProperty({ + description: 'Time of the last connection to RDI.', + type: String, + format: 'date-time', + example: '2021-01-06T12:44:39.000Z', + }) + @Expose() + lastConnection: Date; +} diff --git a/redisinsight/api/src/modules/rdi/providers/rdi.client.factory.ts b/redisinsight/api/src/modules/rdi/providers/rdi.client.factory.ts new file mode 100644 index 0000000000..91c3318e41 --- /dev/null +++ b/redisinsight/api/src/modules/rdi/providers/rdi.client.factory.ts @@ -0,0 +1,29 @@ +import { BadRequestException, Injectable, NotImplementedException } from '@nestjs/common'; +import { RdiClient } from 'src/modules/rdi/client/rdi.client'; +import { Rdi, RdiClientMetadata, RdiType } from 'src/modules/rdi/models'; +import axios from 'axios'; +import { ApiRdiClient } from 'src/modules/rdi/client/api.rdi.client'; + +@Injectable() +export class RdiClientFactory { + async createClient(clientMetadata: RdiClientMetadata, rdi: Rdi): Promise { + switch (rdi.type) { + case RdiType.API: + return this.createApiRdiClient(clientMetadata, rdi); + case RdiType.GEARS: + throw new NotImplementedException(); + default: + throw new BadRequestException('Unsupported RDI type'); + } + } + + async createApiRdiClient(clientMetadata: RdiClientMetadata, rdi: Rdi): Promise { + const apiClient = axios.create({ + baseURL: rdi.url, + }); + + // todo: login with credentials and store them in the apiClient + + return new ApiRdiClient(clientMetadata, apiClient); + } +} diff --git a/redisinsight/api/src/modules/rdi/providers/rdi.client.provider.ts b/redisinsight/api/src/modules/rdi/providers/rdi.client.provider.ts new file mode 100644 index 0000000000..f9701e08aa --- /dev/null +++ b/redisinsight/api/src/modules/rdi/providers/rdi.client.provider.ts @@ -0,0 +1,33 @@ +import { RdiClient } from 'src/modules/rdi/client/rdi.client'; +import { Injectable } from '@nestjs/common'; +import { RdiClientMetadata } from 'src/modules/rdi/models'; +import { RdiService } from 'src/modules/rdi/rdi.service'; +import { RdiClientStorage } from 'src/modules/rdi/providers/rdi.client.storage'; +import { RdiClientFactory } from 'src/modules/rdi/providers/rdi.client.factory'; + +@Injectable() +export class RdiClientProvider { + constructor( + private readonly rdiService: RdiService, + private readonly rdiClientStorage: RdiClientStorage, + private readonly rdiClientFactory: RdiClientFactory, + ) {} + + async getOrCreate(rdiClientMetadata: RdiClientMetadata): Promise { + let client = await this.rdiClientStorage.getByMetadata(rdiClientMetadata); + + if (client) { + return client; + } + + client = await this.create(rdiClientMetadata); + + return this.rdiClientStorage.set(client); + } + + async create(clientMetadata: RdiClientMetadata): Promise { + const rdi = await this.rdiService.get(clientMetadata.id); + + return this.rdiClientFactory.createClient(clientMetadata, rdi); + } +} diff --git a/redisinsight/api/src/modules/rdi/providers/rdi.client.storage.ts b/redisinsight/api/src/modules/rdi/providers/rdi.client.storage.ts new file mode 100644 index 0000000000..fe7f3894f1 --- /dev/null +++ b/redisinsight/api/src/modules/rdi/providers/rdi.client.storage.ts @@ -0,0 +1,54 @@ +import { RdiClient } from 'src/modules/rdi/client/rdi.client'; +import { Injectable, Logger } from '@nestjs/common'; +import { RdiClientMetadata } from 'src/modules/rdi/models'; + +@Injectable() +export class RdiClientStorage { + private readonly logger = new Logger('RdiClientStorage'); + + private readonly clients: Map = new Map(); + + // todo: sync clients (idle check) + + async get(id: string): Promise { + const client = this.clients.get(id); + + if (client) { + client.setLastUsed(); + if (!(await client.isConnected())) { + await this.delete(id); + return null; + } + } + + return client; + } + + async getByMetadata(rdiClientMetadata: RdiClientMetadata): Promise { + return this.clients.get(RdiClient.generateId(rdiClientMetadata)); + } + + async delete(id: string): Promise { + const client = this.clients.get(id); + + if (client) { + await client.disconnect() + .catch((e) => this.logger.warn('Unable to disconnect client', e)); + + this.clients.delete(id); + + return 1; + } + + return 0; + } + + async set(client: RdiClient): Promise { + // todo: client metadata check + // todo: existing client check + // const existingClient = this.get(client.id); + this.clients.set(client.id, client); + + return client; + } +} diff --git a/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts b/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts new file mode 100644 index 0000000000..97a7bb82cd --- /dev/null +++ b/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts @@ -0,0 +1,29 @@ +import { + ClassSerializerInterceptor, Controller, Get, UseInterceptors, UsePipes, ValidationPipe, +} from '@nestjs/common'; +import { Rdi, RdiClientMetadata } from 'src/modules/rdi/models'; +import { ApiTags } from '@nestjs/swagger'; +import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; +import { RdiPipelineService } from 'src/modules/rdi/rdi-pipeline.service'; +import { RequestRdiClientMetadata } from 'src/modules/rdi/decorators'; + +@ApiTags('RDI') +@UsePipes(new ValidationPipe({ transform: true })) +@UseInterceptors(ClassSerializerInterceptor) +@Controller('rdi/:id/pipeline') +export class RdiPipelineController { + constructor( + private readonly rdiPipelineService: RdiPipelineService, + ) {} + + @Get() + @ApiEndpoint({ + description: 'Get pipeline schema', + responses: [{ status: 200, type: Rdi }], + }) + async getSchema( + @RequestRdiClientMetadata() rdiClientMetadata: RdiClientMetadata, + ): Promise { + return this.rdiPipelineService.getSchema(rdiClientMetadata); + } +} diff --git a/redisinsight/api/src/modules/rdi/rdi-pipeline.service.ts b/redisinsight/api/src/modules/rdi/rdi-pipeline.service.ts new file mode 100644 index 0000000000..05813639cb --- /dev/null +++ b/redisinsight/api/src/modules/rdi/rdi-pipeline.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { RdiClientMetadata } from 'src/modules/rdi/models'; +import { RdiClientProvider } from 'src/modules/rdi/providers/rdi.client.provider'; + +@Injectable() +export class RdiPipelineService { + constructor( + private readonly rdiClientProvider: RdiClientProvider, + ) {} + + async getSchema(rdiClientMetadata: RdiClientMetadata): Promise { + const client = await this.rdiClientProvider.getOrCreate(rdiClientMetadata); + + const schema = await client.getSchema(); + + // todo: process somehow + + return schema; + } +} diff --git a/redisinsight/api/src/modules/rdi/rdi.controller.ts b/redisinsight/api/src/modules/rdi/rdi.controller.ts new file mode 100644 index 0000000000..4deed85ca6 --- /dev/null +++ b/redisinsight/api/src/modules/rdi/rdi.controller.ts @@ -0,0 +1,68 @@ +import { + Body, + ClassSerializerInterceptor, Controller, Delete, Get, Param, Patch, Post, UseInterceptors, UsePipes, ValidationPipe, +} from '@nestjs/common'; +import { Rdi } from 'src/modules/rdi/models'; +import { ApiTags } from '@nestjs/swagger'; +import { RdiService } from 'src/modules/rdi/rdi.service'; +import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; +import { CreateRdiDto, UpdateRdiDto } from 'src/modules/rdi/dto'; + +@ApiTags('RDI') +@UsePipes(new ValidationPipe({ transform: true })) +@UseInterceptors(ClassSerializerInterceptor) +@Controller('rdi') +export class RdiController { + constructor( + private readonly rdiService: RdiService, + ) {} + + @Get() + @ApiEndpoint({ + description: 'Get RDI list', + responses: [{ status: 200, isArray: true, type: Rdi }], + }) + async list(): Promise { + return this.rdiService.list(); + } + + @Get('/:id') + @ApiEndpoint({ + description: 'Get RDI by id', + responses: [{ status: 200, type: Rdi }], + }) + async get(@Param('id') id: string): Promise { + return this.rdiService.get(id); + } + + @Post() + @ApiEndpoint({ + description: 'Create RDI', + statusCode: 201, + responses: [{ status: 201, type: Rdi }], + }) + async create(@Body() dto: CreateRdiDto): Promise { + return this.rdiService.create(dto); + } + + @Patch('/:id') + @ApiEndpoint({ + description: 'Update RDI', + responses: [{ status: 200, type: Rdi }], + }) + async update( + @Param('id') id: string, + @Body() dto: UpdateRdiDto, + ): Promise { + return this.rdiService.update(id, dto); + } + + @Delete('/:id') + @ApiEndpoint({ + description: 'Delete RDI', + responses: [{ status: 200 }], + }) + async delete(@Param('id') id: string): Promise { + return this.rdiService.delete(id); + } +} diff --git a/redisinsight/api/src/modules/rdi/rdi.module.ts b/redisinsight/api/src/modules/rdi/rdi.module.ts new file mode 100644 index 0000000000..f11c6adf7a --- /dev/null +++ b/redisinsight/api/src/modules/rdi/rdi.module.ts @@ -0,0 +1,36 @@ +import { Module, Type } from '@nestjs/common'; +import { RdiController } from 'src/modules/rdi/rdi.controller'; +import { RdiPipelineController } from 'src/modules/rdi/rdi-pipeline.controller'; +import { RdiService } from 'src/modules/rdi/rdi.service'; +import { RdiPipelineService } from 'src/modules/rdi/rdi-pipeline.service'; +import { RdiRepository } from 'src/modules/rdi/repository/rdi.repository'; +import { LocalRdiRepository } from 'src/modules/rdi/repository/local.rdi.repository'; +import { RdiClientProvider } from 'src/modules/rdi/providers/rdi.client.provider'; +import { RdiClientStorage } from 'src/modules/rdi/providers/rdi.client.storage'; +import { RdiClientFactory } from 'src/modules/rdi/providers/rdi.client.factory'; + +@Module({}) +export class RdiModule { + static register( + rdiRepository: Type = LocalRdiRepository, + ) { + return { + module: RdiModule, + controllers: [ + RdiController, + RdiPipelineController, + ], + providers: [ + RdiService, + RdiPipelineService, + RdiClientProvider, + RdiClientStorage, + RdiClientFactory, + { + provide: RdiRepository, + useClass: rdiRepository, + }, + ], + }; + } +} diff --git a/redisinsight/api/src/modules/rdi/rdi.service.ts b/redisinsight/api/src/modules/rdi/rdi.service.ts new file mode 100644 index 0000000000..f0b4768925 --- /dev/null +++ b/redisinsight/api/src/modules/rdi/rdi.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { Rdi } from 'src/modules/rdi/models'; +import { CreateRdiDto, UpdateRdiDto } from 'src/modules/rdi/dto'; +import { RdiRepository } from 'src/modules/rdi/repository/rdi.repository'; + +@Injectable() +export class RdiService { + constructor( + private readonly repository: RdiRepository, + ) {} + + async list(): Promise { + return []; + } + + async get(id: string): Promise { + const rdi = await this.repository.get(id); + + if (!rdi) { + throw new Error('TBD not found'); + } + + return rdi; + } + + async update(id: string, dto: UpdateRdiDto): Promise { + return null; + } + + async create(dto: CreateRdiDto): Promise { + return null; + } + + async delete(id: string): Promise { + + } +} diff --git a/redisinsight/api/src/modules/rdi/repository/local.rdi.repository.ts b/redisinsight/api/src/modules/rdi/repository/local.rdi.repository.ts new file mode 100644 index 0000000000..1b77f22049 --- /dev/null +++ b/redisinsight/api/src/modules/rdi/repository/local.rdi.repository.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { classToClass } from 'src/utils'; +import { EncryptionService } from 'src/modules/encryption/encryption.service'; +import { ModelEncryptor } from 'src/modules/encryption/model.encryptor'; +import { RdiRepository } from 'src/modules/rdi/repository/rdi.repository'; +import { RdiEntity } from 'src/modules/rdi/entities/rdi.entity'; +import { Rdi } from 'src/modules/rdi/models'; + +@Injectable() +export class LocalRdiRepository extends RdiRepository { + private readonly modelEncryptor: ModelEncryptor; + + constructor( + @InjectRepository(RdiEntity) + private readonly repository: Repository, + private readonly encryptionService: EncryptionService, + ) { + super(); + this.modelEncryptor = new ModelEncryptor(this.encryptionService, ['password']); + } + + /** + * @inheritDoc + */ + public async get( + id: string, + ignoreEncryptionErrors: boolean = false, + ): Promise { + const entity = await this.repository.findOneBy({ id }); + + if (!entity) { + return null; + } + + return classToClass(Rdi, await this.modelEncryptor.decryptEntity(entity, ignoreEncryptionErrors)); + } + + /** + * @inheritDoc + */ + public async list(): Promise { + const entities = await this.repository + .createQueryBuilder('r') + .select([ + 'r.id', 'r.name', 'r.host', 'r.port', 'r.type', 'r.lastConnection', + ]) + .getMany(); + + return entities.map((entity) => classToClass(Rdi, entity)); + } + + /** + * @inheritDoc + */ + public async create(rdi: Rdi): Promise { + const entity = classToClass(RdiEntity, rdi); + return classToClass( + Rdi, + await this.modelEncryptor.decryptEntity( + await this.repository.save( + await this.modelEncryptor.encryptEntity(entity), + ), + ), + ); + } + + /** + * @inheritDoc + */ + public async update(id: string, rdi: Partial): Promise { + // todo: the same way as PATCH for databases + return null; + } + + /** + * @inheritDoc + */ + public async delete(id: string): Promise { + await this.repository.delete(id); + } +} diff --git a/redisinsight/api/src/modules/rdi/repository/rdi.repository.ts b/redisinsight/api/src/modules/rdi/repository/rdi.repository.ts new file mode 100644 index 0000000000..9511c97425 --- /dev/null +++ b/redisinsight/api/src/modules/rdi/repository/rdi.repository.ts @@ -0,0 +1,37 @@ +import { Rdi } from 'src/modules/rdi/models'; + +export abstract class RdiRepository { + /** + * List of RDIs (limited fields only) + * Fields: ['id', 'name', 'host', 'port', 'type', 'lastConnection'] + * @return Rdi[] + */ + abstract list(): Promise; + + /** + * Get RDI connection details by id + * @param id + * @param ignoreEncryptionErrors + * @return Rdi + */ + abstract get(id: string, ignoreEncryptionErrors?: boolean): Promise; + + /** + * Create RDI connection + * @param rdi + */ + abstract create(rdi: Rdi): Promise; + + /** + * Update RDI connection config + * @param id + * @param rdi + */ + abstract update(id: string, rdi: Partial): Promise; + + /** + * Delete RDI by id + * @param id + */ + abstract delete(id: string): Promise; +} From c88a48b98aa7ce450d1e5bca2ff1f5b66e76ef48 Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 25 Oct 2023 13:36:47 +0400 Subject: [PATCH 002/566] #RI-5015_rdi_schema --- redisinsight/api/src/modules/rdi/client/api.rdi.client.ts | 4 +++- redisinsight/api/src/modules/rdi/constants/index.ts | 3 +++ .../api/src/modules/rdi/rdi-pipeline.controller.ts | 2 +- redisinsight/api/src/modules/rdi/rdi.service.ts | 8 ++++++-- 4 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 redisinsight/api/src/modules/rdi/constants/index.ts diff --git a/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts b/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts index 98d2d25b9a..7658aea326 100644 --- a/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts +++ b/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts @@ -1,5 +1,6 @@ import { RdiJob, RdiPipeline, RdiType } from 'src/modules/rdi/models'; import { RdiClient } from 'src/modules/rdi/client/rdi.client'; +import { RdiUrl } from 'src/modules/rdi/constants' import { AxiosInstance } from 'axios'; export class ApiRdiClient extends RdiClient { @@ -13,7 +14,8 @@ export class ApiRdiClient extends RdiClient { } async getSchema(): Promise { - return {}; + const response = await this.client.get(RdiUrl.GetSchema); + return response.data; } async getPipeline(): Promise { diff --git a/redisinsight/api/src/modules/rdi/constants/index.ts b/redisinsight/api/src/modules/rdi/constants/index.ts new file mode 100644 index 0000000000..1b42f25c7c --- /dev/null +++ b/redisinsight/api/src/modules/rdi/constants/index.ts @@ -0,0 +1,3 @@ +export enum RdiUrl { + GetSchema = '/schema', +} \ No newline at end of file diff --git a/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts b/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts index 97a7bb82cd..0b00813a30 100644 --- a/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts +++ b/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts @@ -16,7 +16,7 @@ export class RdiPipelineController { private readonly rdiPipelineService: RdiPipelineService, ) {} - @Get() + @Get('/schema') @ApiEndpoint({ description: 'Get pipeline schema', responses: [{ status: 200, type: Rdi }], diff --git a/redisinsight/api/src/modules/rdi/rdi.service.ts b/redisinsight/api/src/modules/rdi/rdi.service.ts index f0b4768925..41833e6f4e 100644 --- a/redisinsight/api/src/modules/rdi/rdi.service.ts +++ b/redisinsight/api/src/modules/rdi/rdi.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { Rdi } from 'src/modules/rdi/models'; import { CreateRdiDto, UpdateRdiDto } from 'src/modules/rdi/dto'; import { RdiRepository } from 'src/modules/rdi/repository/rdi.repository'; +import { classToClass } from "src/utils"; @Injectable() export class RdiService { @@ -10,7 +11,7 @@ export class RdiService { ) {} async list(): Promise { - return []; + return await this.repository.list(); } async get(id: string): Promise { @@ -28,7 +29,10 @@ export class RdiService { } async create(dto: CreateRdiDto): Promise { - return null; + const model = classToClass(Rdi, dto); + model.lastConnection = new Date(); + + return await this.repository.create(model); } async delete(id: string): Promise { From 1251b56275e14d1beba8147b537e853e4e980c6e Mon Sep 17 00:00:00 2001 From: Amir Allayarov Date: Wed, 25 Oct 2023 13:40:34 +0400 Subject: [PATCH 003/566] #RI-5061 - update --- redisinsight/api/src/modules/rdi/client/api.rdi.client.ts | 2 +- redisinsight/api/src/modules/rdi/constants/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts b/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts index 7658aea326..60e08697cd 100644 --- a/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts +++ b/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts @@ -1,6 +1,6 @@ import { RdiJob, RdiPipeline, RdiType } from 'src/modules/rdi/models'; import { RdiClient } from 'src/modules/rdi/client/rdi.client'; -import { RdiUrl } from 'src/modules/rdi/constants' +import { RdiUrl } from 'src/modules/rdi/constants'; import { AxiosInstance } from 'axios'; export class ApiRdiClient extends RdiClient { diff --git a/redisinsight/api/src/modules/rdi/constants/index.ts b/redisinsight/api/src/modules/rdi/constants/index.ts index 1b42f25c7c..68796fecb0 100644 --- a/redisinsight/api/src/modules/rdi/constants/index.ts +++ b/redisinsight/api/src/modules/rdi/constants/index.ts @@ -1,3 +1,3 @@ export enum RdiUrl { GetSchema = '/schema', -} \ No newline at end of file +} From 7f0d14be2a31bab72bbdd683fc792865ab2fccd0 Mon Sep 17 00:00:00 2001 From: Amir Allayarov <100589048+AmirAllayarovSofteq@users.noreply.github.com> Date: Fri, 1 Dec 2023 17:04:20 +0400 Subject: [PATCH 004/566] #RI-5133 - update pipeline endpoint (#2840) --- .../api/src/modules/rdi/client/api.rdi.client.ts | 3 ++- redisinsight/api/src/modules/rdi/constants/index.ts | 1 + .../api/src/modules/rdi/rdi-pipeline.controller.ts | 13 ++++++++++++- .../api/src/modules/rdi/rdi-pipeline.service.ts | 8 +++++--- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts b/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts index 60e08697cd..6e3f335e17 100644 --- a/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts +++ b/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts @@ -19,7 +19,8 @@ export class ApiRdiClient extends RdiClient { } async getPipeline(): Promise { - return null; + const response = await this.client.get(RdiUrl.GetPipeline); + return response.data; } async deploy(pipeline: RdiPipeline): Promise { diff --git a/redisinsight/api/src/modules/rdi/constants/index.ts b/redisinsight/api/src/modules/rdi/constants/index.ts index 68796fecb0..3c888a8a90 100644 --- a/redisinsight/api/src/modules/rdi/constants/index.ts +++ b/redisinsight/api/src/modules/rdi/constants/index.ts @@ -1,3 +1,4 @@ export enum RdiUrl { GetSchema = '/schema', + GetPipeline = '/pipeline', } diff --git a/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts b/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts index 0b00813a30..3783ea8ec9 100644 --- a/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts +++ b/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts @@ -1,7 +1,7 @@ import { ClassSerializerInterceptor, Controller, Get, UseInterceptors, UsePipes, ValidationPipe, } from '@nestjs/common'; -import { Rdi, RdiClientMetadata } from 'src/modules/rdi/models'; +import { Rdi, RdiPipeline, RdiClientMetadata } from 'src/modules/rdi/models'; import { ApiTags } from '@nestjs/swagger'; import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; import { RdiPipelineService } from 'src/modules/rdi/rdi-pipeline.service'; @@ -26,4 +26,15 @@ export class RdiPipelineController { ): Promise { return this.rdiPipelineService.getSchema(rdiClientMetadata); } + + @Get('/') + @ApiEndpoint({ + description: 'Get pipeline', + responses: [{ status: 200, type: RdiPipeline }], + }) + async getPipeline( + @RequestRdiClientMetadata() rdiClientMetadata: RdiClientMetadata, + ): Promise { + return this.rdiPipelineService.getPipeline(rdiClientMetadata); + } } diff --git a/redisinsight/api/src/modules/rdi/rdi-pipeline.service.ts b/redisinsight/api/src/modules/rdi/rdi-pipeline.service.ts index 05813639cb..85203c8d79 100644 --- a/redisinsight/api/src/modules/rdi/rdi-pipeline.service.ts +++ b/redisinsight/api/src/modules/rdi/rdi-pipeline.service.ts @@ -11,10 +11,12 @@ export class RdiPipelineService { async getSchema(rdiClientMetadata: RdiClientMetadata): Promise { const client = await this.rdiClientProvider.getOrCreate(rdiClientMetadata); - const schema = await client.getSchema(); + return await client.getSchema(); + } - // todo: process somehow + async getPipeline(rdiClientMetadata: RdiClientMetadata): Promise { + const client = await this.rdiClientProvider.getOrCreate(rdiClientMetadata); - return schema; + return await client.getPipeline(); } } From 8012166f4a7bf4676ff21d5a630f644b0ef9e8ea Mon Sep 17 00:00:00 2001 From: Amir Allayarov <100589048+AmirAllayarovSofteq@users.noreply.github.com> Date: Mon, 4 Dec 2023 14:17:15 +0400 Subject: [PATCH 005/566] Fe/feature/ri 5133 rdi config (#2841) * #RI-5133 - update pipeline endpoint --- redisinsight/ui/src/assets/img/icons/file.svg | 10 ++ .../main-router/constants/defaultRoutes.ts | 8 +- .../constants/sub-routes/rdiRoutes.ts | 18 +-- .../monaco-yaml/MonacoYaml.spec.tsx | 10 ++ .../components/monaco-yaml/MonacoYaml.tsx | 32 ++++ .../components/monaco-yaml/index.ts | 3 + .../ui/src/components/monaco-editor/index.ts | 2 + redisinsight/ui/src/constants/api.ts | 2 + redisinsight/ui/src/constants/links.ts | 1 + redisinsight/ui/src/constants/pages.ts | 3 + .../pages/rdi/pipeline/PipelinePage.spec.tsx | 45 ++++++ .../src/pages/rdi/pipeline/PipelinePage.tsx | 35 +++++ .../rdi/pipeline/PipelinePageRouter.spec.tsx | 17 ++ .../pages/rdi/pipeline/PipelinePageRouter.tsx | 17 ++ .../components/navigation/Navigation.spec.tsx | 36 +++++ .../components/navigation/Navigation.tsx | 88 +++++++++++ .../pipeline/components/navigation/index.ts | 3 + .../components/navigation/styles.module.scss | 78 ++++++++++ .../ui/src/pages/rdi/pipeline/index.ts | 3 + .../rdi/pipeline/pages/config/Config.spec.tsx | 48 ++++++ .../rdi/pipeline/pages/config/Config.tsx | 73 +++++++++ .../pages/rdi/pipeline/pages/config/index.ts | 3 + .../pipeline/pages/config/styles.module.scss | 33 ++++ .../rdi/pipeline/pages/prepare/Prepare.tsx | 15 ++ .../pages/rdi/pipeline/pages/prepare/index.ts | 3 + .../src/pages/rdi/pipeline/styles.module.scss | 21 +++ redisinsight/ui/src/slices/interfaces/rdi.ts | 12 ++ redisinsight/ui/src/slices/rdi/pipeline.ts | 73 +++++++++ redisinsight/ui/src/slices/store.ts | 4 +- .../ui/src/slices/tests/rdi/pipeline.spec.ts | 147 ++++++++++++++++++ redisinsight/ui/src/telemetry/pageViews.ts | 3 +- 31 files changed, 831 insertions(+), 15 deletions(-) create mode 100644 redisinsight/ui/src/assets/img/icons/file.svg create mode 100644 redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.spec.tsx create mode 100644 redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.tsx create mode 100644 redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/index.ts create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/PipelinePage.spec.tsx create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/PipelinePage.tsx create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/PipelinePageRouter.spec.tsx create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/PipelinePageRouter.tsx create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/components/navigation/Navigation.spec.tsx create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/components/navigation/Navigation.tsx create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/components/navigation/index.ts create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/components/navigation/styles.module.scss create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/index.ts create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/pages/config/Config.spec.tsx create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/pages/config/Config.tsx create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/pages/config/index.ts create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/pages/config/styles.module.scss create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/pages/prepare/Prepare.tsx create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/pages/prepare/index.ts create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/styles.module.scss create mode 100644 redisinsight/ui/src/slices/interfaces/rdi.ts create mode 100644 redisinsight/ui/src/slices/rdi/pipeline.ts create mode 100644 redisinsight/ui/src/slices/tests/rdi/pipeline.spec.ts diff --git a/redisinsight/ui/src/assets/img/icons/file.svg b/redisinsight/ui/src/assets/img/icons/file.svg new file mode 100644 index 0000000000..1d0a119992 --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/file.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts b/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts index c997876761..7b358d10b6 100644 --- a/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts +++ b/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts @@ -13,7 +13,8 @@ import WorkbenchPage from 'uiSrc/pages/workbench' import PubSubPage from 'uiSrc/pages/pub-sub' import AnalyticsPage from 'uiSrc/pages/analytics' import TriggeredFunctionsPage from 'uiSrc/pages/triggered-functions' -import RdiList from 'uiSrc/pages/rdi/home' +import RdiList from 'uiSrc/pages/rdi/home/RDIList' +import RdiPipeline from 'uiSrc/pages/rdi/pipeline/PipelinePage' import { ANALYTICS_ROUTES, RDI_ROUTES, TRIGGERED_FUNCTIONS_ROUTES } from './sub-routes' import COMMON_ROUTES from './commonRoutes' @@ -77,9 +78,8 @@ const ROUTES: IRoute[] = [ ], }, { - path: Pages.rdi, - // todo: add home rdi component - list of instances - component: RdiList, + path: '/integrate/:rdiInstanceId', + component: RdiPipeline, routes: RDI_ROUTES, }, { diff --git a/redisinsight/ui/src/components/main-router/constants/sub-routes/rdiRoutes.ts b/redisinsight/ui/src/components/main-router/constants/sub-routes/rdiRoutes.ts index 4590df87e1..4a691b8a4f 100644 --- a/redisinsight/ui/src/components/main-router/constants/sub-routes/rdiRoutes.ts +++ b/redisinsight/ui/src/components/main-router/constants/sub-routes/rdiRoutes.ts @@ -1,14 +1,14 @@ -import { IRoute } from 'uiSrc/constants' -import RdiList from 'uiSrc/pages/rdi/home' +import { IRoute, Pages } from 'uiSrc/constants' +import PreparePage from 'uiSrc/pages/rdi/pipeline/pages/prepare' +import ConfigPage from 'uiSrc/pages/rdi/pipeline/pages/config' export const RDI_ROUTES: IRoute[] = [ { - // todo: rename path - path: '/:rdiId', - // todo add component like Instance page - component: RdiList, - routes: [ - // todo: add page routes here - ], + path: Pages.rdiPipelinePrepare(':rdiInstanceId'), + component: PreparePage, + }, + { + path: Pages.rdiPipelineConfig(':rdiInstanceId'), + component: ConfigPage, }, ] diff --git a/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.spec.tsx b/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.spec.tsx new file mode 100644 index 0000000000..30c5684f3d --- /dev/null +++ b/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.spec.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import { render } from 'uiSrc/utils/test-utils' + +import MonacoYaml from './MonacoYaml' + +describe('MonacoYaml', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.tsx b/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.tsx new file mode 100644 index 0000000000..fed956f624 --- /dev/null +++ b/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api' + +import { MonacoEditor } from 'uiSrc/components/monaco-editor' +import { CommonProps } from 'uiSrc/components/monaco-editor/MonacoEditor' + +const MonacoYaml = (props: CommonProps) => { + const editorDidMount = ( + editor: monacoEditor.editor.IStandaloneCodeEditor, + monaco: typeof monacoEditor, + ) => { + monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ + validate: true, + schemaValidation: 'error', + schemaRequest: 'error', + trailingCommas: 'error' + }) + const messageContribution = editor.getContribution('editor.contrib.messageController') + editor.onDidAttemptReadOnlyEdit(() => messageContribution.dispose()) + } + + return ( + + ) +} + +export default MonacoYaml diff --git a/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/index.ts b/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/index.ts new file mode 100644 index 0000000000..f5cf5dbd17 --- /dev/null +++ b/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/index.ts @@ -0,0 +1,3 @@ +import MonacoYaml from './MonacoYaml' + +export default MonacoYaml diff --git a/redisinsight/ui/src/components/monaco-editor/index.ts b/redisinsight/ui/src/components/monaco-editor/index.ts index cbd6f985f9..38b43c4995 100644 --- a/redisinsight/ui/src/components/monaco-editor/index.ts +++ b/redisinsight/ui/src/components/monaco-editor/index.ts @@ -1,9 +1,11 @@ import MonacoEditor from './MonacoEditor' import MonacoJS from './components/monaco-js' import MonacoJson from './components/monaco-json' +import MonacoYaml from './components/monaco-yaml' export { MonacoEditor, MonacoJS, MonacoJson, + MonacoYaml, } diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index 6351c824b2..e6c1b4d532 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -136,6 +136,8 @@ enum ApiEndpoints { ANALYTICS_SEND_EVENT = 'analytics/send-event', ANALYTICS_SEND_PAGE = 'analytics/send-page', + + RDI_PIPELINE = 'rdi/pipeline' } export enum CustomHeaders { diff --git a/redisinsight/ui/src/constants/links.ts b/redisinsight/ui/src/constants/links.ts index e92975c40d..b68c303ebe 100644 --- a/redisinsight/ui/src/constants/links.ts +++ b/redisinsight/ui/src/constants/links.ts @@ -11,6 +11,7 @@ export const EXTERNAL_LINKS = { cloudConsole: 'https://app.redislabs.com/#/databases', tryFree: 'https://redis.com/try-free', docker: 'https://redis.io/docs/getting-started/install-stack/docker', + rdiQuickStart: 'https://docs.redis.com/latest/rdi/quickstart/', } export const UTM_CAMPAINGS: Record = { diff --git a/redisinsight/ui/src/constants/pages.ts b/redisinsight/ui/src/constants/pages.ts index 41c3ef03f7..d230d696bc 100644 --- a/redisinsight/ui/src/constants/pages.ts +++ b/redisinsight/ui/src/constants/pages.ts @@ -51,4 +51,7 @@ export const Pages = { `/${instanceId}/${PageNames.triggeredFunctions}/${PageNames.triggeredFunctionsFunctions}`, // rdi pages rdi: '/integrate', + rdiPipeline: (rdiInstance: string) => `integrate/${rdiInstance}/pipeline}`, + rdiPipelineConfig: (rdiInstance: string) => `/integrate/${rdiInstance}/pipeline/config`, + rdiPipelinePrepare: (rdiInstance: string) => `/integrate/${rdiInstance}/pipeline/prepare`, } diff --git a/redisinsight/ui/src/pages/rdi/pipeline/PipelinePage.spec.tsx b/redisinsight/ui/src/pages/rdi/pipeline/PipelinePage.spec.tsx new file mode 100644 index 0000000000..48f839586d --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/PipelinePage.spec.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { cloneDeep } from 'lodash' + +import { BrowserRouter } from 'react-router-dom' +import { instance, mock } from 'ts-mockito' +import { act, render, cleanup, mockedStore } from 'uiSrc/utils/test-utils' +import { getPipeline } from 'uiSrc/slices/rdi/pipeline' +import PipelinePage, { Props } from './PipelinePage' + +const mockedProps = mock() + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('PipelinePage', () => { + it('should render', () => { + expect( + render( + + + + ) + ).toBeTruthy() + }) + + it('should dispatch fetchRdiPipeline on render', async () => { + await act(() => { + render( + + + + ) + }) + + const expectedActions = [ + getPipeline(), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/pipeline/PipelinePage.tsx b/redisinsight/ui/src/pages/rdi/pipeline/PipelinePage.tsx new file mode 100644 index 0000000000..c08c20ada1 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/PipelinePage.tsx @@ -0,0 +1,35 @@ +import React, { useEffect } from 'react' +import { useLocation, useParams } from 'react-router-dom' +import { useDispatch } from 'react-redux' + +import { fetchRdiPipeline } from 'uiSrc/slices/rdi/pipeline' + +import Navigation from './components/navigation' +import PipelinePageRouter from './PipelinePageRouter' +import styles from './styles.module.scss' + +export interface Props { + routes: any[] +} + +const Pipeline = ({ routes = [] }: Props) => { + const { rdiInstanceId } = useParams<{ rdiInstanceId: string }>() + + const { pathname } = useLocation() + const dispatch = useDispatch() + + const path = pathname?.split('/').pop() || '' + + useEffect(() => { + dispatch(fetchRdiPipeline(rdiInstanceId)) + }, []) + + return ( +
+ + +
+ ) +} + +export default Pipeline diff --git a/redisinsight/ui/src/pages/rdi/pipeline/PipelinePageRouter.spec.tsx b/redisinsight/ui/src/pages/rdi/pipeline/PipelinePageRouter.spec.tsx new file mode 100644 index 0000000000..8d3e1b6d01 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/PipelinePageRouter.spec.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { render } from 'uiSrc/utils/test-utils' +import PipelinePageRouter from './PipelinePageRouter' + +const mockedRoutes = [ + { + path: '/page', + }, +] + +describe('PipelinePageRouter', () => { + it('should render', () => { + expect( + render(, { withRouter: true }) + ).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/pipeline/PipelinePageRouter.tsx b/redisinsight/ui/src/pages/rdi/pipeline/PipelinePageRouter.tsx new file mode 100644 index 0000000000..63246baf23 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/PipelinePageRouter.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { Switch } from 'react-router-dom' +import RouteWithSubRoutes from 'uiSrc/utils/routerWithSubRoutes' + +export interface Props { + routes: any[]; +} +const PipelinePageRouter = ({ routes }: Props) => ( + + {routes.map((route, i) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + +) + +export default React.memo(PipelinePageRouter) diff --git a/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/Navigation.spec.tsx b/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/Navigation.spec.tsx new file mode 100644 index 0000000000..3a9c598cce --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/Navigation.spec.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import reactRouterDom from 'react-router-dom' +import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' + +import Navigation from './Navigation' + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: jest.fn, + }), +})) + +describe('Navigation', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call proper history push after click on tabs', () => { + const pushMock = jest.fn() + reactRouterDom.useHistory = jest.fn().mockReturnValue({ push: pushMock }) + + render() + + fireEvent.click(screen.getByTestId('rdi-nav-btn-config')) + expect(pushMock).toBeCalledWith('/integrate/undefined/pipeline/config') + + fireEvent.click(screen.getByTestId('rdi-nav-btn-prepare')) + expect(pushMock).toBeCalledWith('/integrate/undefined/pipeline/prepare') + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/Navigation.tsx b/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/Navigation.tsx new file mode 100644 index 0000000000..b779b50611 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/Navigation.tsx @@ -0,0 +1,88 @@ +import React from 'react' +import { useHistory, useParams } from 'react-router-dom' +import { + EuiTextColor, + EuiText, + EuiTab, + EuiTabs, + EuiIcon, +} from '@elastic/eui' +import cx from 'classnames' + +import { Pages } from 'uiSrc/constants' +import { ReactComponent as FileIcon } from 'uiSrc/assets/img/icons/file.svg' + +import styles from './styles.module.scss' + +export interface IProps { + path: string +} + +enum RdiPipelineNav { + Prepare = 'prepare', + Config = 'config', +} + +const defaultNavList = [ + { + id: RdiPipelineNav.Prepare, + title: 'Prepare', + fileName: 'Select pipeline type', + }, + { + id: RdiPipelineNav.Config, + title: 'Configuration', + fileName: 'Target connection details' + } +] + +const Navigation = (props: IProps) => { + const { path } = props + + const history = useHistory() + + const { rdiInstanceId } = useParams<{ rdiInstanceId: string }>() + + // TODO resolve job id + const onSelectedTabChanged = (id: string) => { + if (id === RdiPipelineNav.Prepare) { + history.push(Pages.rdiPipelinePrepare(rdiInstanceId)) + } + if (id === RdiPipelineNav.Config) { + history.push(Pages.rdiPipelineConfig(rdiInstanceId)) + } + } + + const renderTabs = () => defaultNavList.map(({ id, title, fileName }) => ( + + <> + {title} +
{}} + onClick={() => onSelectedTabChanged(id)} + data-testid={`rdi-nav-btn-${id}`} + > + + {fileName} +
+ +
+ )) + + return ( +
+ Pipeline Management + {renderTabs()} +
+ ) +} + +export default Navigation diff --git a/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/index.ts b/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/index.ts new file mode 100644 index 0000000000..d4dd76dd61 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/index.ts @@ -0,0 +1,3 @@ +import Navigation from './Navigation' + +export default Navigation diff --git a/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/styles.module.scss b/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/styles.module.scss new file mode 100644 index 0000000000..baae24f07b --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/styles.module.scss @@ -0,0 +1,78 @@ +.wrapper { + border-radius: 8px; + width: 269px; + background-color: var(--euiColorLightestShade); + height: 100%; + + .title { + padding: 16px; + font: normal normal 500 14px/17px Graphik, sans-serif; + border-bottom: 1px solid var(--separatorColor); + } + + .tabs { + flex-direction: column; + + .tab { + cursor: default; + border-bottom: 1px solid var(--separatorColor) !important; + + &:global(.euiTab-isSelected) { + background-color: var(--tableRowSelectedColor) !important; + border-left: 2px solid var(--euiColorPrimary); + + .tabTitle { + font-weight: 500; + } + + .text { + color: var(--iconsDefaultHoverColor) !important; + } + + .fileIcon path { + fill: var(--iconsDefaultHoverColor); + } + } + + &:global(.euiTab:hover:not(.euiTab-isSelected)) { + text-decoration: none; + + .text:hover { + text-decoration: underline; + } + } + + :global(.euiTab__content) { + padding: 16px 12px 16px 16px; + } + + .tabTitle { + font: normal normal normal 14px/17px Graphik, sans-serif; + color: var(--externalLinkColor) !important; + text-align: left; + padding: 3px 0; + } + + .file { + display: flex; + padding: 3px 0; + } + + .text { + font: normal normal normal 14px/17px Graphik, sans-serif; + color: var(--euiTextSubduedColor) !important; + } + + .fileIcon { + width: 14px; + height: 15px; + margin-right: 6px; + + path { + fill: var(--euiTextSubduedColor); + } + } + } + } +} + diff --git a/redisinsight/ui/src/pages/rdi/pipeline/index.ts b/redisinsight/ui/src/pages/rdi/pipeline/index.ts new file mode 100644 index 0000000000..e21e78dd0c --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/index.ts @@ -0,0 +1,3 @@ +import PipelinePage from './PipelinePage' + +export default PipelinePage diff --git a/redisinsight/ui/src/pages/rdi/pipeline/pages/config/Config.spec.tsx b/redisinsight/ui/src/pages/rdi/pipeline/pages/config/Config.spec.tsx new file mode 100644 index 0000000000..472c8cf27b --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/pages/config/Config.spec.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { rdiPipelineSelector } from 'uiSrc/slices/rdi/pipeline' +import { render, screen } from 'uiSrc/utils/test-utils' + +import { sendPageViewTelemetry, TelemetryPageView } from 'uiSrc/telemetry' +import Config from './Config' + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendPageViewTelemetry: jest.fn(), +})) + +jest.mock('uiSrc/slices/rdi/pipeline', () => ({ + ...jest.requireActual('uiSrc/slices/rdi/pipeline'), + rdiPipelineSelector: jest.fn().mockReturnValue({ + loading: false, + error: '', + data: null, + }), +})) + +describe('Config', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call proper sendPageViewTelemetry', () => { + const sendPageViewTelemetryMock = jest.fn() + sendPageViewTelemetry.mockImplementation(() => sendPageViewTelemetryMock) + + render() + + expect(sendPageViewTelemetry).toBeCalledWith({ + name: TelemetryPageView.RDI_CONFIG, + }) + }) + + it('should render loading spinner', () => { + const rdiPipelineSelectorMock = jest.fn().mockReturnValue({ + loading: true, + }) + rdiPipelineSelector.mockImplementation(rdiPipelineSelectorMock) + + render() + + expect(screen.getByTestId('rdi-config-loading')).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/pipeline/pages/config/Config.tsx b/redisinsight/ui/src/pages/rdi/pipeline/pages/config/Config.tsx new file mode 100644 index 0000000000..4588e0c038 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/pages/config/Config.tsx @@ -0,0 +1,73 @@ +import React, { useState, useEffect } from 'react' +import { useSelector } from 'react-redux' +import { EuiText, EuiLink, EuiButton, EuiLoadingSpinner } from '@elastic/eui' +import cx from 'classnames' + +import { sendPageViewTelemetry, TelemetryPageView } from 'uiSrc/telemetry' +import { EXTERNAL_LINKS } from 'uiSrc/constants/links' +import { rdiPipelineSelector } from 'uiSrc/slices/rdi/pipeline' +import { MonacoYaml } from 'uiSrc/components/monaco-editor' + +import styles from './styles.module.scss' + +const Config = () => { + const { loading, data } = useSelector(rdiPipelineSelector) + + const [value, setValue] = useState(data?.config ?? '') + + useEffect(() => { + setValue(data?.config ?? '') + }, [data]) + + useEffect(() => { + sendPageViewTelemetry({ + name: TelemetryPageView.RDI_CONFIG, + }) + }, []) + + return ( +
+ Target database configuration + + {'Configure target instance '} + + connection details + + {' and applier settings.'} + + {loading ? ( +
+ Loading data... + +
+ ) : ( + + )} + +
+ {}} + data-testid="rdi-test-connection" + > + Test Connection + +
+
+ ) +} + +export default Config diff --git a/redisinsight/ui/src/pages/rdi/pipeline/pages/config/index.ts b/redisinsight/ui/src/pages/rdi/pipeline/pages/config/index.ts new file mode 100644 index 0000000000..1fca4da459 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/pages/config/index.ts @@ -0,0 +1,3 @@ +import Config from './Config' + +export default Config diff --git a/redisinsight/ui/src/pages/rdi/pipeline/pages/config/styles.module.scss b/redisinsight/ui/src/pages/rdi/pipeline/pages/config/styles.module.scss new file mode 100644 index 0000000000..17b6adc4c1 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/pages/config/styles.module.scss @@ -0,0 +1,33 @@ +.wrapper { + .title { + font: normal normal normal 16px/19px Graphik, sans-serif; + } + + .title, + .text { + margin-bottom: 8px; + } + + .actions { + display: flex; + justify-content: end; + padding-top: 16px; + } + + .editorWrapper { + height: calc(100% - 105px); + border: 1px solid var(--separatorColorLight) !important; + + :global(.inlineMonacoEditor) { + height: 100%; + } + } + + .loading { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: var(--browserViewTypePassive) !important; + } +} diff --git a/redisinsight/ui/src/pages/rdi/pipeline/pages/prepare/Prepare.tsx b/redisinsight/ui/src/pages/rdi/pipeline/pages/prepare/Prepare.tsx new file mode 100644 index 0000000000..ddd300d011 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/pages/prepare/Prepare.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import { EuiTextColor } from '@elastic/eui' + +export interface IProps { + +} + +// TODO rename and update this component +const Prepare = () => ( +
+ prepare +
+) + +export default Prepare diff --git a/redisinsight/ui/src/pages/rdi/pipeline/pages/prepare/index.ts b/redisinsight/ui/src/pages/rdi/pipeline/pages/prepare/index.ts new file mode 100644 index 0000000000..f4e7b67a8d --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/pages/prepare/index.ts @@ -0,0 +1,3 @@ +import Prepare from './Prepare' + +export default Prepare diff --git a/redisinsight/ui/src/pages/rdi/pipeline/styles.module.scss b/redisinsight/ui/src/pages/rdi/pipeline/styles.module.scss new file mode 100644 index 0000000000..c0c26a850a --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/styles.module.scss @@ -0,0 +1,21 @@ +.wrapper { + display: flex; + width: 100%; + height: 100%; + padding: 16px; + + :global(.content) { + border-radius: 8px; + padding: 32px 24px 16px 24px; + margin-left: 16px; + background-color: var(--euiColorEmptyShade); + width: 100%; + height: 100%; + } + + :global { + .monaco-editor, .monaco-editor .margin, .monaco-editor .minimap-decorations-layer, .monaco-editor-background { + background-color: var(--browserViewTypePassive) !important; + } + } +} diff --git a/redisinsight/ui/src/slices/interfaces/rdi.ts b/redisinsight/ui/src/slices/interfaces/rdi.ts new file mode 100644 index 0000000000..c3d7fe45b5 --- /dev/null +++ b/redisinsight/ui/src/slices/interfaces/rdi.ts @@ -0,0 +1,12 @@ +import { Nullable } from 'uiSrc/utils' + +export interface IPipeline { + config: string + jobs: any[] +} + +export interface IStateRdi { + loading: boolean; + error: string + data: Nullable +} diff --git a/redisinsight/ui/src/slices/rdi/pipeline.ts b/redisinsight/ui/src/slices/rdi/pipeline.ts new file mode 100644 index 0000000000..704ff542d4 --- /dev/null +++ b/redisinsight/ui/src/slices/rdi/pipeline.ts @@ -0,0 +1,73 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { AxiosError } from 'axios' +import { apiService, } from 'uiSrc/services' +import { addErrorNotification } from 'uiSrc/slices/app/notifications' +import { IStateRdi, IPipeline } from 'uiSrc/slices/interfaces/rdi' +import { getApiErrorMessage, isStatusSuccessful } from 'uiSrc/utils' + +import { AppDispatch, RootState } from '../store' + +export const initialState: IStateRdi = { + loading: false, + error: '', + data: null, +} + +const rdiPipelineSlice = createSlice({ + name: 'rdiPipeline', + initialState, + reducers: { + getPipeline: (state) => { + state.loading = true + }, + getPipelineSuccess: (state, { payload }: PayloadAction) => { + state.loading = false + state.data = payload + }, + getPipelineFailure: (state, { payload }) => { + state.loading = false + state.error = payload + }, + } +}) + +export const rdiSelector = (state: RootState) => state.rdi +export const rdiPipelineSelector = (state: RootState) => state.rdi.pipeline + +export const { + getPipeline, + getPipelineSuccess, + getPipelineFailure, +} = rdiPipelineSlice.actions + +// The reducer +export default rdiPipelineSlice.reducer + +// Asynchronous thunk action +export function fetchRdiPipeline( + rdiInstanceId: string, + onSuccessAction?: (data: any) => void, + onFailAction?: () => void, +) { + return async (dispatch: AppDispatch) => { + try { + dispatch(getPipeline()) + const { data, status } = await apiService.get( + // TODO connect with Kyle to find solution + `rdi/${rdiInstanceId}/pipeline` + ) + + if (isStatusSuccessful(status)) { + dispatch(getPipelineSuccess(data)) + + onSuccessAction?.(data) + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(getPipelineFailure(errorMessage)) + onFailAction?.() + } + } +} diff --git a/redisinsight/ui/src/slices/store.ts b/redisinsight/ui/src/slices/store.ts index e96ae89903..f64c7173b6 100644 --- a/redisinsight/ui/src/slices/store.ts +++ b/redisinsight/ui/src/slices/store.ts @@ -45,6 +45,7 @@ import recommendationsReducer from './recommendations/recommendations' import triggeredFunctionsReducer from './triggeredFunctions/triggeredFunctions' import insightsPanelReducer from './panels/insights' import appRDIReducer from './rdi/rdi' +import rdiPipelineSlice from './rdi/pipeline' export const history = createBrowserHistory() @@ -113,7 +114,8 @@ export const rootReducer = combineReducers({ insights: insightsPanelReducer, }), rdi: combineReducers({ - rdi: appRDIReducer + rdi: appRDIReducer, + pipeline: rdiPipelineSlice, }) }) diff --git a/redisinsight/ui/src/slices/tests/rdi/pipeline.spec.ts b/redisinsight/ui/src/slices/tests/rdi/pipeline.spec.ts new file mode 100644 index 0000000000..f6416608f4 --- /dev/null +++ b/redisinsight/ui/src/slices/tests/rdi/pipeline.spec.ts @@ -0,0 +1,147 @@ +import { cloneDeep } from 'lodash' +import { AxiosError } from 'axios' +import { cleanup, initialStateDefault, mockedStore } from 'uiSrc/utils/test-utils' +import reducer, { + initialState, + getPipeline, + getPipelineSuccess, + getPipelineFailure, + fetchRdiPipeline, + rdiSelector, +} from 'uiSrc/slices/rdi/pipeline' +import { apiService } from 'uiSrc/services' +import { addErrorNotification } from 'uiSrc/slices/app/notifications' + +let store: typeof mockedStore + +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('rdi pipe slice', () => { + describe('reducer, actions and selectors', () => { + it('should return the initial state', () => { + // Arrange + const nextState = initialState + + // Act + const result = reducer(undefined, {}) + + // Assert + expect(result).toEqual(nextState) + }) + }) + + describe('rdiSelector', () => { + it('should properly set state', () => { + // Arrange + const state = { + ...initialState, + loading: true, + } + + // Act + const nextState = reducer(initialState, getPipeline()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + rdi: nextState, + }) + expect(rdiSelector(rootState)).toEqual(state) + }) + }) + + describe('getPipelineSuccess', () => { + it('should properly set state', () => { + // Arrange + const pipeline = { config: 'string', jobs: [] } + const state = { + ...initialState, + data: pipeline, + } + + // Act + const nextState = reducer(initialState, getPipelineSuccess(pipeline)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + rdi: nextState, + }) + expect(rdiSelector(rootState)).toEqual(state) + }) + }) + + describe('getPipelineFailure', () => { + it('should properly set state', () => { + // Arrange + const error = 'error' + const state = { + ...initialState, + error, + } + + // Act + const nextState = reducer(initialState, getPipelineFailure(error)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + rdi: nextState, + }) + expect(rdiSelector(rootState)).toEqual(state) + }) + }) + + // thunks + + describe('thunks', () => { + describe('fetchRdiPipeline', () => { + it('succeed to fetch data', async () => { + const data = { config: 'string', jobs: [] } + const responsePayload = { data, status: 200 } + + apiService.get = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch( + fetchRdiPipeline('123') + ) + + // Assert + const expectedActions = [ + getPipeline(), + getPipelineSuccess(data), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to fetch data', async () => { + const errorMessage = 'Something was wrong!' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.get = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch( + fetchRdiPipeline('123') + ) + + // Assert + const expectedActions = [ + getPipeline(), + addErrorNotification(responsePayload as AxiosError), + getPipelineFailure(errorMessage) + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) + }) +}) diff --git a/redisinsight/ui/src/telemetry/pageViews.ts b/redisinsight/ui/src/telemetry/pageViews.ts index 7515f2af01..f799900dfd 100644 --- a/redisinsight/ui/src/telemetry/pageViews.ts +++ b/redisinsight/ui/src/telemetry/pageViews.ts @@ -8,5 +8,6 @@ export enum TelemetryPageView { CLUSTER_DETAILS_PAGE = 'Overview', PUBSUB_PAGE = 'Pub/Sub', DATABASE_ANALYSIS = 'Database Analysis', - TRIGGERED_FUNCTIONS = 'Triggers and Functions' + TRIGGERED_FUNCTIONS = 'Triggers and Functions', + RDI_CONFIG = 'RDI Configuration' } From 47b629220eff2eecc46e3cc448b2396920d5a731 Mon Sep 17 00:00:00 2001 From: Amir Allayarov <100589048+AmirAllayarovSofteq@users.noreply.github.com> Date: Mon, 11 Dec 2023 19:01:07 +0400 Subject: [PATCH 006/566] Display RDI config file RI-5133 (#2839) * #RI-5133 - add rdi config --- .../src/modules/rdi/client/api.rdi.client.ts | 3 +- .../api/src/modules/rdi/constants/index.ts | 1 + .../modules/rdi/rdi-pipeline.controller.ts | 13 +- .../src/modules/rdi/rdi-pipeline.service.ts | 8 +- redisinsight/ui/src/assets/img/icons/file.svg | 10 ++ .../main-router/constants/defaultRoutes.ts | 8 +- .../constants/sub-routes/rdiRoutes.ts | 18 +-- .../monaco-yaml/MonacoYaml.spec.tsx | 10 ++ .../components/monaco-yaml/MonacoYaml.tsx | 32 ++++ .../components/monaco-yaml/index.ts | 3 + .../ui/src/components/monaco-editor/index.ts | 2 + redisinsight/ui/src/constants/api.ts | 2 + redisinsight/ui/src/constants/links.ts | 1 + redisinsight/ui/src/constants/pages.ts | 3 + .../pages/rdi/pipeline/PipelinePage.spec.tsx | 45 ++++++ .../src/pages/rdi/pipeline/PipelinePage.tsx | 35 +++++ .../rdi/pipeline/PipelinePageRouter.spec.tsx | 17 ++ .../pages/rdi/pipeline/PipelinePageRouter.tsx | 17 ++ .../components/navigation/Navigation.spec.tsx | 36 +++++ .../components/navigation/Navigation.tsx | 88 +++++++++++ .../pipeline/components/navigation/index.ts | 3 + .../components/navigation/styles.module.scss | 78 ++++++++++ .../ui/src/pages/rdi/pipeline/index.ts | 3 + .../rdi/pipeline/pages/config/Config.spec.tsx | 48 ++++++ .../rdi/pipeline/pages/config/Config.tsx | 73 +++++++++ .../pages/rdi/pipeline/pages/config/index.ts | 3 + .../pipeline/pages/config/styles.module.scss | 33 ++++ .../rdi/pipeline/pages/prepare/Prepare.tsx | 15 ++ .../pages/rdi/pipeline/pages/prepare/index.ts | 3 + .../src/pages/rdi/pipeline/styles.module.scss | 21 +++ redisinsight/ui/src/slices/interfaces/rdi.ts | 12 ++ redisinsight/ui/src/slices/rdi/pipeline.ts | 73 +++++++++ redisinsight/ui/src/slices/store.ts | 4 +- .../ui/src/slices/tests/rdi/pipeline.spec.ts | 147 ++++++++++++++++++ redisinsight/ui/src/telemetry/pageViews.ts | 3 +- 35 files changed, 851 insertions(+), 20 deletions(-) create mode 100644 redisinsight/ui/src/assets/img/icons/file.svg create mode 100644 redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.spec.tsx create mode 100644 redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.tsx create mode 100644 redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/index.ts create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/PipelinePage.spec.tsx create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/PipelinePage.tsx create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/PipelinePageRouter.spec.tsx create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/PipelinePageRouter.tsx create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/components/navigation/Navigation.spec.tsx create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/components/navigation/Navigation.tsx create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/components/navigation/index.ts create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/components/navigation/styles.module.scss create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/index.ts create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/pages/config/Config.spec.tsx create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/pages/config/Config.tsx create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/pages/config/index.ts create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/pages/config/styles.module.scss create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/pages/prepare/Prepare.tsx create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/pages/prepare/index.ts create mode 100644 redisinsight/ui/src/pages/rdi/pipeline/styles.module.scss create mode 100644 redisinsight/ui/src/slices/interfaces/rdi.ts create mode 100644 redisinsight/ui/src/slices/rdi/pipeline.ts create mode 100644 redisinsight/ui/src/slices/tests/rdi/pipeline.spec.ts diff --git a/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts b/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts index 60e08697cd..6e3f335e17 100644 --- a/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts +++ b/redisinsight/api/src/modules/rdi/client/api.rdi.client.ts @@ -19,7 +19,8 @@ export class ApiRdiClient extends RdiClient { } async getPipeline(): Promise { - return null; + const response = await this.client.get(RdiUrl.GetPipeline); + return response.data; } async deploy(pipeline: RdiPipeline): Promise { diff --git a/redisinsight/api/src/modules/rdi/constants/index.ts b/redisinsight/api/src/modules/rdi/constants/index.ts index 68796fecb0..3c888a8a90 100644 --- a/redisinsight/api/src/modules/rdi/constants/index.ts +++ b/redisinsight/api/src/modules/rdi/constants/index.ts @@ -1,3 +1,4 @@ export enum RdiUrl { GetSchema = '/schema', + GetPipeline = '/pipeline', } diff --git a/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts b/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts index 0b00813a30..3783ea8ec9 100644 --- a/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts +++ b/redisinsight/api/src/modules/rdi/rdi-pipeline.controller.ts @@ -1,7 +1,7 @@ import { ClassSerializerInterceptor, Controller, Get, UseInterceptors, UsePipes, ValidationPipe, } from '@nestjs/common'; -import { Rdi, RdiClientMetadata } from 'src/modules/rdi/models'; +import { Rdi, RdiPipeline, RdiClientMetadata } from 'src/modules/rdi/models'; import { ApiTags } from '@nestjs/swagger'; import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; import { RdiPipelineService } from 'src/modules/rdi/rdi-pipeline.service'; @@ -26,4 +26,15 @@ export class RdiPipelineController { ): Promise { return this.rdiPipelineService.getSchema(rdiClientMetadata); } + + @Get('/') + @ApiEndpoint({ + description: 'Get pipeline', + responses: [{ status: 200, type: RdiPipeline }], + }) + async getPipeline( + @RequestRdiClientMetadata() rdiClientMetadata: RdiClientMetadata, + ): Promise { + return this.rdiPipelineService.getPipeline(rdiClientMetadata); + } } diff --git a/redisinsight/api/src/modules/rdi/rdi-pipeline.service.ts b/redisinsight/api/src/modules/rdi/rdi-pipeline.service.ts index 05813639cb..85203c8d79 100644 --- a/redisinsight/api/src/modules/rdi/rdi-pipeline.service.ts +++ b/redisinsight/api/src/modules/rdi/rdi-pipeline.service.ts @@ -11,10 +11,12 @@ export class RdiPipelineService { async getSchema(rdiClientMetadata: RdiClientMetadata): Promise { const client = await this.rdiClientProvider.getOrCreate(rdiClientMetadata); - const schema = await client.getSchema(); + return await client.getSchema(); + } - // todo: process somehow + async getPipeline(rdiClientMetadata: RdiClientMetadata): Promise { + const client = await this.rdiClientProvider.getOrCreate(rdiClientMetadata); - return schema; + return await client.getPipeline(); } } diff --git a/redisinsight/ui/src/assets/img/icons/file.svg b/redisinsight/ui/src/assets/img/icons/file.svg new file mode 100644 index 0000000000..1d0a119992 --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/file.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts b/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts index c997876761..7b358d10b6 100644 --- a/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts +++ b/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts @@ -13,7 +13,8 @@ import WorkbenchPage from 'uiSrc/pages/workbench' import PubSubPage from 'uiSrc/pages/pub-sub' import AnalyticsPage from 'uiSrc/pages/analytics' import TriggeredFunctionsPage from 'uiSrc/pages/triggered-functions' -import RdiList from 'uiSrc/pages/rdi/home' +import RdiList from 'uiSrc/pages/rdi/home/RDIList' +import RdiPipeline from 'uiSrc/pages/rdi/pipeline/PipelinePage' import { ANALYTICS_ROUTES, RDI_ROUTES, TRIGGERED_FUNCTIONS_ROUTES } from './sub-routes' import COMMON_ROUTES from './commonRoutes' @@ -77,9 +78,8 @@ const ROUTES: IRoute[] = [ ], }, { - path: Pages.rdi, - // todo: add home rdi component - list of instances - component: RdiList, + path: '/integrate/:rdiInstanceId', + component: RdiPipeline, routes: RDI_ROUTES, }, { diff --git a/redisinsight/ui/src/components/main-router/constants/sub-routes/rdiRoutes.ts b/redisinsight/ui/src/components/main-router/constants/sub-routes/rdiRoutes.ts index 4590df87e1..4a691b8a4f 100644 --- a/redisinsight/ui/src/components/main-router/constants/sub-routes/rdiRoutes.ts +++ b/redisinsight/ui/src/components/main-router/constants/sub-routes/rdiRoutes.ts @@ -1,14 +1,14 @@ -import { IRoute } from 'uiSrc/constants' -import RdiList from 'uiSrc/pages/rdi/home' +import { IRoute, Pages } from 'uiSrc/constants' +import PreparePage from 'uiSrc/pages/rdi/pipeline/pages/prepare' +import ConfigPage from 'uiSrc/pages/rdi/pipeline/pages/config' export const RDI_ROUTES: IRoute[] = [ { - // todo: rename path - path: '/:rdiId', - // todo add component like Instance page - component: RdiList, - routes: [ - // todo: add page routes here - ], + path: Pages.rdiPipelinePrepare(':rdiInstanceId'), + component: PreparePage, + }, + { + path: Pages.rdiPipelineConfig(':rdiInstanceId'), + component: ConfigPage, }, ] diff --git a/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.spec.tsx b/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.spec.tsx new file mode 100644 index 0000000000..30c5684f3d --- /dev/null +++ b/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.spec.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import { render } from 'uiSrc/utils/test-utils' + +import MonacoYaml from './MonacoYaml' + +describe('MonacoYaml', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.tsx b/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.tsx new file mode 100644 index 0000000000..fed956f624 --- /dev/null +++ b/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/MonacoYaml.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api' + +import { MonacoEditor } from 'uiSrc/components/monaco-editor' +import { CommonProps } from 'uiSrc/components/monaco-editor/MonacoEditor' + +const MonacoYaml = (props: CommonProps) => { + const editorDidMount = ( + editor: monacoEditor.editor.IStandaloneCodeEditor, + monaco: typeof monacoEditor, + ) => { + monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ + validate: true, + schemaValidation: 'error', + schemaRequest: 'error', + trailingCommas: 'error' + }) + const messageContribution = editor.getContribution('editor.contrib.messageController') + editor.onDidAttemptReadOnlyEdit(() => messageContribution.dispose()) + } + + return ( + + ) +} + +export default MonacoYaml diff --git a/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/index.ts b/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/index.ts new file mode 100644 index 0000000000..f5cf5dbd17 --- /dev/null +++ b/redisinsight/ui/src/components/monaco-editor/components/monaco-yaml/index.ts @@ -0,0 +1,3 @@ +import MonacoYaml from './MonacoYaml' + +export default MonacoYaml diff --git a/redisinsight/ui/src/components/monaco-editor/index.ts b/redisinsight/ui/src/components/monaco-editor/index.ts index cbd6f985f9..38b43c4995 100644 --- a/redisinsight/ui/src/components/monaco-editor/index.ts +++ b/redisinsight/ui/src/components/monaco-editor/index.ts @@ -1,9 +1,11 @@ import MonacoEditor from './MonacoEditor' import MonacoJS from './components/monaco-js' import MonacoJson from './components/monaco-json' +import MonacoYaml from './components/monaco-yaml' export { MonacoEditor, MonacoJS, MonacoJson, + MonacoYaml, } diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index 6351c824b2..e6c1b4d532 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -136,6 +136,8 @@ enum ApiEndpoints { ANALYTICS_SEND_EVENT = 'analytics/send-event', ANALYTICS_SEND_PAGE = 'analytics/send-page', + + RDI_PIPELINE = 'rdi/pipeline' } export enum CustomHeaders { diff --git a/redisinsight/ui/src/constants/links.ts b/redisinsight/ui/src/constants/links.ts index e92975c40d..b68c303ebe 100644 --- a/redisinsight/ui/src/constants/links.ts +++ b/redisinsight/ui/src/constants/links.ts @@ -11,6 +11,7 @@ export const EXTERNAL_LINKS = { cloudConsole: 'https://app.redislabs.com/#/databases', tryFree: 'https://redis.com/try-free', docker: 'https://redis.io/docs/getting-started/install-stack/docker', + rdiQuickStart: 'https://docs.redis.com/latest/rdi/quickstart/', } export const UTM_CAMPAINGS: Record = { diff --git a/redisinsight/ui/src/constants/pages.ts b/redisinsight/ui/src/constants/pages.ts index 41c3ef03f7..d230d696bc 100644 --- a/redisinsight/ui/src/constants/pages.ts +++ b/redisinsight/ui/src/constants/pages.ts @@ -51,4 +51,7 @@ export const Pages = { `/${instanceId}/${PageNames.triggeredFunctions}/${PageNames.triggeredFunctionsFunctions}`, // rdi pages rdi: '/integrate', + rdiPipeline: (rdiInstance: string) => `integrate/${rdiInstance}/pipeline}`, + rdiPipelineConfig: (rdiInstance: string) => `/integrate/${rdiInstance}/pipeline/config`, + rdiPipelinePrepare: (rdiInstance: string) => `/integrate/${rdiInstance}/pipeline/prepare`, } diff --git a/redisinsight/ui/src/pages/rdi/pipeline/PipelinePage.spec.tsx b/redisinsight/ui/src/pages/rdi/pipeline/PipelinePage.spec.tsx new file mode 100644 index 0000000000..48f839586d --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/PipelinePage.spec.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { cloneDeep } from 'lodash' + +import { BrowserRouter } from 'react-router-dom' +import { instance, mock } from 'ts-mockito' +import { act, render, cleanup, mockedStore } from 'uiSrc/utils/test-utils' +import { getPipeline } from 'uiSrc/slices/rdi/pipeline' +import PipelinePage, { Props } from './PipelinePage' + +const mockedProps = mock() + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('PipelinePage', () => { + it('should render', () => { + expect( + render( + + + + ) + ).toBeTruthy() + }) + + it('should dispatch fetchRdiPipeline on render', async () => { + await act(() => { + render( + + + + ) + }) + + const expectedActions = [ + getPipeline(), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/pipeline/PipelinePage.tsx b/redisinsight/ui/src/pages/rdi/pipeline/PipelinePage.tsx new file mode 100644 index 0000000000..c08c20ada1 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/PipelinePage.tsx @@ -0,0 +1,35 @@ +import React, { useEffect } from 'react' +import { useLocation, useParams } from 'react-router-dom' +import { useDispatch } from 'react-redux' + +import { fetchRdiPipeline } from 'uiSrc/slices/rdi/pipeline' + +import Navigation from './components/navigation' +import PipelinePageRouter from './PipelinePageRouter' +import styles from './styles.module.scss' + +export interface Props { + routes: any[] +} + +const Pipeline = ({ routes = [] }: Props) => { + const { rdiInstanceId } = useParams<{ rdiInstanceId: string }>() + + const { pathname } = useLocation() + const dispatch = useDispatch() + + const path = pathname?.split('/').pop() || '' + + useEffect(() => { + dispatch(fetchRdiPipeline(rdiInstanceId)) + }, []) + + return ( +
+ + +
+ ) +} + +export default Pipeline diff --git a/redisinsight/ui/src/pages/rdi/pipeline/PipelinePageRouter.spec.tsx b/redisinsight/ui/src/pages/rdi/pipeline/PipelinePageRouter.spec.tsx new file mode 100644 index 0000000000..8d3e1b6d01 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/PipelinePageRouter.spec.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { render } from 'uiSrc/utils/test-utils' +import PipelinePageRouter from './PipelinePageRouter' + +const mockedRoutes = [ + { + path: '/page', + }, +] + +describe('PipelinePageRouter', () => { + it('should render', () => { + expect( + render(, { withRouter: true }) + ).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/pipeline/PipelinePageRouter.tsx b/redisinsight/ui/src/pages/rdi/pipeline/PipelinePageRouter.tsx new file mode 100644 index 0000000000..63246baf23 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/PipelinePageRouter.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { Switch } from 'react-router-dom' +import RouteWithSubRoutes from 'uiSrc/utils/routerWithSubRoutes' + +export interface Props { + routes: any[]; +} +const PipelinePageRouter = ({ routes }: Props) => ( + + {routes.map((route, i) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + +) + +export default React.memo(PipelinePageRouter) diff --git a/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/Navigation.spec.tsx b/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/Navigation.spec.tsx new file mode 100644 index 0000000000..3a9c598cce --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/Navigation.spec.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import reactRouterDom from 'react-router-dom' +import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' + +import Navigation from './Navigation' + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: jest.fn, + }), +})) + +describe('Navigation', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call proper history push after click on tabs', () => { + const pushMock = jest.fn() + reactRouterDom.useHistory = jest.fn().mockReturnValue({ push: pushMock }) + + render() + + fireEvent.click(screen.getByTestId('rdi-nav-btn-config')) + expect(pushMock).toBeCalledWith('/integrate/undefined/pipeline/config') + + fireEvent.click(screen.getByTestId('rdi-nav-btn-prepare')) + expect(pushMock).toBeCalledWith('/integrate/undefined/pipeline/prepare') + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/Navigation.tsx b/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/Navigation.tsx new file mode 100644 index 0000000000..b779b50611 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/Navigation.tsx @@ -0,0 +1,88 @@ +import React from 'react' +import { useHistory, useParams } from 'react-router-dom' +import { + EuiTextColor, + EuiText, + EuiTab, + EuiTabs, + EuiIcon, +} from '@elastic/eui' +import cx from 'classnames' + +import { Pages } from 'uiSrc/constants' +import { ReactComponent as FileIcon } from 'uiSrc/assets/img/icons/file.svg' + +import styles from './styles.module.scss' + +export interface IProps { + path: string +} + +enum RdiPipelineNav { + Prepare = 'prepare', + Config = 'config', +} + +const defaultNavList = [ + { + id: RdiPipelineNav.Prepare, + title: 'Prepare', + fileName: 'Select pipeline type', + }, + { + id: RdiPipelineNav.Config, + title: 'Configuration', + fileName: 'Target connection details' + } +] + +const Navigation = (props: IProps) => { + const { path } = props + + const history = useHistory() + + const { rdiInstanceId } = useParams<{ rdiInstanceId: string }>() + + // TODO resolve job id + const onSelectedTabChanged = (id: string) => { + if (id === RdiPipelineNav.Prepare) { + history.push(Pages.rdiPipelinePrepare(rdiInstanceId)) + } + if (id === RdiPipelineNav.Config) { + history.push(Pages.rdiPipelineConfig(rdiInstanceId)) + } + } + + const renderTabs = () => defaultNavList.map(({ id, title, fileName }) => ( + + <> + {title} +
{}} + onClick={() => onSelectedTabChanged(id)} + data-testid={`rdi-nav-btn-${id}`} + > + + {fileName} +
+ +
+ )) + + return ( +
+ Pipeline Management + {renderTabs()} +
+ ) +} + +export default Navigation diff --git a/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/index.ts b/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/index.ts new file mode 100644 index 0000000000..d4dd76dd61 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/index.ts @@ -0,0 +1,3 @@ +import Navigation from './Navigation' + +export default Navigation diff --git a/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/styles.module.scss b/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/styles.module.scss new file mode 100644 index 0000000000..baae24f07b --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/components/navigation/styles.module.scss @@ -0,0 +1,78 @@ +.wrapper { + border-radius: 8px; + width: 269px; + background-color: var(--euiColorLightestShade); + height: 100%; + + .title { + padding: 16px; + font: normal normal 500 14px/17px Graphik, sans-serif; + border-bottom: 1px solid var(--separatorColor); + } + + .tabs { + flex-direction: column; + + .tab { + cursor: default; + border-bottom: 1px solid var(--separatorColor) !important; + + &:global(.euiTab-isSelected) { + background-color: var(--tableRowSelectedColor) !important; + border-left: 2px solid var(--euiColorPrimary); + + .tabTitle { + font-weight: 500; + } + + .text { + color: var(--iconsDefaultHoverColor) !important; + } + + .fileIcon path { + fill: var(--iconsDefaultHoverColor); + } + } + + &:global(.euiTab:hover:not(.euiTab-isSelected)) { + text-decoration: none; + + .text:hover { + text-decoration: underline; + } + } + + :global(.euiTab__content) { + padding: 16px 12px 16px 16px; + } + + .tabTitle { + font: normal normal normal 14px/17px Graphik, sans-serif; + color: var(--externalLinkColor) !important; + text-align: left; + padding: 3px 0; + } + + .file { + display: flex; + padding: 3px 0; + } + + .text { + font: normal normal normal 14px/17px Graphik, sans-serif; + color: var(--euiTextSubduedColor) !important; + } + + .fileIcon { + width: 14px; + height: 15px; + margin-right: 6px; + + path { + fill: var(--euiTextSubduedColor); + } + } + } + } +} + diff --git a/redisinsight/ui/src/pages/rdi/pipeline/index.ts b/redisinsight/ui/src/pages/rdi/pipeline/index.ts new file mode 100644 index 0000000000..e21e78dd0c --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/index.ts @@ -0,0 +1,3 @@ +import PipelinePage from './PipelinePage' + +export default PipelinePage diff --git a/redisinsight/ui/src/pages/rdi/pipeline/pages/config/Config.spec.tsx b/redisinsight/ui/src/pages/rdi/pipeline/pages/config/Config.spec.tsx new file mode 100644 index 0000000000..472c8cf27b --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/pages/config/Config.spec.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { rdiPipelineSelector } from 'uiSrc/slices/rdi/pipeline' +import { render, screen } from 'uiSrc/utils/test-utils' + +import { sendPageViewTelemetry, TelemetryPageView } from 'uiSrc/telemetry' +import Config from './Config' + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendPageViewTelemetry: jest.fn(), +})) + +jest.mock('uiSrc/slices/rdi/pipeline', () => ({ + ...jest.requireActual('uiSrc/slices/rdi/pipeline'), + rdiPipelineSelector: jest.fn().mockReturnValue({ + loading: false, + error: '', + data: null, + }), +})) + +describe('Config', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call proper sendPageViewTelemetry', () => { + const sendPageViewTelemetryMock = jest.fn() + sendPageViewTelemetry.mockImplementation(() => sendPageViewTelemetryMock) + + render() + + expect(sendPageViewTelemetry).toBeCalledWith({ + name: TelemetryPageView.RDI_CONFIG, + }) + }) + + it('should render loading spinner', () => { + const rdiPipelineSelectorMock = jest.fn().mockReturnValue({ + loading: true, + }) + rdiPipelineSelector.mockImplementation(rdiPipelineSelectorMock) + + render() + + expect(screen.getByTestId('rdi-config-loading')).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/pipeline/pages/config/Config.tsx b/redisinsight/ui/src/pages/rdi/pipeline/pages/config/Config.tsx new file mode 100644 index 0000000000..4588e0c038 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/pages/config/Config.tsx @@ -0,0 +1,73 @@ +import React, { useState, useEffect } from 'react' +import { useSelector } from 'react-redux' +import { EuiText, EuiLink, EuiButton, EuiLoadingSpinner } from '@elastic/eui' +import cx from 'classnames' + +import { sendPageViewTelemetry, TelemetryPageView } from 'uiSrc/telemetry' +import { EXTERNAL_LINKS } from 'uiSrc/constants/links' +import { rdiPipelineSelector } from 'uiSrc/slices/rdi/pipeline' +import { MonacoYaml } from 'uiSrc/components/monaco-editor' + +import styles from './styles.module.scss' + +const Config = () => { + const { loading, data } = useSelector(rdiPipelineSelector) + + const [value, setValue] = useState(data?.config ?? '') + + useEffect(() => { + setValue(data?.config ?? '') + }, [data]) + + useEffect(() => { + sendPageViewTelemetry({ + name: TelemetryPageView.RDI_CONFIG, + }) + }, []) + + return ( +
+ Target database configuration + + {'Configure target instance '} + + connection details + + {' and applier settings.'} + + {loading ? ( +
+ Loading data... + +
+ ) : ( + + )} + +
+ {}} + data-testid="rdi-test-connection" + > + Test Connection + +
+
+ ) +} + +export default Config diff --git a/redisinsight/ui/src/pages/rdi/pipeline/pages/config/index.ts b/redisinsight/ui/src/pages/rdi/pipeline/pages/config/index.ts new file mode 100644 index 0000000000..1fca4da459 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/pages/config/index.ts @@ -0,0 +1,3 @@ +import Config from './Config' + +export default Config diff --git a/redisinsight/ui/src/pages/rdi/pipeline/pages/config/styles.module.scss b/redisinsight/ui/src/pages/rdi/pipeline/pages/config/styles.module.scss new file mode 100644 index 0000000000..17b6adc4c1 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/pages/config/styles.module.scss @@ -0,0 +1,33 @@ +.wrapper { + .title { + font: normal normal normal 16px/19px Graphik, sans-serif; + } + + .title, + .text { + margin-bottom: 8px; + } + + .actions { + display: flex; + justify-content: end; + padding-top: 16px; + } + + .editorWrapper { + height: calc(100% - 105px); + border: 1px solid var(--separatorColorLight) !important; + + :global(.inlineMonacoEditor) { + height: 100%; + } + } + + .loading { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: var(--browserViewTypePassive) !important; + } +} diff --git a/redisinsight/ui/src/pages/rdi/pipeline/pages/prepare/Prepare.tsx b/redisinsight/ui/src/pages/rdi/pipeline/pages/prepare/Prepare.tsx new file mode 100644 index 0000000000..ddd300d011 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/pages/prepare/Prepare.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import { EuiTextColor } from '@elastic/eui' + +export interface IProps { + +} + +// TODO rename and update this component +const Prepare = () => ( +
+ prepare +
+) + +export default Prepare diff --git a/redisinsight/ui/src/pages/rdi/pipeline/pages/prepare/index.ts b/redisinsight/ui/src/pages/rdi/pipeline/pages/prepare/index.ts new file mode 100644 index 0000000000..f4e7b67a8d --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/pages/prepare/index.ts @@ -0,0 +1,3 @@ +import Prepare from './Prepare' + +export default Prepare diff --git a/redisinsight/ui/src/pages/rdi/pipeline/styles.module.scss b/redisinsight/ui/src/pages/rdi/pipeline/styles.module.scss new file mode 100644 index 0000000000..c0c26a850a --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/pipeline/styles.module.scss @@ -0,0 +1,21 @@ +.wrapper { + display: flex; + width: 100%; + height: 100%; + padding: 16px; + + :global(.content) { + border-radius: 8px; + padding: 32px 24px 16px 24px; + margin-left: 16px; + background-color: var(--euiColorEmptyShade); + width: 100%; + height: 100%; + } + + :global { + .monaco-editor, .monaco-editor .margin, .monaco-editor .minimap-decorations-layer, .monaco-editor-background { + background-color: var(--browserViewTypePassive) !important; + } + } +} diff --git a/redisinsight/ui/src/slices/interfaces/rdi.ts b/redisinsight/ui/src/slices/interfaces/rdi.ts new file mode 100644 index 0000000000..c3d7fe45b5 --- /dev/null +++ b/redisinsight/ui/src/slices/interfaces/rdi.ts @@ -0,0 +1,12 @@ +import { Nullable } from 'uiSrc/utils' + +export interface IPipeline { + config: string + jobs: any[] +} + +export interface IStateRdi { + loading: boolean; + error: string + data: Nullable +} diff --git a/redisinsight/ui/src/slices/rdi/pipeline.ts b/redisinsight/ui/src/slices/rdi/pipeline.ts new file mode 100644 index 0000000000..704ff542d4 --- /dev/null +++ b/redisinsight/ui/src/slices/rdi/pipeline.ts @@ -0,0 +1,73 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { AxiosError } from 'axios' +import { apiService, } from 'uiSrc/services' +import { addErrorNotification } from 'uiSrc/slices/app/notifications' +import { IStateRdi, IPipeline } from 'uiSrc/slices/interfaces/rdi' +import { getApiErrorMessage, isStatusSuccessful } from 'uiSrc/utils' + +import { AppDispatch, RootState } from '../store' + +export const initialState: IStateRdi = { + loading: false, + error: '', + data: null, +} + +const rdiPipelineSlice = createSlice({ + name: 'rdiPipeline', + initialState, + reducers: { + getPipeline: (state) => { + state.loading = true + }, + getPipelineSuccess: (state, { payload }: PayloadAction) => { + state.loading = false + state.data = payload + }, + getPipelineFailure: (state, { payload }) => { + state.loading = false + state.error = payload + }, + } +}) + +export const rdiSelector = (state: RootState) => state.rdi +export const rdiPipelineSelector = (state: RootState) => state.rdi.pipeline + +export const { + getPipeline, + getPipelineSuccess, + getPipelineFailure, +} = rdiPipelineSlice.actions + +// The reducer +export default rdiPipelineSlice.reducer + +// Asynchronous thunk action +export function fetchRdiPipeline( + rdiInstanceId: string, + onSuccessAction?: (data: any) => void, + onFailAction?: () => void, +) { + return async (dispatch: AppDispatch) => { + try { + dispatch(getPipeline()) + const { data, status } = await apiService.get( + // TODO connect with Kyle to find solution + `rdi/${rdiInstanceId}/pipeline` + ) + + if (isStatusSuccessful(status)) { + dispatch(getPipelineSuccess(data)) + + onSuccessAction?.(data) + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(getPipelineFailure(errorMessage)) + onFailAction?.() + } + } +} diff --git a/redisinsight/ui/src/slices/store.ts b/redisinsight/ui/src/slices/store.ts index e96ae89903..f64c7173b6 100644 --- a/redisinsight/ui/src/slices/store.ts +++ b/redisinsight/ui/src/slices/store.ts @@ -45,6 +45,7 @@ import recommendationsReducer from './recommendations/recommendations' import triggeredFunctionsReducer from './triggeredFunctions/triggeredFunctions' import insightsPanelReducer from './panels/insights' import appRDIReducer from './rdi/rdi' +import rdiPipelineSlice from './rdi/pipeline' export const history = createBrowserHistory() @@ -113,7 +114,8 @@ export const rootReducer = combineReducers({ insights: insightsPanelReducer, }), rdi: combineReducers({ - rdi: appRDIReducer + rdi: appRDIReducer, + pipeline: rdiPipelineSlice, }) }) diff --git a/redisinsight/ui/src/slices/tests/rdi/pipeline.spec.ts b/redisinsight/ui/src/slices/tests/rdi/pipeline.spec.ts new file mode 100644 index 0000000000..f6416608f4 --- /dev/null +++ b/redisinsight/ui/src/slices/tests/rdi/pipeline.spec.ts @@ -0,0 +1,147 @@ +import { cloneDeep } from 'lodash' +import { AxiosError } from 'axios' +import { cleanup, initialStateDefault, mockedStore } from 'uiSrc/utils/test-utils' +import reducer, { + initialState, + getPipeline, + getPipelineSuccess, + getPipelineFailure, + fetchRdiPipeline, + rdiSelector, +} from 'uiSrc/slices/rdi/pipeline' +import { apiService } from 'uiSrc/services' +import { addErrorNotification } from 'uiSrc/slices/app/notifications' + +let store: typeof mockedStore + +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('rdi pipe slice', () => { + describe('reducer, actions and selectors', () => { + it('should return the initial state', () => { + // Arrange + const nextState = initialState + + // Act + const result = reducer(undefined, {}) + + // Assert + expect(result).toEqual(nextState) + }) + }) + + describe('rdiSelector', () => { + it('should properly set state', () => { + // Arrange + const state = { + ...initialState, + loading: true, + } + + // Act + const nextState = reducer(initialState, getPipeline()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + rdi: nextState, + }) + expect(rdiSelector(rootState)).toEqual(state) + }) + }) + + describe('getPipelineSuccess', () => { + it('should properly set state', () => { + // Arrange + const pipeline = { config: 'string', jobs: [] } + const state = { + ...initialState, + data: pipeline, + } + + // Act + const nextState = reducer(initialState, getPipelineSuccess(pipeline)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + rdi: nextState, + }) + expect(rdiSelector(rootState)).toEqual(state) + }) + }) + + describe('getPipelineFailure', () => { + it('should properly set state', () => { + // Arrange + const error = 'error' + const state = { + ...initialState, + error, + } + + // Act + const nextState = reducer(initialState, getPipelineFailure(error)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + rdi: nextState, + }) + expect(rdiSelector(rootState)).toEqual(state) + }) + }) + + // thunks + + describe('thunks', () => { + describe('fetchRdiPipeline', () => { + it('succeed to fetch data', async () => { + const data = { config: 'string', jobs: [] } + const responsePayload = { data, status: 200 } + + apiService.get = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch( + fetchRdiPipeline('123') + ) + + // Assert + const expectedActions = [ + getPipeline(), + getPipelineSuccess(data), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to fetch data', async () => { + const errorMessage = 'Something was wrong!' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.get = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch( + fetchRdiPipeline('123') + ) + + // Assert + const expectedActions = [ + getPipeline(), + addErrorNotification(responsePayload as AxiosError), + getPipelineFailure(errorMessage) + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) + }) +}) diff --git a/redisinsight/ui/src/telemetry/pageViews.ts b/redisinsight/ui/src/telemetry/pageViews.ts index 7515f2af01..f799900dfd 100644 --- a/redisinsight/ui/src/telemetry/pageViews.ts +++ b/redisinsight/ui/src/telemetry/pageViews.ts @@ -8,5 +8,6 @@ export enum TelemetryPageView { CLUSTER_DETAILS_PAGE = 'Overview', PUBSUB_PAGE = 'Pub/Sub', DATABASE_ANALYSIS = 'Database Analysis', - TRIGGERED_FUNCTIONS = 'Triggers and Functions' + TRIGGERED_FUNCTIONS = 'Triggers and Functions', + RDI_CONFIG = 'RDI Configuration' } From be6cc6ac7dad8ca53a1a1459dc2c4f1a1173cf54 Mon Sep 17 00:00:00 2001 From: kyle-marcum Date: Mon, 11 Dec 2023 14:47:17 -0600 Subject: [PATCH 007/566] RI-5152: Display the list of RDI instances --- .../src/modules/rdi/models/export-instance.ts | 11 + .../api/src/modules/rdi/models/rdi.ts | 29 +- .../ui/src/assets/img/rdi/empty_list.svg | 54 +++ .../ui/src/assets/img/rdi/new_tab.svg | 3 + .../ui/src/assets/img/rdi/redis_db.svg | 13 + redisinsight/ui/src/assets/img/rdi/rocket.svg | 38 ++ .../components/item-list/ItemList.spec.tsx | 110 ++++++ .../ui/src/components/item-list/ItemList.tsx | 158 ++++++++ .../components/action-bar/ActionBar.spec.tsx | 0 .../components/action-bar/ActionBar.tsx | 0 .../components/action-bar/styles.module.scss | 0 .../delete-action/DeleteAction.spec.tsx | 22 ++ .../components/delete-action/DeleteAction.tsx | 43 +- .../export-action/ExportAction.spec.tsx | 4 +- .../components/export-action/ExportAction.tsx | 37 +- .../item-list}/components/index.ts | 0 .../item-list}/components/styles.module.scss | 2 +- .../ui/src/components/item-list/index.ts | 3 + .../components/item-list/styles.module.scss | 25 ++ .../ui/src/components/item-list/styles.scss | 221 +++++++++++ .../main-router/constants/defaultRoutes.ts | 11 +- .../constants/sub-routes/rdiRoutes.ts | 5 + .../notifications/success-messages.tsx | 34 ++ redisinsight/ui/src/constants/api.ts | 3 + redisinsight/ui/src/constants/pages.ts | 9 +- redisinsight/ui/src/constants/storage.ts | 1 + .../RedisCloudDatabasesResultPage.spec.tsx | 2 +- .../RedisCloudDatabasesPage.spec.tsx | 2 +- redisinsight/ui/src/pages/home/HomePage.tsx | 6 +- .../DatabasesListWrapper.spec.tsx | 373 ++++++++++++++++++ .../DatabasesListWrapper.tsx | 217 +++++----- .../index.ts | 0 .../styles.module.scss | 50 +-- .../DatabasesListWrapper.spec.tsx | 192 --------- .../databases-list/DatabasesList.spec.tsx | 110 ------ .../databases-list/DatabasesList.tsx | 160 -------- .../delete-action/DeleteAction.spec.tsx | 51 --- .../databases-list/index.ts | 3 - .../SearchDatabasesList.spec.tsx | 2 + .../ui/src/pages/home/styles.module.scss | 3 - redisinsight/ui/src/pages/home/styles.scss | 224 ----------- .../src/pages/rdi/header/RdiHeader.spec.tsx | 12 + .../ui/src/pages/rdi/header/RdiHeader.tsx | 34 ++ .../ui/src/pages/rdi/home/RDIList.tsx | 14 - .../ui/src/pages/rdi/home/RdiPage.spec.tsx | 80 ++++ .../ui/src/pages/rdi/home/RdiPage.tsx | 73 ++++ .../rdi/home/components/EmptyMessage.spec.tsx | 9 + .../rdi/home/components/EmptyMessage.tsx | 61 +++ .../rdi/home/components/styles.module.scss | 43 ++ redisinsight/ui/src/pages/rdi/home/index.ts | 4 +- .../ui/src/pages/rdi/home/styles.module.scss | 13 + .../RdiInstancesListWrapper.spec.tsx | 217 ++++++++++ .../instance-list/RdiInstancesListWrapper.tsx | 236 +++++++++++ .../rdi/instance-list/styles.module.scss | 21 + .../pages/rdi/search/SearchRdiList.spec.tsx | 155 ++++++++ .../ui/src/pages/rdi/search/SearchRdiList.tsx | 54 +++ .../src/pages/rdi/search/styles.module.scss | 6 + .../ui/src/slices/interfaces/index.ts | 1 + redisinsight/ui/src/slices/interfaces/rdi.ts | 25 +- redisinsight/ui/src/slices/rdi/instances.ts | 299 ++++++++++++++ redisinsight/ui/src/slices/rdi/rdi.ts | 21 - redisinsight/ui/src/slices/store.ts | 4 +- redisinsight/ui/src/telemetry/events.ts | 7 + redisinsight/ui/src/utils/test-utils.tsx | 4 +- 64 files changed, 2620 insertions(+), 1004 deletions(-) create mode 100644 redisinsight/api/src/modules/rdi/models/export-instance.ts create mode 100644 redisinsight/ui/src/assets/img/rdi/empty_list.svg create mode 100644 redisinsight/ui/src/assets/img/rdi/new_tab.svg create mode 100644 redisinsight/ui/src/assets/img/rdi/redis_db.svg create mode 100644 redisinsight/ui/src/assets/img/rdi/rocket.svg create mode 100644 redisinsight/ui/src/components/item-list/ItemList.spec.tsx create mode 100644 redisinsight/ui/src/components/item-list/ItemList.tsx rename redisinsight/ui/src/{pages/home/components/databases-list-component/databases-list => components/item-list}/components/action-bar/ActionBar.spec.tsx (100%) rename redisinsight/ui/src/{pages/home/components/databases-list-component/databases-list => components/item-list}/components/action-bar/ActionBar.tsx (100%) rename redisinsight/ui/src/{pages/home/components/databases-list-component/databases-list => components/item-list}/components/action-bar/styles.module.scss (100%) create mode 100644 redisinsight/ui/src/components/item-list/components/delete-action/DeleteAction.spec.tsx rename redisinsight/ui/src/{pages/home/components/databases-list-component/databases-list => components/item-list}/components/delete-action/DeleteAction.tsx (62%) rename redisinsight/ui/src/{pages/home/components/databases-list-component/databases-list => components/item-list}/components/export-action/ExportAction.spec.tsx (88%) rename redisinsight/ui/src/{pages/home/components/databases-list-component/databases-list => components/item-list}/components/export-action/ExportAction.tsx (72%) rename redisinsight/ui/src/{pages/home/components/databases-list-component/databases-list => components/item-list}/components/index.ts (100%) rename redisinsight/ui/src/{pages/home/components/databases-list-component/databases-list => components/item-list}/components/styles.module.scss (98%) create mode 100644 redisinsight/ui/src/components/item-list/index.ts create mode 100644 redisinsight/ui/src/components/item-list/styles.module.scss create mode 100644 redisinsight/ui/src/components/item-list/styles.scss create mode 100644 redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.spec.tsx rename redisinsight/ui/src/pages/home/components/{databases-list-component => database-list-component}/DatabasesListWrapper.tsx (72%) rename redisinsight/ui/src/pages/home/components/{databases-list-component => database-list-component}/index.ts (100%) rename redisinsight/ui/src/pages/home/components/{databases-list-component => database-list-component}/styles.module.scss (71%) delete mode 100644 redisinsight/ui/src/pages/home/components/databases-list-component/DatabasesListWrapper.spec.tsx delete mode 100644 redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/DatabasesList.spec.tsx delete mode 100644 redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/DatabasesList.tsx delete mode 100644 redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/delete-action/DeleteAction.spec.tsx delete mode 100644 redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/index.ts create mode 100644 redisinsight/ui/src/pages/rdi/header/RdiHeader.spec.tsx create mode 100644 redisinsight/ui/src/pages/rdi/header/RdiHeader.tsx delete mode 100644 redisinsight/ui/src/pages/rdi/home/RDIList.tsx create mode 100644 redisinsight/ui/src/pages/rdi/home/RdiPage.spec.tsx create mode 100644 redisinsight/ui/src/pages/rdi/home/RdiPage.tsx create mode 100644 redisinsight/ui/src/pages/rdi/home/components/EmptyMessage.spec.tsx create mode 100644 redisinsight/ui/src/pages/rdi/home/components/EmptyMessage.tsx create mode 100644 redisinsight/ui/src/pages/rdi/home/components/styles.module.scss create mode 100644 redisinsight/ui/src/pages/rdi/home/styles.module.scss create mode 100644 redisinsight/ui/src/pages/rdi/instance-list/RdiInstancesListWrapper.spec.tsx create mode 100644 redisinsight/ui/src/pages/rdi/instance-list/RdiInstancesListWrapper.tsx create mode 100644 redisinsight/ui/src/pages/rdi/instance-list/styles.module.scss create mode 100644 redisinsight/ui/src/pages/rdi/search/SearchRdiList.spec.tsx create mode 100644 redisinsight/ui/src/pages/rdi/search/SearchRdiList.tsx create mode 100644 redisinsight/ui/src/pages/rdi/search/styles.module.scss create mode 100644 redisinsight/ui/src/slices/rdi/instances.ts delete mode 100644 redisinsight/ui/src/slices/rdi/rdi.ts diff --git a/redisinsight/api/src/modules/rdi/models/export-instance.ts b/redisinsight/api/src/modules/rdi/models/export-instance.ts new file mode 100644 index 0000000000..a1cb90c7ef --- /dev/null +++ b/redisinsight/api/src/modules/rdi/models/export-instance.ts @@ -0,0 +1,11 @@ +import { PickType } from '@nestjs/swagger'; +import { Rdi } from './rdi'; + +export class ExportInstance extends PickType(Rdi, [ + 'id', + 'url', + 'name', + 'username', + 'password', + 'lastConnection', +] as const) {} diff --git a/redisinsight/api/src/modules/rdi/models/rdi.ts b/redisinsight/api/src/modules/rdi/models/rdi.ts index 898dd1cd9e..fe8f9db7bc 100644 --- a/redisinsight/api/src/modules/rdi/models/rdi.ts +++ b/redisinsight/api/src/modules/rdi/models/rdi.ts @@ -1,8 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Expose } from 'class-transformer'; -import { - IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, MaxLength, ValidateIf -} from 'class-validator'; +import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; export enum RdiType { API = 'api', @@ -22,11 +20,10 @@ export class Rdi { enum: RdiType, }) @Expose() - @IsNotEmpty() @IsEnum(RdiType, { message: `Type must be a valid enum value from: ${Object.values(RdiType)}.`, }) - type: RdiType; + type?: RdiType; @ApiPropertyOptional({ description: 'Base url of API to connect to (for API type only)', @@ -97,5 +94,25 @@ export class Rdi { example: '2021-01-06T12:44:39.000Z', }) @Expose() - lastConnection: Date; + lastConnection?: Date; + + @ApiPropertyOptional({ + description: 'The version of RDI being used', + type: String, + }) + @IsOptional() + @Expose() + @IsNotEmpty() + @IsString() + version?: string; + + @ApiPropertyOptional({ + description: 'A newly created connection', + type: Boolean, + default: false, + }) + @Expose() + @IsOptional() + @IsBoolean({ always: true }) + new?: boolean; } diff --git a/redisinsight/ui/src/assets/img/rdi/empty_list.svg b/redisinsight/ui/src/assets/img/rdi/empty_list.svg new file mode 100644 index 0000000000..ff78a7e6dc --- /dev/null +++ b/redisinsight/ui/src/assets/img/rdi/empty_list.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/rdi/new_tab.svg b/redisinsight/ui/src/assets/img/rdi/new_tab.svg new file mode 100644 index 0000000000..55d50b41ec --- /dev/null +++ b/redisinsight/ui/src/assets/img/rdi/new_tab.svg @@ -0,0 +1,3 @@ + + + diff --git a/redisinsight/ui/src/assets/img/rdi/redis_db.svg b/redisinsight/ui/src/assets/img/rdi/redis_db.svg new file mode 100644 index 0000000000..354e7d8778 --- /dev/null +++ b/redisinsight/ui/src/assets/img/rdi/redis_db.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/redisinsight/ui/src/assets/img/rdi/rocket.svg b/redisinsight/ui/src/assets/img/rdi/rocket.svg new file mode 100644 index 0000000000..eab387e389 --- /dev/null +++ b/redisinsight/ui/src/assets/img/rdi/rocket.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/redisinsight/ui/src/components/item-list/ItemList.spec.tsx b/redisinsight/ui/src/components/item-list/ItemList.spec.tsx new file mode 100644 index 0000000000..a1abb409bc --- /dev/null +++ b/redisinsight/ui/src/components/item-list/ItemList.spec.tsx @@ -0,0 +1,110 @@ +import { EuiBasicTableColumn } from '@elastic/eui' +import React from 'react' +import { ConnectionType, Instance } from 'uiSrc/slices/interfaces' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' +import ItemList, { Props } from './ItemList' + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn() +})) + +const columnsMock: EuiBasicTableColumn[] = [ + { + field: 'subscriptionId', + className: 'column_subscriptionId', + name: 'Subscription ID', + dataType: 'string', + sortable: true, + width: '170px', + truncateText: true + } +] + +const columnVariationsMock: EuiBasicTableColumn[][] = [columnsMock, columnsMock] + +const mockedProps: Props = { + loading: false, + data: [ + { + id: 'e37cc441-a4f2-402c-8bdb-fc2413cbbaff', + host: 'localhost', + port: 6379, + name: 'localhost', + username: null, + password: null, + connectionType: ConnectionType.Standalone, + nameFromProvider: null, + modules: [], + version: null, + lastConnection: new Date('2021-04-22T09:03:56.917Z') + }, + { + id: 'a0db1bc8-a353-4c43-a856-b72f4811d2d4', + host: 'localhost', + port: 12000, + name: 'oea123123', + username: null, + password: null, + connectionType: ConnectionType.Standalone, + nameFromProvider: null, + modules: [], + version: null, + tls: true + } + ], + width: 0, + dialogIsOpen: false, + editedInstance: null, + columnVariations: columnVariationsMock, + onDelete: () => {}, + onExport: () => {}, + onWheel: () => {}, + onTableChange: () => {}, + sort: { + field: 'subscriptionId', + direction: 'asc' + } +} + +describe('ItemList', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call onDelete', async () => { + const onDelete = jest.fn() + render() + + // select items to be deleted + fireEvent.click(screen.getAllByLabelText(/Select all rows/i)[0]) + + // click delete button + const deleteButton = await screen.findByText('Delete') + fireEvent.click(deleteButton) + + // confirm delete + const deleteButtons = await screen.findAllByText('Delete') + fireEvent.click(deleteButtons[1]) + + expect(onDelete).toBeCalledTimes(1) + }) + + it('should call onExport', async () => { + const onExport = jest.fn() + render() + + // select items to be exported + fireEvent.click(screen.getAllByLabelText(/Select all rows/i)[0]) + + // click export button + const exportButton = await screen.findByText('Export') + fireEvent.click(exportButton) + + // confirm export + const exportButtons = await screen.findAllByText('Export') + fireEvent.click(exportButtons[1]) + + expect(onExport).toBeCalledTimes(1) + }) +}) diff --git a/redisinsight/ui/src/components/item-list/ItemList.tsx b/redisinsight/ui/src/components/item-list/ItemList.tsx new file mode 100644 index 0000000000..72f0a3ed88 --- /dev/null +++ b/redisinsight/ui/src/components/item-list/ItemList.tsx @@ -0,0 +1,158 @@ +import { + Criteria, + EuiBasicTableColumn, + EuiInMemoryTable, + EuiTableSelectionType, + PropertySort, +} from '@elastic/eui' +import cx from 'classnames' +import { first, last } from 'lodash' +import React, { useEffect, useRef, useState } from 'react' +import { Maybe, Nullable } from 'uiSrc/utils' + +import { ActionBar, DeleteAction, ExportAction } from './components' + +import './styles.scss' +import styles from './styles.module.scss' + +export interface Props { + width: number + dialogIsOpen: boolean + editedInstance: Nullable + columnVariations: EuiBasicTableColumn[][] + onDelete: (ids: T[]) => void + onExport: (ids: T[], withSecrets: boolean) => void + onWheel: () => void + loading: boolean + data: T[] + onTableChange: ({ sort, page }: Criteria) => void + sort: PropertySort +} + +const columnsHideWidth = 950 + +function ItemList({ + width, + dialogIsOpen, + columnVariations, + onDelete, + onExport, + onWheel, + editedInstance, + loading, + data: instances, + onTableChange, + sort +}: Props) { + const [columns, setColumns] = useState(first(columnVariations)) + const [selection, setSelection] = useState([]) + const [message, setMessage] = useState>(undefined) + + const tableRef = useRef>(null) + const containerTableRef = useRef(null) + + useEffect(() => { + if (containerTableRef.current) { + const { offsetWidth } = containerTableRef.current + + if (dialogIsOpen) { + setColumns(columnVariations[1]) + return + } + + if (offsetWidth < columnsHideWidth && columns?.length !== last(columnVariations)?.length) { + setColumns(last(columnVariations)) + return + } + + if (offsetWidth > columnsHideWidth && columns?.length !== first(columnVariations)?.length) { + setColumns(first(columnVariations)) + } + } + }, [width]) + + useEffect(() => { + if (loading) { + setMessage('loading...') + return + } + + if (instances.every(({ visible }) => !visible)) { + setMessage( +
+
No results found
+
No results matched your search. Try reducing the criteria.
+
+ ) + } + }, [instances, loading]) + + const selectionValue: EuiTableSelectionType = { + onSelectionChange: (selected: T[]) => setSelection(selected) + } + + const handleResetSelection = () => { + tableRef.current?.setSelection([]) + } + + const handleDelete = () => { + onDelete(selection) + } + + const handleExport = (instances: T[], withSecrets: boolean) => { + onExport(instances, withSecrets) + tableRef.current?.setSelection([]) + } + + const toggleSelectedRow = (instance: T) => ({ + className: cx({ + 'euiTableRow-isSelected': instance?.id === editedInstance?.id + }) + }) + + const actionMsg = (action: string) => ` + Selected + ${' '} + ${selection.length} + ${' '} + items will be ${action} from + RedisInsight: + ` + + return ( +
+ visible)} + itemId="id" + loading={loading} + message={message} + columns={columns ?? []} + rowProps={toggleSelectedRow} + sorting={{ sort }} + selection={selectionValue} + onWheel={onWheel} + onTableChange={onTableChange} + isSelectable + /> + + {selection.length > 0 && ( + selection={selection} onExport={handleExport} subTitle={actionMsg('exported')} />, + + selection={selection} + onDelete={handleDelete} + subTitle={actionMsg('deleted')} + /> + ]} + width={width} + /> + )} +
+ ) +} + +export default ItemList diff --git a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/action-bar/ActionBar.spec.tsx b/redisinsight/ui/src/components/item-list/components/action-bar/ActionBar.spec.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/action-bar/ActionBar.spec.tsx rename to redisinsight/ui/src/components/item-list/components/action-bar/ActionBar.spec.tsx diff --git a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/action-bar/ActionBar.tsx b/redisinsight/ui/src/components/item-list/components/action-bar/ActionBar.tsx similarity index 100% rename from redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/action-bar/ActionBar.tsx rename to redisinsight/ui/src/components/item-list/components/action-bar/ActionBar.tsx diff --git a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/action-bar/styles.module.scss b/redisinsight/ui/src/components/item-list/components/action-bar/styles.module.scss similarity index 100% rename from redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/action-bar/styles.module.scss rename to redisinsight/ui/src/components/item-list/components/action-bar/styles.module.scss diff --git a/redisinsight/ui/src/components/item-list/components/delete-action/DeleteAction.spec.tsx b/redisinsight/ui/src/components/item-list/components/delete-action/DeleteAction.spec.tsx new file mode 100644 index 0000000000..121b88268b --- /dev/null +++ b/redisinsight/ui/src/components/item-list/components/delete-action/DeleteAction.spec.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' + +import { INSTANCES_MOCK } from 'uiSrc/mocks/handlers/instances/instancesHandlers' +import DeleteAction from './DeleteAction' + +describe('DeleteAction', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should display instances that are going to be deleted', () => { + const onDelete = jest.fn() + render() + + fireEvent.click(screen.getByTestId('delete-btn')) + + expect(screen.getByText('localhost')).toBeInTheDocument() + expect(screen.getByText('oea123123')).toBeInTheDocument() + expect(screen.getByText('sentinel')).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/delete-action/DeleteAction.tsx b/redisinsight/ui/src/components/item-list/components/delete-action/DeleteAction.tsx similarity index 62% rename from redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/delete-action/DeleteAction.tsx rename to redisinsight/ui/src/components/item-list/components/delete-action/DeleteAction.tsx index 68c9aeab9d..ae5290d1c9 100644 --- a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/delete-action/DeleteAction.tsx +++ b/redisinsight/ui/src/components/item-list/components/delete-action/DeleteAction.tsx @@ -1,27 +1,20 @@ import React, { useState } from 'react' import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiPopover, EuiText } from '@elastic/eui' -import { Instance } from 'uiSrc/slices/interfaces' import { formatLongName } from 'uiSrc/utils' -import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import styles from '../styles.module.scss' -export interface Props { - selection: Instance[] - onDelete: (instances: Instance[]) => void +export interface Props { + selection: T[] + onDelete: () => void + subTitle: string } -const DeleteAction = (props: Props) => { - const { selection, onDelete } = props +const DeleteAction = (props: Props) => { + const { selection, onDelete, subTitle } = props const [isPopoverOpen, setIsPopoverOpen] = useState(false) const onButtonClick = () => { - sendEventTelemetry({ - event: TelemetryEvent.CONFIG_DATABASES_MULTIPLE_DATABASES_DELETE_CLICKED, - eventData: { - databasesIds: selection.map((selected: Instance) => selected.id) - } - }) setIsPopoverOpen((prevState) => !prevState) } @@ -54,25 +47,11 @@ const DeleteAction = (props: Props) => { data-testid="delete-popover" > -

- Selected - {' '} - {selection.length} - {' '} - databases will be deleted from - RedisInsight Databases: -

+

{subTitle}

-
- {selection.map((select: Instance) => ( - +
+ {selection.map((select) => ( + @@ -90,7 +69,7 @@ const DeleteAction = (props: Props) => { iconType="trash" onClick={() => { closePopover() - onDelete(selection) + onDelete() }} className={styles.popoverDeleteBtn} data-testid="delete-selected-dbs" diff --git a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/export-action/ExportAction.spec.tsx b/redisinsight/ui/src/components/item-list/components/export-action/ExportAction.spec.tsx similarity index 88% rename from redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/export-action/ExportAction.spec.tsx rename to redisinsight/ui/src/components/item-list/components/export-action/ExportAction.spec.tsx index c49850888b..aff4c10ff5 100644 --- a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/export-action/ExportAction.spec.tsx +++ b/redisinsight/ui/src/components/item-list/components/export-action/ExportAction.spec.tsx @@ -6,12 +6,13 @@ import ExportAction from './ExportAction' describe('ExportAction', () => { it('should render', () => { - expect(render()).toBeTruthy() + expect(render()).toBeTruthy() }) it('should call onExport with proper data', () => { const onExport = jest.fn() render() @@ -25,6 +26,7 @@ describe('ExportAction', () => { it('should call onExport with proper data', () => { const onExport = jest.fn() render() diff --git a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/export-action/ExportAction.tsx b/redisinsight/ui/src/components/item-list/components/export-action/ExportAction.tsx similarity index 72% rename from redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/export-action/ExportAction.tsx rename to redisinsight/ui/src/components/item-list/components/export-action/ExportAction.tsx index 99e6d8be51..0b0bca315c 100644 --- a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/export-action/ExportAction.tsx +++ b/redisinsight/ui/src/components/item-list/components/export-action/ExportAction.tsx @@ -3,23 +3,24 @@ import { EuiButton, EuiCheckbox, EuiFlexGroup, - EuiFlexItem, EuiFormRow, + EuiFlexItem, + EuiFormRow, EuiIcon, EuiPopover, EuiText, } from '@elastic/eui' -import { Instance } from 'uiSrc/slices/interfaces' import { formatLongName } from 'uiSrc/utils' import styles from '../styles.module.scss' -export interface Props { - selection: Instance[] - onExport: (instances: Instance[], withSecrets: boolean) => void +export interface Props { + selection: T[] + onExport: (instances: T[], withSecrets: boolean) => void + subTitle: string } -const ExportAction = (props: Props) => { - const { selection, onExport } = props +const ExportAction = (props: Props) => { + const { selection, onExport, subTitle } = props const [isPopoverOpen, setIsPopoverOpen] = useState(false) const [withSecrets, setWithSecrets] = useState(true) @@ -48,25 +49,11 @@ const ExportAction = (props: Props) => { data-testid="export-popover" > -

- Selected - {' '} - {selection.length} - {' '} - databases will be exported from - RedisInsight Databases: -

+

{subTitle}

-
- {selection.map((select: Instance) => ( - +
+ {selection.map((select) => ( + diff --git a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/index.ts b/redisinsight/ui/src/components/item-list/components/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/index.ts rename to redisinsight/ui/src/components/item-list/components/index.ts diff --git a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/styles.module.scss b/redisinsight/ui/src/components/item-list/components/styles.module.scss similarity index 98% rename from redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/styles.module.scss rename to redisinsight/ui/src/components/item-list/components/styles.module.scss index a94f19fae9..912bc45072 100644 --- a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/styles.module.scss +++ b/redisinsight/ui/src/components/item-list/components/styles.module.scss @@ -47,7 +47,7 @@ } } -.noSearchResults { +.noResults { display: flex; height: calc(100vh - 315px); diff --git a/redisinsight/ui/src/components/item-list/index.ts b/redisinsight/ui/src/components/item-list/index.ts new file mode 100644 index 0000000000..cfc6d00203 --- /dev/null +++ b/redisinsight/ui/src/components/item-list/index.ts @@ -0,0 +1,3 @@ +import ItemList from './ItemList' + +export default ItemList diff --git a/redisinsight/ui/src/components/item-list/styles.module.scss b/redisinsight/ui/src/components/item-list/styles.module.scss new file mode 100644 index 0000000000..b6726a4910 --- /dev/null +++ b/redisinsight/ui/src/components/item-list/styles.module.scss @@ -0,0 +1,25 @@ +@import "@elastic/eui/src/global_styling/index"; + +.noResults { + display: flex; + + height: calc(100vh - 315px); + align-items: center; + flex-direction: column; + justify-content: center; + + @media (min-width: 768px) and (max-width: 1100px) { + height: calc(100vh - 223px); + } + + @media (min-width: 1101px) { + height: calc(100vh - 248px); + } +} + +.tableMsgTitle { + font-size: 18px; + margin-bottom: 12px; + height: 24px; + color: var(--htmlColor) !important; +} diff --git a/redisinsight/ui/src/components/item-list/styles.scss b/redisinsight/ui/src/components/item-list/styles.scss new file mode 100644 index 0000000000..54440e8648 --- /dev/null +++ b/redisinsight/ui/src/components/item-list/styles.scss @@ -0,0 +1,221 @@ +@import "@elastic/eui/src/global_styling/index"; + +.itemList { + @include euiScrollBar; + + overflow: auto; + position: relative; + + background-color: var(--euiColorEmptyShade); + + .euiBasicTable { + border-top: none; + } + + .euiTable { + position: relative; + background-color: transparent; + } + + thead tr { + background-color: var(--euiColorEmptyShade); + height: 54px; + + &:first-child { + border-left: 1px solid var(--euiColorLightShade); + } + &:last-child { + border-right: 1px solid var(--euiColorLightShade); + } + } + + tbody tr { + &:last-child { + border-bottom: 1px solid var(--euiColorLightShade); + } + } + + .euiTableHeaderCell, + .euiTableHeaderCellCheckbox { + padding-top: 3px; + position: sticky; + top: 0; + z-index: 1; + background-color: var(--euiColorEmptyShade); + border-bottom: none !important; + + box-shadow: inset 0 1px 0 var(--euiColorLightShade), inset 0 -1px 0 var(--euiColorLightShade); + } + + .euiTableRow { + font-size: 14px !important; + height: 48px; + + .column_name { + cursor: pointer; + padding-top: 0; + padding-bottom: 0; + + div { + line-height: 47px; + display: block; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + width: 100%; + min-width: 40px; + } + + :global(.euiToolTipAnchor) { + max-width: 100%; + } + } + + .copyHostPortText, + .copyUrlText, + .copyPublicEndpointText, + .column_name, + .column_name .euiToolTipAnchor { + display: inline-block; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: 100%; + vertical-align: top; + } + + .euiIcon--medium { + width: 18px; + height: 18px; + } + + .column_controls { + float: right; + width: 100%; + + .euiPopover { + text-align: right; + position: absolute; + right: 15px; + } + } + + .host_port, + .url, + .public_endpoint { + height: 24px; + line-height: 24px; + width: auto; + max-width: 100%; + padding-right: 34px; + position: relative; + + * { + color: var(--textColorShade) !important; + } + + &:hover .copyHostPortBtn, + &:hover .copyUrlBtn, + &:hover .copyPublicEndpointBtn { + opacity: 1; + height: auto; + } + } + + .copyHostPortBtn, + .copyUrlBtn, + .copyPublicEndpointBtn { + margin-left: 25px; + opacity: 0; + height: 0; + transition: opacity 250ms ease-in-out; + } + + .copyHostPortText, + .copyUrlText, + .copyPublicEndpointText { + display: inline-block; + width: auto; + max-width: 100%; + } + + .copyPublicEndpointText { + max-width: calc(100% - 50px); + } + + .copyHostPortTooltip, + .copyUrlTooltip, + .copyPublicEndpointTooltip { + position: absolute; + right: 0; + } + + .column_copy { + padding-left: 50%; + } + + .deleteInstancePopover { + width: 100%; + } + + .deleteInstanceTooltip { + margin-right: 10%; + } + + .editInstanceBtn { + position: absolute; + right: 50px; + } + + &:nth-child(odd) { + background-color: var(--euiColorEmptyShade); + .options_icon { + border: 2px solid var(--euiColorEmptyShade); + } + } + &:nth-child(even) { + background-color: var(--browserTableRowEven); + + .options_icon { + border: 2px solid var(--browserTableRowEven); + } + } + + .euiTableRowCell, + .euiTableRowCellCheckbox { + border-bottom-width: 0; + } + + @media only screen and (max-width: 767px) { + height: auto; + } + } + + .euiTableCellContent { + @media only screen and (min-width: 767px) { + padding-left: 14px; + } + } + + .euiTableFooterCell, + .euiTableHeaderCell { + color: var(--htmlColor); + } + + .euiTableHeaderCell { + .euiTableCellContent__text { + font-size: 16px !important; + font-family: "Graphik", sans-serif !important; + font-weight: 500 !important; + } + + .euiTableHeaderButton { + &:hover *, + &:active *, + &:focus * { + color: var(--euiTextColor) !important; + fill: var(--euiTextColor) !important; + } + } + } +} diff --git a/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts b/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts index 7b358d10b6..1a79e6c405 100644 --- a/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts +++ b/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts @@ -13,8 +13,7 @@ import WorkbenchPage from 'uiSrc/pages/workbench' import PubSubPage from 'uiSrc/pages/pub-sub' import AnalyticsPage from 'uiSrc/pages/analytics' import TriggeredFunctionsPage from 'uiSrc/pages/triggered-functions' -import RdiList from 'uiSrc/pages/rdi/home/RDIList' -import RdiPipeline from 'uiSrc/pages/rdi/pipeline/PipelinePage' +import RdiPage from 'uiSrc/pages/rdi/home' import { ANALYTICS_ROUTES, RDI_ROUTES, TRIGGERED_FUNCTIONS_ROUTES } from './sub-routes' import COMMON_ROUTES from './commonRoutes' @@ -78,14 +77,14 @@ const ROUTES: IRoute[] = [ ], }, { - path: '/integrate/:rdiInstanceId', - component: RdiPipeline, - routes: RDI_ROUTES, + path: Pages.rdi, + component: RdiPage, + routes: RDI_ROUTES }, { path: '/:instanceId', component: InstancePage, - routes: INSTANCE_ROUTES, + routes: INSTANCE_ROUTES }, ] diff --git a/redisinsight/ui/src/components/main-router/constants/sub-routes/rdiRoutes.ts b/redisinsight/ui/src/components/main-router/constants/sub-routes/rdiRoutes.ts index 4a691b8a4f..2b0d9190b5 100644 --- a/redisinsight/ui/src/components/main-router/constants/sub-routes/rdiRoutes.ts +++ b/redisinsight/ui/src/components/main-router/constants/sub-routes/rdiRoutes.ts @@ -1,8 +1,13 @@ import { IRoute, Pages } from 'uiSrc/constants' import PreparePage from 'uiSrc/pages/rdi/pipeline/pages/prepare' import ConfigPage from 'uiSrc/pages/rdi/pipeline/pages/config' +import RdiPipeline from 'uiSrc/pages/rdi/pipeline/PipelinePage' export const RDI_ROUTES: IRoute[] = [ + { + path: Pages.rdiPipeline(':rdiInstanceId'), + component: RdiPipeline, + }, { path: Pages.rdiPipelinePrepare(':rdiInstanceId'), component: PreparePage, diff --git a/redisinsight/ui/src/components/notifications/success-messages.tsx b/redisinsight/ui/src/components/notifications/success-messages.tsx index bfada1f0ae..f578d4bf11 100644 --- a/redisinsight/ui/src/components/notifications/success-messages.tsx +++ b/redisinsight/ui/src/components/notifications/success-messages.tsx @@ -28,6 +28,16 @@ export default { ), }), + DELETE_RDI_INSTANCE: (instanceName: string) => ({ + title: 'Instance has been deleted', + message: ( + <> + {formatNameShort(instanceName)} + {' '} + has been deleted from RedisInsight. + + ), + }), DELETE_INSTANCES: (instanceNames: Maybe[]) => { const limitShowRemovedInstances = 10 return { @@ -52,6 +62,30 @@ export default { ), } }, + DELETE_RDI_INSTANCES: (instanceNames: Maybe[]) => { + const limitShowRemovedInstances = 10 + return { + title: 'Instances have been deleted', + message: ( + <> + + {instanceNames.length} + {' '} + instances have been deleted from RedisInsight: + +
    + {instanceNames.slice(0, limitShowRemovedInstances).map((el, i) => ( + // eslint-disable-next-line react/no-array-index-key +
  • + {formatNameShort(el)} +
  • + ))} + {instanceNames.length >= limitShowRemovedInstances &&
  • ...
  • } +
+ + ), + } + }, ADDED_NEW_KEY: (keyName: RedisResponseBuffer) => ({ title: 'Key has been added', message: ( diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index e6c1b4d532..0e6e8d1ffe 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -4,6 +4,9 @@ enum ApiEndpoints { DATABASES_TEST_CONNECTION = 'databases/test', DATABASES_EXPORT = 'databases/export', + RDI_INSTANCES = 'rdi/instances', + RDI_INSTANCES_EXPORT = 'rdi/instances/export', + BULK_ACTIONS_IMPORT = 'bulk-actions/import', BULK_ACTIONS_IMPORT_TUTORIAL_DATA = 'bulk-actions/import/tutorial-data', diff --git a/redisinsight/ui/src/constants/pages.ts b/redisinsight/ui/src/constants/pages.ts index d230d696bc..4c66366b46 100644 --- a/redisinsight/ui/src/constants/pages.ts +++ b/redisinsight/ui/src/constants/pages.ts @@ -24,6 +24,7 @@ export enum PageNames { const redisCloud = '/redis-cloud' const sentinel = '/sentinel' +const rdi = '/integrate' export const Pages = { home: '/', @@ -50,8 +51,8 @@ export const Pages = { triggeredFunctionsFunctions: (instanceId: string) => `/${instanceId}/${PageNames.triggeredFunctions}/${PageNames.triggeredFunctionsFunctions}`, // rdi pages - rdi: '/integrate', - rdiPipeline: (rdiInstance: string) => `integrate/${rdiInstance}/pipeline}`, - rdiPipelineConfig: (rdiInstance: string) => `/integrate/${rdiInstance}/pipeline/config`, - rdiPipelinePrepare: (rdiInstance: string) => `/integrate/${rdiInstance}/pipeline/prepare`, + rdi, + rdiPipeline: (rdiInstanceId: string) => `${rdi}/${rdiInstanceId}/pipeline}`, + rdiPipelineConfig: (rdiInstanceId: string) => `${rdi}/${rdiInstanceId}/pipeline/config`, + rdiPipelinePrepare: (rdiInstanceId: string) => `${rdi}/${rdiInstanceId}/pipeline/prepare`, } diff --git a/redisinsight/ui/src/constants/storage.ts b/redisinsight/ui/src/constants/storage.ts index 73d701a67c..5a11a7570c 100644 --- a/redisinsight/ui/src/constants/storage.ts +++ b/redisinsight/ui/src/constants/storage.ts @@ -1,6 +1,7 @@ enum BrowserStorageItem { instancesCount = 'instancesCount', instancesSorting = 'instancesSorting', + rdiInstancesSorting = 'rdiInstancesSorting', theme = 'theme', browserViewType = 'browserViewType', browserSearchMode = 'browserSearchMode', diff --git a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases-result/RedisCloudDatabasesResultPage.spec.tsx b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases-result/RedisCloudDatabasesResultPage.spec.tsx index 856fa482fb..d677e52857 100644 --- a/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases-result/RedisCloudDatabasesResultPage.spec.tsx +++ b/redisinsight/ui/src/pages/autodiscover-cloud/redis-cloud-databases-result/RedisCloudDatabasesResultPage.spec.tsx @@ -16,7 +16,7 @@ const mockRedisCloudDatabasesResult = (props: RedisCloudDatabasesResultProps) =>
-
+
( -
+
{ paddingSize="none" >
- { ) : (
{!!instances.length || loading ? ( - () + +jest.mock('uiSrc/components/item-list/ItemList', () => ({ + __esModule: true, + namedExport: jest.fn(), + default: jest.fn() +})) + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn() +})) + +jest.mock('file-saver', () => ({ + saveAs: jest.fn() +})) + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn() +})) + +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 + } + } +] + +jest.mock('uiSrc/slices/instances/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances/instances'), + instancesSelector: jest.fn().mockReturnValue({ + loading: false, + error: '', + data: mockInstances, + }) +})) + +const mockDatabasesList = (props: ItemListProps) => { + const columns = first(props.columnVariations) + + if (!columns) { + return null + } + + return ( +
+ + + +
+ +
+
+ ) +} + +describe('DatabasesListWrapper', () => { + beforeAll(() => { + (ItemList as jest.Mock).mockImplementation(mockDatabasesList) + }) + + beforeEach(() => { + const state: RootState = store.getState(); + (useSelector as jest.Mock).mockImplementation((callback: (arg0: RootState) => RootState) => + callback({ + ...state, + analytics: { + ...state.analytics + }, + connections: { + ...state.connections, + instances: { + ...state.connections.instances, + data: mockInstances + } + } + })) + }) + + 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() + + await act(() => { + fireEvent.click(screen.getByTestId('onExport-btn')) + }) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CONFIG_DATABASES_REDIS_EXPORT_SUCCEEDED, + eventData: { + numberOfDatabases: 1 + } + }); + + (sendEventTelemetry as jest.Mock).mockRestore() + }) + + it('should call proper telemetry on fail export', async () => { + mswServer.use(...errorHandlers) + const sendEventTelemetryMock = jest.fn(); + + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + render() + + await act(() => { + fireEvent.click(screen.getByTestId('onExport-btn')) + }) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CONFIG_DATABASES_REDIS_EXPORT_FAILED, + eventData: { + numberOfDatabases: 1 + } + }); + + (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() + + await act(() => { + const copyHostPortButtons = screen.getAllByLabelText(/Copy host:port/i) + fireEvent.click(copyHostPortButtons[0]) + }) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CONFIG_DATABASES_HOST_PORT_COPIED, + eventData: { + databaseId: 'e37cc441-a4f2-402c-8bdb-fc2413cbbaff' + } + }); + + (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 + }, + 'Triggers and Functions': { + 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() + }) + + 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/databases-list-component/DatabasesListWrapper.tsx b/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx similarity index 72% rename from redisinsight/ui/src/pages/home/components/databases-list-component/DatabasesListWrapper.tsx rename to redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx index 4021271d0e..202a46efac 100644 --- a/redisinsight/ui/src/pages/home/components/databases-list-component/DatabasesListWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx @@ -1,4 +1,5 @@ import { + Criteria, EuiButtonIcon, EuiIcon, EuiLink, @@ -6,15 +7,33 @@ import { EuiText, EuiTextColor, EuiToolTip, + PropertySort, } from '@elastic/eui' +import cx from 'classnames' +import { saveAs } from 'file-saver' import { capitalize, map } from 'lodash' import React, { useContext, useEffect, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useHistory, useLocation } from 'react-router-dom' -import cx from 'classnames' import AutoSizer from 'react-virtualized-auto-sizer' -import { saveAs } from 'file-saver' +import RediStackDarkMin from 'uiSrc/assets/img/modules/redistack/RediStackDark-min.svg' +import RediStackLightMin from 'uiSrc/assets/img/modules/redistack/RediStackLight-min.svg' +import RediStackDarkLogo from 'uiSrc/assets/img/modules/redistack/RedisStackLogoDark.svg' +import RediStackLightLogo from 'uiSrc/assets/img/modules/redistack/RedisStackLogoLight.svg' +import { ReactComponent as CloudLinkIcon } from 'uiSrc/assets/img/oauth/cloud_link.svg' +import { ShowChildByCondition } from 'uiSrc/components' +import DatabaseListModules from 'uiSrc/components/database-list-modules/DatabaseListModules' +import ItemList from 'uiSrc/components/item-list' +import { BrowserStorageItem, PageNames, 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' +import { localStorageService } from 'uiSrc/services' +import { appContextSelector, setAppContextInitialState } from 'uiSrc/slices/app/context' +import { resetKeys } from 'uiSrc/slices/browser/keys' +import { resetRedisearchKeysData } from 'uiSrc/slices/browser/redisearch' +import { resetCliHelperSettings, resetCliSettingsAction } from 'uiSrc/slices/cli/cli-settings' import { checkConnectToInstanceAction, deleteInstancesAction, @@ -22,45 +41,23 @@ import { instancesSelector, setConnectedInstanceId, } from 'uiSrc/slices/instances/instances' -import { CONNECTION_TYPE_DISPLAY, ConnectionType, Instance, } from 'uiSrc/slices/interfaces' -import { resetKeys } from 'uiSrc/slices/browser/keys' -import { resetRedisearchKeysData } from 'uiSrc/slices/browser/redisearch' -import { PageNames, Pages, Theme } from 'uiSrc/constants' -import { getRedisModulesSummary, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { ThemeContext } from 'uiSrc/contexts/themeContext' -import { ShowChildByCondition } from 'uiSrc/components' -import { formatLongName, getDbIndex, lastConnectionFormat, Nullable, replaceSpaces } from 'uiSrc/utils' -import { appContextSelector, setAppContextInitialState } from 'uiSrc/slices/app/context' -import { resetCliHelperSettings, resetCliSettingsAction } from 'uiSrc/slices/cli/cli-settings' -import DatabaseListModules from 'uiSrc/components/database-list-modules/DatabaseListModules' -import PopoverDelete from 'uiSrc/pages/browser/components/popover-delete/PopoverDelete' -import RediStackDarkMin from 'uiSrc/assets/img/modules/redistack/RediStackDark-min.svg' -import RediStackLightMin from 'uiSrc/assets/img/modules/redistack/RediStackLight-min.svg' -import RediStackLightLogo from 'uiSrc/assets/img/modules/redistack/RedisStackLogoLight.svg' -import RediStackDarkLogo from 'uiSrc/assets/img/modules/redistack/RedisStackLogoDark.svg' -import { ReactComponent as CloudLinkIcon } from 'uiSrc/assets/img/oauth/cloud_link.svg' -import { EXTERNAL_LINKS } from 'uiSrc/constants/links' -import DatabasesList from './databases-list' +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 styles from './styles.module.scss' export interface Props { - width: number; - dialogIsOpen: boolean; - editedInstance: Nullable; - onEditInstance: (instance: Instance) => void; - onDeleteInstances: (instances: Instance[]) => void; + width: number + dialogIsOpen: boolean + editedInstance: Nullable + onEditInstance: (instance: Instance) => void + onDeleteInstances: (instances: Instance[]) => void } const suffix = '_db_instance' -const DatabasesListWrapper = ({ - width, - dialogIsOpen, - onEditInstance, - editedInstance, - onDeleteInstances -}: Props) => { +const DatabasesListWrapper = ({ width, dialogIsOpen, onEditInstance, editedInstance, onDeleteInstances }: Props) => { const dispatch = useDispatch() const history = useHistory() const { search } = useLocation() @@ -170,35 +167,45 @@ const DatabasesListWrapper = ({ } const handleDeleteInstances = (instances: Instance[]) => { + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_MULTIPLE_DATABASES_DELETE_CLICKED, + eventData: { + ids: instances.map((instance) => instance.id) + } + }) dispatch(deleteInstancesAction(instances, () => onDeleteInstances(instances))) } const handleExportInstances = (instances: Instance[], withSecrets: boolean) => { const ids = map(instances, 'id') - dispatch(exportInstancesAction( - ids, - withSecrets, - (data) => { - const file = new Blob([JSON.stringify(data, null, 2)], { type: 'text/plain;charset=utf-8' }) - saveAs(file, `RedisInsight_connections_${Date.now()}.json`) + sendEventTelemetry({ event: TelemetryEvent.CONFIG_DATABASES_REDIS_EXPORT_CLICKED }) - sendEventTelemetry({ - event: TelemetryEvent.CONFIG_DATABASES_REDIS_EXPORT_SUCCEEDED, - eventData: { - numberOfDatabases: ids.length - } - }) - }, - () => { - sendEventTelemetry({ - event: TelemetryEvent.CONFIG_DATABASES_REDIS_EXPORT_FAILED, - eventData: { - numberOfDatabases: ids.length - } - }) - } - )) + dispatch( + exportInstancesAction( + ids, + withSecrets, + (data) => { + const file = new Blob([JSON.stringify(data, null, 2)], { type: 'text/plain;charset=utf-8' }) + saveAs(file, `RedisInsight_connections_${Date.now()}.json`) + + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_REDIS_EXPORT_SUCCEEDED, + eventData: { + numberOfDatabases: ids.length + } + }) + }, + () => { + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_REDIS_EXPORT_FAILED, + eventData: { + numberOfDatabases: ids.length + } + }) + } + ) + ) } const handleClickGoToCloud = () => { @@ -222,16 +229,10 @@ const DatabasesListWrapper = ({ const cellContent = replaceSpaces(name.substring(0, 200)) return ( -
+
{newStatus && ( - +
@@ -249,14 +250,10 @@ const DatabasesListWrapper = ({ onClick={(e: React.MouseEvent) => handleCheckConnectToInstance(e, instance)} onKeyDown={(e: React.KeyboardEvent) => handleCheckConnectToInstance(e, instance)} > - + {cellContent} - - {` ${getDbIndex(db)}`} - + {` ${getDbIndex(db)}`} @@ -277,11 +274,7 @@ const DatabasesListWrapper = ({ return (
{text} - + - CONNECTION_TYPE_DISPLAY[cellData] || capitalize(cellData), + render: (cellData: ConnectionType) => CONNECTION_TYPE_DISPLAY[cellData] || capitalize(cellData) }, { field: 'modules', @@ -317,24 +309,30 @@ const DatabasesListWrapper = ({ {({ width: columnWidth }) => (
- ) : undefined} - tooltipTitle={isRediStack ? ( - <> + content={ + isRediStack ? ( - Includes - - ) : undefined} + ) : undefined + } + tooltipTitle={ + isRediStack ? ( + <> + + + Includes + + + ) : undefined + } modules={modules} - maxViewModules={columnWidth > 40 ? (Math.floor((columnWidth - 12) / 28) - 1) : 0} + maxViewModules={columnWidth && columnWidth > 40 ? Math.floor((columnWidth - 12) / 28) - 1 : 0} />
)} @@ -349,8 +347,7 @@ const DatabasesListWrapper = ({ dataType: 'date', align: 'right', width: '170px', - sortable: ({ lastConnection }) => - (lastConnection ? -new Date(`${lastConnection}`) : -Infinity), + sortable: ({ lastConnection }) => (lastConnection ? -new Date(`${lastConnection}`) : -Infinity), render: (date: Date) => lastConnectionFormat(date), }, { @@ -362,9 +359,7 @@ const DatabasesListWrapper = ({ return ( <> {instance.cloudDetails && ( - + columnsHideForTablet.indexOf(field) === -1 - ) - const columnsEditing = columnsFull.filter( - ({ field }) => columnsHideForEditing.indexOf(field) === -1 - ) + const columnsTablet = columnsFull.filter(({ field = '' }) => columnsHideForTablet.indexOf(field) === -1) + const columnsEditing = columnsFull.filter(({ field }) => columnsHideForEditing.indexOf(field) === -1) const columnVariations = [columnsFull, columnsEditing, columnsTablet] + const onTableChange = ({ sort, page }: Criteria) => { + // calls also with page changing + if (sort && !page) { + localStorageService.set(BrowserStorageItem.instancesSorting, sort) + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_DATABASES_DATABASE_LIST_SORTED, + eventData: sort + }) + } + } + + const sort: PropertySort = localStorageService.get(BrowserStorageItem.instancesSorting) ?? { + field: 'lastConnection', + direction: 'asc' + } + return (
- width={width} editedInstance={editedInstance} dialogIsOpen={dialogIsOpen} @@ -423,6 +430,10 @@ const DatabasesListWrapper = ({ onDelete={handleDeleteInstances} onExport={handleExportInstances} onWheel={closePopover} + loading={instances.loading} + data={instances.data} + onTableChange={onTableChange} + sort={sort} />
) diff --git a/redisinsight/ui/src/pages/home/components/databases-list-component/index.ts b/redisinsight/ui/src/pages/home/components/database-list-component/index.ts similarity index 100% rename from redisinsight/ui/src/pages/home/components/databases-list-component/index.ts rename to redisinsight/ui/src/pages/home/components/database-list-component/index.ts diff --git a/redisinsight/ui/src/pages/home/components/databases-list-component/styles.module.scss b/redisinsight/ui/src/pages/home/components/database-list-component/styles.module.scss similarity index 71% rename from redisinsight/ui/src/pages/home/components/databases-list-component/styles.module.scss rename to redisinsight/ui/src/pages/home/components/database-list-component/styles.module.scss index e719321d32..de6ba2f178 100644 --- a/redisinsight/ui/src/pages/home/components/databases-list-component/styles.module.scss +++ b/redisinsight/ui/src/pages/home/components/database-list-component/styles.module.scss @@ -42,10 +42,6 @@ $breakpoint-l: 1400px; } } -.icon { - margin-right: 5px; -} - .columnModules { height: 40px; padding-right: 0 !important; @@ -56,38 +52,6 @@ $breakpoint-l: 1400px; height: 18px !important; } -.noSearchResults { - display: flex; - - height: calc(100vh - 315px); - align-items: center; - flex-direction: column; - justify-content: center; - - @media (min-width: 768px) and (max-width: 1100px) { - height: calc(100vh - 223px); - } - - @media (min-width: 1101px) { - height: calc(100vh - 248px); - } -} - -.tableMsgTitle { - font-size: 18px; - margin-bottom: 12px; - height: 24px; - color: var(--htmlColor) !important; -} - -.columnNew { - padding: 0 !important; - - .euiFlexItem { - margin: 0 !important; - } -} - .newStatus { background-color: var(--euiColorPrimary) !important; cursor: pointer; @@ -109,6 +73,20 @@ $breakpoint-l: 1400px; tr > td:nth-child(2) { padding-left: 5px !important; } + + :global { + .itemList { + height: calc(100vh - 207px); + + @media (min-width: 768px) and (max-width: 1100px) { + height: calc(100vh - 150px); + } + + @media (min-width: 1101px) { + height: calc(100vh - 175px); + } + } + } } .cloudIcon { diff --git a/redisinsight/ui/src/pages/home/components/databases-list-component/DatabasesListWrapper.spec.tsx b/redisinsight/ui/src/pages/home/components/databases-list-component/DatabasesListWrapper.spec.tsx deleted file mode 100644 index c1f6d74d86..0000000000 --- a/redisinsight/ui/src/pages/home/components/databases-list-component/DatabasesListWrapper.spec.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import React from 'react' -import { instance, mock } from 'ts-mockito' -import { EuiInMemoryTable } from '@elastic/eui' -import { useSelector } from 'react-redux' - -import { first } from 'lodash' -import { render, screen, fireEvent, act } from 'uiSrc/utils/test-utils' -import { mswServer } from 'uiSrc/mocks/server' -import { ConnectionType } from 'uiSrc/slices/interfaces' -import { RootState, store } from 'uiSrc/slices/store' -import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { errorHandlers } from 'uiSrc/mocks/res/responseComposition' -import DatabasesListWrapper, { Props } from './DatabasesListWrapper' -import DatabasesList, { Props as DatabasesListProps } from './databases-list/DatabasesList' - -const mockedProps = mock() - -jest.mock('./databases-list/DatabasesList', () => ({ - __esModule: true, - namedExport: jest.fn(), - default: jest.fn(), -})) - -jest.mock('uiSrc/telemetry', () => ({ - ...jest.requireActual('uiSrc/telemetry'), - sendEventTelemetry: jest.fn(), -})) - -jest.mock('file-saver', () => ({ - saveAs: jest.fn() -})) - -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useSelector: jest.fn() -})) - -const mockInstances = [ - { - id: 'e37cc441-a4f2-402c-8bdb-fc2413cbbaff', - host: 'localhost', - port: 6379, - name: 'localhost', - username: null, - password: null, - connectionType: ConnectionType.Standalone, - nameFromProvider: null, - new: true, - lastConnection: new Date('2021-04-22T09:03:56.917Z'), - }, - { - id: 'a0db1bc8-a353-4c43-a856-b72f4811d2d4', - host: 'localhost', - port: 12000, - name: 'oea123123', - username: null, - password: null, - connectionType: ConnectionType.Standalone, - nameFromProvider: null, - lastConnection: null, - tls: { - verifyServerCert: true, - caCertId: '70b95d32-c19d-4311-bb24-e684af12cf15', - clientCertPairId: '70b95d32-c19d-4311-b23b24-e684af12cf15', - }, - cloudDetails: {} - }, -] - -const mockDatabasesList = (props: DatabasesListProps) => ( -
- - -
- -
-
-) - -beforeEach(() => { - const state: RootState = store.getState(); - - (useSelector as jest.Mock).mockImplementation((callback: (arg0: RootState) => RootState) => callback({ - ...state, - analytics: { - ...state.analytics - }, - connections: { - ...state.connections, - instances: mockInstances, - } - })) -}) - -describe('DatabasesListWrapper', () => { - beforeAll(() => { - DatabasesList.mockImplementation(mockDatabasesList) - }) - it('should render', () => { - expect( - render() - ).toBeTruthy() - }) - - it('should call onDelete', () => { - DatabasesList.mockImplementation(mockDatabasesList) - - const component = render() - fireEvent.click(screen.getByTestId('onDelete-btn')) - expect(component).toBeTruthy() - }) - - it('should show indicator for a new connection', () => { - DatabasesList.mockImplementation(mockDatabasesList) - - 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.mockImplementation(() => sendEventTelemetryMock) - render() - - await act(() => { - fireEvent.click(screen.getByTestId('onExport-btn')) - }) - - expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.CONFIG_DATABASES_REDIS_EXPORT_SUCCEEDED, - eventData: { - numberOfDatabases: 1 - } - }) - }) - - it('should call proper telemetry on fail export', async () => { - mswServer.use(...errorHandlers) - const sendEventTelemetryMock = jest.fn() - - sendEventTelemetry.mockImplementation(() => sendEventTelemetryMock) - render() - - await act(() => { - fireEvent.click(screen.getByTestId('onExport-btn')) - }) - - expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.CONFIG_DATABASES_REDIS_EXPORT_FAILED, - eventData: { - numberOfDatabases: 1 - } - }) - - sendEventTelemetry.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 ling', () => { - const sendEventTelemetryMock = jest.fn() - - sendEventTelemetry.mockImplementation(() => sendEventTelemetryMock) - render() - - fireEvent.click(screen.getByTestId(`cloud-link-${mockInstances[1].id}`)) - - expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.CLOUD_LINK_CLICKED, - }) - - sendEventTelemetry.mockRestore() - }) -}) diff --git a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/DatabasesList.spec.tsx b/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/DatabasesList.spec.tsx deleted file mode 100644 index ee9ffaf1b9..0000000000 --- a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/DatabasesList.spec.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { EuiBasicTableColumn } from '@elastic/eui' -import React from 'react' -import { instance, mock } from 'ts-mockito' -import { Instance } from 'uiSrc/slices/interfaces' -import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' -import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import DatabasesList, { Props } from './DatabasesList' - -const mockedProps = mock() - -jest.mock('uiSrc/telemetry', () => ({ - ...jest.requireActual('uiSrc/telemetry'), - sendEventTelemetry: jest.fn(), -})) - -jest.mock('uiSrc/slices/instances/instances', () => ({ - ...jest.requireActual('uiSrc/slices/instances/instances'), - instancesSelector: jest.fn().mockReturnValue({ - loading: false, - error: '', - data: [ - { - id: 'e37cc441-a4f2-402c-8bdb-fc2413cbbaff', - host: 'localhost', - port: 6379, - name: 'localhost', - username: null, - password: null, - connectionType: 'standalone', - nameFromProvider: null, - modules: [], - uoeu: 123, - lastConnection: new Date('2021-04-22T09:03:56.917Z'), - }, - { - id: 'a0db1bc8-a353-4c43-a856-b72f4811d2d4', - host: 'localhost', - port: 12000, - name: 'oea123123', - username: null, - password: null, - connectionType: 'standalone', - nameFromProvider: null, - modules: [], - tls: { - verifyServerCert: true, - caCertId: '70b95d32-c19d-4311-bb24-e684af12cf15', - clientCertPairId: '70b95d32-c19d-4311-b23b24-e684af12cf15', - }, - } - ] - }) -})) - -const columnsMock = [ - { - field: 'subscriptionId', - className: 'column_subscriptionId', - name: 'Subscription ID', - dataType: 'string', - sortable: true, - width: '170px', - truncateText: true, - }, -] - -const columnVariationsMock: EuiBasicTableColumn[][] = [ - columnsMock, - columnsMock, -] - -describe('DatabasesList', () => { - it('should render', () => { - expect( - render( - - ) - ).toBeTruthy() - }) - - it('should call onExport and send telemetry on click export btn', () => { - const sendEventTelemetryMock = jest.fn() - const onExport = jest.fn() - - sendEventTelemetry.mockImplementation(() => sendEventTelemetryMock) - - const { container } = render( - - ) - - fireEvent.click( - container.querySelector('[data-test-subj="checkboxSelectAll"]') as Element - ) - - fireEvent.click(screen.getByTestId('export-btn')) - fireEvent.click(screen.getByTestId('export-selected-dbs')) - - expect(onExport).toBeCalled() - expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.CONFIG_DATABASES_REDIS_EXPORT_CLICKED - }) - }) -}) diff --git a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/DatabasesList.tsx b/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/DatabasesList.tsx deleted file mode 100644 index 49fba83ca7..0000000000 --- a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/DatabasesList.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { - Criteria, - Direction, - EuiBasicTableColumn, - EuiInMemoryTable, - EuiTableSelectionType, - PropertySort, -} from '@elastic/eui' -import cx from 'classnames' -import { first, last } from 'lodash' -import React, { useEffect, useRef, useState } from 'react' -import { useSelector } from 'react-redux' -import { instancesSelector } from 'uiSrc/slices/instances/instances' -import { Instance } from 'uiSrc/slices/interfaces' -import { Nullable } from 'uiSrc/utils' -import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { localStorageService } from 'uiSrc/services' -import { BrowserStorageItem } from 'uiSrc/constants' - -import { ActionBar, DeleteAction, ExportAction } from './components' - -import styles from '../styles.module.scss' - -export interface Props { - width: number - dialogIsOpen: boolean - editedInstance: Nullable - columnVariations: EuiBasicTableColumn[][] - onDelete: (ids: Instance[]) => void - onExport: (ids: Instance[], withSecrets: boolean) => void - onWheel: () => void -} - -const columnsHideWidth = 950 -const loadingMsg = 'loading...' - -function DatabasesList({ - width, - dialogIsOpen, - columnVariations, - onDelete, - onExport, - onWheel, - editedInstance, -}: Props) { - const [columns, setColumns] = useState(first(columnVariations)) - const [selection, setSelection] = useState([]) - - const { loading, data: instances } = useSelector(instancesSelector) - - const tableRef = useRef>(null) - const containerTableRef = useRef(null) - - useEffect(() => { - if (containerTableRef.current) { - const { offsetWidth } = containerTableRef.current - - if (dialogIsOpen) { - setColumns(columnVariations[1]) - return - } - - if ( - offsetWidth < columnsHideWidth - && columns?.length !== last(columnVariations)?.length - ) { - setColumns(last(columnVariations)) - return - } - - if ( - offsetWidth > columnsHideWidth - && columns?.length !== first(columnVariations)?.length - ) { - setColumns(first(columnVariations)) - } - } - }, [width]) - - const sort: PropertySort = localStorageService.get(BrowserStorageItem.instancesSorting) ?? { - field: 'lastConnection', - direction: 'asc', - } - - const selectionValue: EuiTableSelectionType = { - onSelectionChange: (selected: Instance[]) => setSelection(selected), - } - - const handleResetSelection = () => { - tableRef.current?.setSelection([]) - } - - const handleExport = (instances: Instance[], withSecrets: boolean) => { - sendEventTelemetry({ - event: TelemetryEvent.CONFIG_DATABASES_REDIS_EXPORT_CLICKED - }) - onExport(instances, withSecrets) - tableRef.current?.setSelection([]) - } - - const toggleSelectedRow = (instance: Instance) => ({ - className: cx({ - 'euiTableRow-isSelected': instance?.id === editedInstance?.id, - }), - }) - - const onTableChange = ({ sort, page }: Criteria) => { - // calls also with page changing - if (sort && !page) { - localStorageService.set(BrowserStorageItem.instancesSorting, sort) - sendEventSortedTelemetry(sort) - } - } - - const sendEventSortedTelemetry = (sort: { field: keyof Instance; direction: Direction }) => - sendEventTelemetry({ - event: TelemetryEvent.CONFIG_DATABASES_DATABASE_LIST_SORTED, - eventData: sort - }) - - const noSearchResultsMsg = ( -
-
No results found
-
No databases matched your search. Try reducing the criteria.
-
- ) - - return ( -
- visible)} - itemId="id" - loading={loading} - message={instances.length ? noSearchResultsMsg : loadingMsg} - columns={columns ?? []} - rowProps={toggleSelectedRow} - sorting={{ sort }} - selection={selectionValue} - onWheel={onWheel} - onTableChange={onTableChange} - isSelectable - /> - - {selection.length > 0 && ( - , - - ]} - width={width} - /> - )} -
- ) -} - -export default DatabasesList diff --git a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/delete-action/DeleteAction.spec.tsx b/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/delete-action/DeleteAction.spec.tsx deleted file mode 100644 index 1abc3c9996..0000000000 --- a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/components/delete-action/DeleteAction.spec.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react' -import { map } from 'lodash' -import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' - -import { INSTANCES_MOCK } from 'uiSrc/mocks/handlers/instances/instancesHandlers' -import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import DeleteAction from './DeleteAction' - -jest.mock('uiSrc/telemetry', () => ({ - ...jest.requireActual('uiSrc/telemetry'), - sendEventTelemetry: jest.fn(), -})) - -describe('DeleteAction', () => { - it('should render', () => { - expect(render()).toBeTruthy() - }) - - it('should call onDelete with proper data', () => { - const onDelete = jest.fn() - render() - - fireEvent.click(screen.getByTestId('delete-btn')) - fireEvent.click(screen.getByTestId('delete-selected-dbs')) - - expect(onDelete).toBeCalledWith(INSTANCES_MOCK) - }) - - it('should call telemetry on click delete btn', () => { - const sendEventTelemetryMock = jest.fn() - - sendEventTelemetry.mockImplementation(() => sendEventTelemetryMock) - - render() - - fireEvent.click(screen.getByTestId('delete-btn')) - - expect(sendEventTelemetry).toBeCalledWith({ - event: TelemetryEvent.CONFIG_DATABASES_MULTIPLE_DATABASES_DELETE_CLICKED, - eventData: { - databasesIds: map(INSTANCES_MOCK, 'id') - } - }) - }) -}) diff --git a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/index.ts b/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/index.ts deleted file mode 100644 index 06b6eca03c..0000000000 --- a/redisinsight/ui/src/pages/home/components/databases-list-component/databases-list/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import DatabasesList from './DatabasesList' - -export default DatabasesList diff --git a/redisinsight/ui/src/pages/home/components/search-databases-list/SearchDatabasesList.spec.tsx b/redisinsight/ui/src/pages/home/components/search-databases-list/SearchDatabasesList.spec.tsx index 75fe6859c8..fcd662c051 100644 --- a/redisinsight/ui/src/pages/home/components/search-databases-list/SearchDatabasesList.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/search-databases-list/SearchDatabasesList.spec.tsx @@ -21,6 +21,7 @@ const instancesMock: Instance[] = [{ visible: true, modules: [], lastConnection: new Date(), + version: '' }, { id: '2', name: 'cloud', @@ -29,6 +30,7 @@ const instancesMock: Instance[] = [{ visible: true, modules: [], lastConnection: new Date(), + version: '' }] beforeEach(() => { diff --git a/redisinsight/ui/src/pages/home/styles.module.scss b/redisinsight/ui/src/pages/home/styles.module.scss index 49520ed6f4..7adcb26140 100644 --- a/redisinsight/ui/src/pages/home/styles.module.scss +++ b/redisinsight/ui/src/pages/home/styles.module.scss @@ -1,7 +1,4 @@ @import "@elastic/eui/src/global_styling/index"; -.contentActive { - border: 1px solid var(--euiColorPrimary); -} .page { padding: 16px 0 0 !important; diff --git a/redisinsight/ui/src/pages/home/styles.scss b/redisinsight/ui/src/pages/home/styles.scss index 130a8a3c0d..0124fd63b3 100644 --- a/redisinsight/ui/src/pages/home/styles.scss +++ b/redisinsight/ui/src/pages/home/styles.scss @@ -3,230 +3,6 @@ @import "@elastic/eui/src/global_styling/index"; .homePage { - .databaseList { - @include euiScrollBar; - - height: calc(100vh - 207px); - overflow: auto; - position: relative; - - background-color: var(--euiColorEmptyShade); - - - @media (min-width: 768px) and (max-width: 1100px) { - height: calc(100vh - 150px); - } - - @media (min-width: 1101px) { - height: calc(100vh - 175px); - } - - .euiBasicTable { - border-top: none; - } - - .euiTable { - position: relative; - background-color: transparent; - } - - thead tr { - background-color: var(--euiColorEmptyShade); - height: 54px; - - &:first-child { - border-left: 1px solid var(--euiColorLightShade); - } - &:last-child { - border-right: 1px solid var(--euiColorLightShade); - } - } - - tbody tr { - &:last-child { - border-bottom: 1px solid var(--euiColorLightShade); - } - } - - .euiTableHeaderCell, - .euiTableHeaderCellCheckbox { - padding-top: 3px; - position: sticky; - top: 0; - z-index: 1; - background-color: var(--euiColorEmptyShade); - border-bottom: none !important; - - box-shadow: inset 0 1px 0 var(--euiColorLightShade), inset 0 -1px 0 var(--euiColorLightShade); - } - - .euiTableRow { - font-size: 14px !important; - height: 48px; - - .column_name { - cursor: pointer; - padding-top: 0; - padding-bottom: 0; - - div { - line-height: 47px; - display: block; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - width: 100%; - min-width: 40px; - } - - :global(.euiToolTipAnchor) { - max-width: 100%; - } - } - - .copyHostPortText, - .copyPublicEndpointText, - .column_name, - .column_name .euiToolTipAnchor { - display: inline-block; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - max-width: 100%; - vertical-align: top; - } - - .euiIcon--medium { - width: 18px; - height: 18px; - } - - .column_controls { - float: right; - width: 100%; - - .euiPopover { - text-align: right; - position: absolute; - right: 15px; - } - } - - .host_port, - .public_endpoint { - height: 24px; - line-height: 24px; - width: auto; - max-width: 100%; - padding-right: 34px; - position: relative; - - * { - color: var(--textColorShade) !important; - } - - &:hover .copyHostPortBtn, - &:hover .copyPublicEndpointBtn { - opacity: 1; - height: auto; - } - } - - .copyHostPortBtn, - .copyPublicEndpointBtn { - margin-left: 25px; - opacity: 0; - height: 0; - transition: opacity 250ms ease-in-out; - } - - .copyHostPortText, - .copyPublicEndpointText { - display: inline-block; - width: auto; - max-width: 100%; - } - - .copyPublicEndpointText { - max-width: calc(100% - 50px); - } - - .copyHostPortTooltip, - .copyPublicEndpointTooltip { - position: absolute; - right: 0; - } - - .column_copy { - padding-left: 50%; - } - - .deleteInstancePopover { - width: 100%; - } - - .deleteInstanceTooltip { - margin-right: 10%; - } - - .editInstanceBtn { - position: absolute; - right: 50px; - } - - &:nth-child(odd) { - background-color: var(--euiColorEmptyShade); - .options_icon { - border: 2px solid var(--euiColorEmptyShade); - } - } - &:nth-child(even) { - background-color: var(--browserTableRowEven); - - .options_icon { - border: 2px solid var(--browserTableRowEven); - } - } - - .euiTableRowCell, - .euiTableRowCellCheckbox { - border-bottom-width: 0; - } - - @media only screen and (max-width: 767px) { - height: auto; - } - } - - .euiTableCellContent { - @media only screen and (min-width: 767px) { - padding-left: 14px; - } - } - - .euiTableFooterCell, - .euiTableHeaderCell { - color: var(--htmlColor); - } - - .euiTableHeaderCell { - .euiTableCellContent__text { - font-size: 16px !important; - font-family: "Graphik", sans-serif !important; - font-weight: 500 !important; - } - - .euiTableHeaderButton { - &:hover *, - &:active *, - &:focus * { - color: var(--euiTextColor) !important; - fill: var(--euiTextColor) !important; - } - } - } - } - .euiTitle { padding-bottom: 15px; font-size: 18px; diff --git a/redisinsight/ui/src/pages/rdi/header/RdiHeader.spec.tsx b/redisinsight/ui/src/pages/rdi/header/RdiHeader.spec.tsx new file mode 100644 index 0000000000..07676d5a43 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/header/RdiHeader.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { render } from 'uiSrc/utils/test-utils' +import RdiHeader, { Props } from './RdiHeader' + +const mockedProps = mock() + +describe('RdiHeader', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/header/RdiHeader.tsx b/redisinsight/ui/src/pages/rdi/header/RdiHeader.tsx new file mode 100644 index 0000000000..c8c538b8ac --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/header/RdiHeader.tsx @@ -0,0 +1,34 @@ +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui' +import React from 'react' + +import SearchRdiList from '../search/SearchRdiList' + +export interface Props { + onAddInstance: () => void +} + +const RdiHeader = ({ onAddInstance }: Props) => ( + + + + + + + + + + RDI instance + + + + {}} data-testid="import-rdi-instance"> + Import + + + + + + +) + +export default RdiHeader diff --git a/redisinsight/ui/src/pages/rdi/home/RDIList.tsx b/redisinsight/ui/src/pages/rdi/home/RDIList.tsx deleted file mode 100644 index 0ac9ac1761..0000000000 --- a/redisinsight/ui/src/pages/rdi/home/RDIList.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react' - -export interface Props { - -} - -// todo: rename -const RdiList = () => ( -
- RDI List -
-) - -export default RdiList diff --git a/redisinsight/ui/src/pages/rdi/home/RdiPage.spec.tsx b/redisinsight/ui/src/pages/rdi/home/RdiPage.spec.tsx new file mode 100644 index 0000000000..eddb7562e0 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/home/RdiPage.spec.tsx @@ -0,0 +1,80 @@ +import { cloneDeep } from 'lodash' +import React from 'react' +import { useSelector } from 'react-redux' +import { instance, mock } from 'ts-mockito' +import { RdiInstance } from 'uiSrc/slices/interfaces' +import { RootState, store } from 'uiSrc/slices/store' +import { cleanup, mockedStore, render, screen } from 'uiSrc/utils/test-utils' + +import RdiPage, { Props } from './RdiPage' + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn() +})) + +let storeMock: typeof mockedStore +const instancesMock: RdiInstance[] = [ + { + id: '1', + name: 'My first integration', + url: 'redis-12345.c253.us-central1-1.gce.cloud.redislabs.com:12345', + lastConnection: new Date(), + version: '1.2', + visible: true + } +] + +const mockedProps = mock() + +describe('RdiPage', () => { + beforeEach(() => { + cleanup() + storeMock = cloneDeep(mockedStore) + storeMock.clearActions() + + const state: RootState = store.getState(); + (useSelector as jest.Mock).mockImplementation((callback: (arg0: RootState) => RootState) => + callback({ + ...state, + rdi: { + ...state.rdi, + instances: { + ...state.rdi.instances, + data: instancesMock + } + } + })) + }) + + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should render empty message when no instances are found', () => { + const state: RootState = store.getState(); + (useSelector as jest.Mock).mockImplementation((callback: (arg0: RootState) => RootState) => + callback({ + ...state, + rdi: { + ...state.rdi, + instances: { + ...state.rdi.instances, + data: [] + } + } + })) + + render() + + expect(screen.queryByTestId('rdi-instance-list')).not.toBeInTheDocument() + expect(screen.getByTestId('empty-rdi-instance-list')).toBeInTheDocument() + }) + + it('should render instance list when instances are found', () => { + render() + + expect(screen.getByTestId('rdi-instance-list')).toBeInTheDocument() + expect(screen.queryByTestId('empty-rdi-instance-list')).not.toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/home/RdiPage.tsx b/redisinsight/ui/src/pages/rdi/home/RdiPage.tsx new file mode 100644 index 0000000000..04e8c93474 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/home/RdiPage.tsx @@ -0,0 +1,73 @@ +import { EuiPage, EuiPageBody, EuiResizeObserver } from '@elastic/eui' +import React, { useEffect, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import { + createInstancesAction, + fetchInstancesAction, + instancesSelector, + setEditedInstance +} from 'uiSrc/slices/rdi/instances' +import EmptyMessage from './components/EmptyMessage' +import RdiHeader from '../header/RdiHeader' +import RdiInstancesListWrapper from '../instance-list/RdiInstancesListWrapper' + +import styles from './styles.module.scss' + +export interface Props {} + +const RdiPage = () => { + const [width, setWidth] = useState(0) + + const { data } = useSelector(instancesSelector) + + const dispatch = useDispatch() + + useEffect(() => { + dispatch(fetchInstancesAction()) + + return () => { + dispatch(setEditedInstance(null)) + } + }, []) + + const onResize = ({ width: innerWidth }: { width: number }) => { + setWidth(innerWidth) + } + + const handleAddInstance = () => { + dispatch(createInstancesAction()) + dispatch(setEditedInstance(null)) + } + + return ( + + {(resizeRef) => ( + + +
+
+ +
+ {!data.length ? ( + + ) : ( +
+ {}} + onDeleteInstances={() => {}} + /> +
+ )} +
+
+
+ )} +
+ ) +} + +export default RdiPage diff --git a/redisinsight/ui/src/pages/rdi/home/components/EmptyMessage.spec.tsx b/redisinsight/ui/src/pages/rdi/home/components/EmptyMessage.spec.tsx new file mode 100644 index 0000000000..563c370279 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/home/components/EmptyMessage.spec.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import { render } from 'uiSrc/utils/test-utils' +import EmptyMessage from './EmptyMessage' + +describe('EmptyMessage', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/home/components/EmptyMessage.tsx b/redisinsight/ui/src/pages/rdi/home/components/EmptyMessage.tsx new file mode 100644 index 0000000000..ee6d235151 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/home/components/EmptyMessage.tsx @@ -0,0 +1,61 @@ +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiImage, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui' +import React from 'react' + +import EmptyListIcon from 'uiSrc/assets/img/rdi/empty_list.svg' +import NewTabIcon from 'uiSrc/assets/img/rdi/new_tab.svg' +import RedisDbIcon from 'uiSrc/assets/img/rdi/redis_db.svg' +import RocketIcon from 'uiSrc/assets/img/rdi/rocket.svg' + +import styles from './styles.module.scss' + +const EmptyMessage = () => { + const moreInfoPanels = [ + { + icon: RedisDbIcon, + title: 'Setting up RDI Server', + description: 'In order to start creating pipelines you need to set up an RDI Server', + link: '' + }, + { + icon: RocketIcon, + title: 'RDI Quickstart', + description: 'Read about RDI and learn how to get started', + link: '' + } + ] + + return ( + +
+ + + No deployments found + Add your first deployment to get started! + More info + + + {moreInfoPanels.map((panel) => ( + + {}}> + + + + + + {panel.title} + {panel.description} + + + + + + + + ))} + +
+
+ ) +} + +export default EmptyMessage diff --git a/redisinsight/ui/src/pages/rdi/home/components/styles.module.scss b/redisinsight/ui/src/pages/rdi/home/components/styles.module.scss new file mode 100644 index 0000000000..f64ada48b3 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/home/components/styles.module.scss @@ -0,0 +1,43 @@ +.basePanel { + height: calc(100vh - 160px); + + @media (min-width: 768px) { + height: calc(100vh - 123px); + + display: flex; + align-items: center; + justify-content: center; + } +} + +.noResultsContainer { + display: flex; + align-items: center; + flex-direction: column; +} + +.subTitle { + font-size: 18px !important; + font-weight: 500 !important; + margin-top: 6px; + margin-bottom: 50px; + color: var(--htmlColor) !important; +} + +.moreInfoContainer { + max-height: 100px; +} + +.moreInfoPanel { + background-color: #2B2B2B !important; + border: none !important; +} + +.moreInfoTitle { + font-weight: 500 !important; +} + +.moreInfoDescription { + font-size: 10px !important; + line-height: 12px !important; +} diff --git a/redisinsight/ui/src/pages/rdi/home/index.ts b/redisinsight/ui/src/pages/rdi/home/index.ts index 99afca22fb..704bcd58e9 100644 --- a/redisinsight/ui/src/pages/rdi/home/index.ts +++ b/redisinsight/ui/src/pages/rdi/home/index.ts @@ -1,3 +1,3 @@ -import RdiList from './RDIList' +import RdiPage from './RdiPage' -export default RdiList +export default RdiPage diff --git a/redisinsight/ui/src/pages/rdi/home/styles.module.scss b/redisinsight/ui/src/pages/rdi/home/styles.module.scss new file mode 100644 index 0000000000..d50e805263 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/home/styles.module.scss @@ -0,0 +1,13 @@ +@import "@elastic/eui/src/global_styling/index"; + +.page { + padding: 16px 0 0 !important; + + @include euiBreakpoint("m", "l", "xl") { + padding: 16px 12px 10px !important; + } +} + +.header { + padding-bottom: 16px; +} diff --git a/redisinsight/ui/src/pages/rdi/instance-list/RdiInstancesListWrapper.spec.tsx b/redisinsight/ui/src/pages/rdi/instance-list/RdiInstancesListWrapper.spec.tsx new file mode 100644 index 0000000000..2eaf2785bf --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance-list/RdiInstancesListWrapper.spec.tsx @@ -0,0 +1,217 @@ +import { EuiInMemoryTable } from '@elastic/eui' +import { first } from 'lodash' +import React from 'react' +import { useSelector } from 'react-redux' +import { instance, mock } from 'ts-mockito' + +import ItemList, { Props as ItemListProps } from 'uiSrc/components/item-list/ItemList' +import { RdiInstance } from 'uiSrc/slices/interfaces' +import { RootState, store } from 'uiSrc/slices/store' +import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' +import { act, fireEvent, render, screen } from 'uiSrc/utils/test-utils' + +import RdiInstancesListWrapper, { Props } from './RdiInstancesListWrapper' + +const mockedProps = mock() + +jest.mock('uiSrc/components/item-list/ItemList', () => ({ + __esModule: true, + namedExport: jest.fn(), + default: jest.fn() +})) + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn() +})) + +jest.mock('file-saver', () => ({ + saveAs: jest.fn() +})) + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn() +})) + +const mockInstances: RdiInstance[] = [ + { + id: '1', + name: 'My first integration', + url: 'redis-12345.c253.us-central1-1.gce.cloud.redislabs.com:12345', + lastConnection: new Date(), + version: '1.2' + }, + { + id: '2', + name: 'My second integration', + url: 'redis-67890.c253.us-central1-1.gce.cloud.redislabs.com:67890', + lastConnection: new Date(), + version: '1.3' + } +] + +jest.mock('uiSrc/slices/instances/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances/instances'), + instancesSelector: jest.fn().mockReturnValue({ + loading: false, + error: '', + data: mockInstances + }) +})) + +const mockRdiInstancesList = (props: ItemListProps) => { + const columns = first(props.columnVariations) + + if (!columns) { + return null + } + + return ( +
+ + + +
+ +
+
+ ) +} + +describe('RdiInstancesListWrapper', () => { + beforeAll(() => { + (ItemList as jest.Mock).mockImplementation(mockRdiInstancesList) + }) + + beforeEach(() => { + const state: RootState = store.getState(); + (useSelector as jest.Mock).mockImplementation((callback: (arg0: RootState) => RootState) => + callback({ + ...state, + analytics: { + ...state.analytics + }, + rdi: { + ...state.rdi, + instances: { + ...state.rdi.instances, + data: mockInstances + } + } + })) + }) + + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call proper telemetry on export', async () => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + render() + + await act(() => { + fireEvent.click(screen.getByTestId('onExport-btn')) + }) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CONFIG_RDI_INSTANCES_EXPORT_CLICKED, + }); + (sendEventTelemetry as jest.Mock).mockRestore() + }) + + it('should call proper telemetry on delete multiple instances', 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_RDI_INSTANCES_MULTIPLE_DELETE_CLICKED, + eventData: { + ids: ['2'] + } + }); + (sendEventTelemetry as jest.Mock).mockRestore() + }) + + it('should call proper telemetry on copy url', async () => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + render() + + await act(() => { + const copyHostPortButtons = screen.getAllByLabelText(/Copy url/i) + fireEvent.click(copyHostPortButtons[0]) + }) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CONFIG_RDI_INSTANCES_URL_COPIED, + eventData: { + id: '1' + } + }); + (sendEventTelemetry as jest.Mock).mockRestore() + }) + + it('should call proper telemetry on delete instance', async () => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + render() + + await act(() => { + fireEvent.click(screen.getByTestId('delete-instance-2-icon')) + }) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CONFIG_RDI_INSTANCES_SINGLE_DELETE_CLICKED, + eventData: { + id: '2' + } + }); + (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_RDI_INSTANCES_LIST_SORTED, + eventData: { field: 'name', direction: 'asc' } + }); + (sendEventTelemetry as jest.Mock).mockRestore() + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/instance-list/RdiInstancesListWrapper.tsx b/redisinsight/ui/src/pages/rdi/instance-list/RdiInstancesListWrapper.tsx new file mode 100644 index 0000000000..ef01a5b39b --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance-list/RdiInstancesListWrapper.tsx @@ -0,0 +1,236 @@ +import { Criteria, EuiButtonIcon, EuiTableFieldDataColumnType, EuiText, EuiToolTip, PropertySort } from '@elastic/eui' +import { saveAs } from 'file-saver' +import { map } from 'lodash' +import React, { useEffect, useRef, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useHistory, useLocation } from 'react-router-dom' + +import ItemList from 'uiSrc/components/item-list' +import { BrowserStorageItem, Pages } from 'uiSrc/constants' +import PopoverDelete from 'uiSrc/pages/browser/components/popover-delete/PopoverDelete' +import { localStorageService } from 'uiSrc/services' +import { RdiInstance } from 'uiSrc/slices/interfaces' +import { deleteInstancesAction, exportInstancesAction, instancesSelector } from 'uiSrc/slices/rdi/instances' +import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' +import { Nullable, formatLongName, lastConnectionFormat } from 'uiSrc/utils' + +import styles from './styles.module.scss' + +export interface Props { + width: number + dialogIsOpen: boolean + editedInstance: Nullable + onEditInstance: (instance: RdiInstance) => void + onDeleteInstances: (instances: RdiInstance[]) => void +} + +const suffix = '_rdi_instance' + +const RdiInstancesListWrapper = ({ width, dialogIsOpen, onEditInstance, editedInstance, onDeleteInstances }: Props) => { + const dispatch = useDispatch() + const history = useHistory() + const { search } = useLocation() + + const instances = useSelector(instancesSelector) + const [, forceRerender] = useState({}) + const deleting = { id: '' } + const isLoadingRef = useRef(false) + + const closePopover = () => { + deleting.id = '' + forceRerender({}) + } + + const showPopover = (id: string) => { + deleting.id = `${id + suffix}` + forceRerender({}) + } + + useEffect(() => { + const editInstanceId = new URLSearchParams(search).get('editInstance') + if (editInstanceId && !instances.loading) { + const instance = instances.data.find((item: RdiInstance) => item.id === editInstanceId) + if (instance) { + handleClickEditInstance(instance) + } + setTimeout(() => { + history.replace(Pages.home) + }, 1000) + } + + isLoadingRef.current = instances.loading + forceRerender({}) + }, [instances.loading, search]) + + useEffect(() => { + closePopover() + }, [width]) + + const handleCopy = (text = '', id: string) => { + navigator.clipboard?.writeText(text) + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_RDI_INSTANCES_URL_COPIED, + eventData: { + id + } + }) + } + + const handleClickDeleteInstance = (id: string) => { + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_RDI_INSTANCES_SINGLE_DELETE_CLICKED, + eventData: { + id + } + }) + showPopover(id) + } + + const handleClickEditInstance = (instance: RdiInstance) => { + onEditInstance(instance) + } + + const handleDeleteInstance = (instance: RdiInstance) => { + dispatch(deleteInstancesAction([instance], () => onDeleteInstances([instance]))) + } + + const handleDeleteInstances = (instances: RdiInstance[]) => { + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_RDI_INSTANCES_MULTIPLE_DELETE_CLICKED, + eventData: { + ids: instances.map((instance) => instance.id) + } + }) + dispatch(deleteInstancesAction(instances, () => onDeleteInstances(instances))) + } + + const handleExportInstances = () => { + sendEventTelemetry({ event: TelemetryEvent.CONFIG_RDI_INSTANCES_EXPORT_CLICKED }) + } + + const columnsFull: EuiTableFieldDataColumnType[] = [ + { + field: 'name', + className: 'column_name', + name: 'RDI Alias', + dataType: 'string', + truncateText: true, + 'data-test-subj': 'rdi-alias-column', + sortable: ({ name }) => name?.toLowerCase(), + width: '30%' + }, + { + field: 'url', + className: 'column_url', + name: 'URL', + width: '35%', + dataType: 'string', + truncateText: true, + sortable: ({ url }) => url?.toLowerCase(), + render: (name: string, { id }) => ( +
+ {name} + + handleCopy(name, id)} + /> + +
+ ) + }, + { + field: 'version', + className: 'column_type', + name: 'RDI Version', + dataType: 'string', + sortable: true, + width: '170px' + }, + { + field: 'lastConnection', + className: 'column_lastConnection', + name: 'Last connection', + dataType: 'date', + align: 'right', + width: '170px', + sortable: ({ lastConnection }) => (lastConnection ? -new Date(`${lastConnection}`) : -Infinity), + render: (date: Date) => lastConnectionFormat(date) + }, + { + field: 'controls', + className: 'column_controls', + width: '120px', + name: '', + render: (_act: any, instance: RdiInstance) => ( + <> + handleClickEditInstance(instance)} + /> + handleDeleteInstance(instance)} + handleButtonClick={() => handleClickDeleteInstance(instance.id)} + testid={`delete-instance-${instance.id}`} + /> + + ) + } + ] + + const columnsHideForTablet = [''] + const columnsHideForEditing = [''] + const columnsTablet = columnsFull.filter(({ field = '' }) => columnsHideForTablet.indexOf(field) === -1) + const columnsEditing = columnsFull.filter(({ field }) => columnsHideForEditing.indexOf(field) === -1) + + const columnVariations = [columnsFull, columnsEditing, columnsTablet] + + const onTableChange = ({ sort, page }: Criteria) => { + // calls also with page changing + if (sort && !page) { + localStorageService.set(BrowserStorageItem.rdiInstancesSorting, sort) + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_RDI_INSTANCES_LIST_SORTED, + eventData: sort + }) + } + } + + const sort: PropertySort = localStorageService.get(BrowserStorageItem.rdiInstancesSorting) ?? { + field: 'lastConnection', + direction: 'asc' + } + + return ( +
+ + width={width} + editedInstance={editedInstance} + dialogIsOpen={dialogIsOpen} + columnVariations={columnVariations} + onDelete={handleDeleteInstances} + onExport={handleExportInstances} + onWheel={closePopover} + loading={instances.loading} + data={instances.data} + onTableChange={onTableChange} + sort={sort} + /> +
+ ) +} + +export default RdiInstancesListWrapper diff --git a/redisinsight/ui/src/pages/rdi/instance-list/styles.module.scss b/redisinsight/ui/src/pages/rdi/instance-list/styles.module.scss new file mode 100644 index 0000000000..58658c328d --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/instance-list/styles.module.scss @@ -0,0 +1,21 @@ +.container { + // RDI alias column + tr > th:nth-child(2), + tr > td:nth-child(2) { + padding-left: 5px !important; + } + + :global { + .itemList { + height: calc(100vh - 261px); + + @media (min-width: 768px) and (max-width: 1100px) { + height: calc(100vh - 122px); + } + + @media (min-width: 1101px) { + height: calc(100vh - 122px); + } + } + } +} diff --git a/redisinsight/ui/src/pages/rdi/search/SearchRdiList.spec.tsx b/redisinsight/ui/src/pages/rdi/search/SearchRdiList.spec.tsx new file mode 100644 index 0000000000..4255732cd3 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/search/SearchRdiList.spec.tsx @@ -0,0 +1,155 @@ +import { cloneDeep } from 'lodash' +import React from 'react' +import { useSelector } from 'react-redux' +import { RdiInstance } from 'uiSrc/slices/interfaces' +import { loadInstancesSuccess } from 'uiSrc/slices/rdi/instances' +import { RootState, store } from 'uiSrc/slices/store' +import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' +import { act, cleanup, fireEvent, mockedStore, render, screen } from 'uiSrc/utils/test-utils' + +import SearchRdiList from './SearchRdiList' + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn() +})) + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn() +})) + +let storeMock: typeof mockedStore +const instancesMock: RdiInstance[] = [ + { + id: '1', + name: 'My first integration', + url: 'redis-12345.c253.us-central1-1.gce.cloud.redislabs.com:12345', + lastConnection: new Date(), + version: '1.2', + visible: true + }, + { + id: '2', + name: 'My second integration', + url: 'redis-67890.c253.us-central1-1.gce.cloud.redislabs.com:67890', + lastConnection: new Date(), + version: '1.3', + visible: true + } +] + +describe('SearchRdiList', () => { + beforeEach(() => { + cleanup() + storeMock = cloneDeep(mockedStore) + storeMock.clearActions() + + const state: RootState = store.getState(); + (useSelector as jest.Mock).mockImplementation((callback: (arg0: RootState) => RootState) => + callback({ + ...state, + rdi: { + ...state.rdi, + instances: { + ...state.rdi.instances, + data: instancesMock + } + } + })) + }) + + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call proper telemetry on instance search', async () => { + const sendEventTelemetryMock = jest.fn(); + + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + render() + + await act(() => { + fireEvent.change(screen.getByTestId('search-rdi-instance-list'), { target: { value: 'first int' } }) + }) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CONFIG_RDI_INSTANCES_LIST_SEARCHED, + eventData: { + instancesFullCount: 2, + instancesSearchedCount: 1, + } + }) + }) + + it('should return all results if filter is blank', async () => { + render() + + await act(() => { + fireEvent.change(screen.getByTestId('search-rdi-instance-list'), { target: { value: ' ' } }) + }) + + const expectedActions = [loadInstancesSuccess(instancesMock)] + expect(storeMock.getActions()).toEqual(expectedActions) + }) + + it('should filter by name', async () => { + const newInstancesMock = [...instancesMock] + render() + + await act(() => { + fireEvent.change(screen.getByTestId('search-rdi-instance-list'), { target: { value: 'second int' } }) + }) + + newInstancesMock[0].visible = false + newInstancesMock[1].visible = true + + const expectedActions = [loadInstancesSuccess(newInstancesMock)] + expect(storeMock.getActions()).toEqual(expectedActions) + }) + + it('should filter by url', async () => { + const newInstancesMock = [...instancesMock] + render() + + await act(() => { + fireEvent.change(screen.getByTestId('search-rdi-instance-list'), { target: { value: 'redislabs.com:12345' } }) + }) + + newInstancesMock[0].visible = true + newInstancesMock[1].visible = false + + const expectedActions = [loadInstancesSuccess(newInstancesMock)] + expect(storeMock.getActions()).toEqual(expectedActions) + }) + + it('should filter by version', async () => { + const newInstancesMock = [...instancesMock] + render() + + await act(() => { + fireEvent.change(screen.getByTestId('search-rdi-instance-list'), { target: { value: '1.2' } }) + }) + + newInstancesMock[0].visible = true + newInstancesMock[1].visible = false + + const expectedActions = [loadInstancesSuccess(newInstancesMock)] + expect(storeMock.getActions()).toEqual(expectedActions) + }) + + it('should filter by lastConnection', async () => { + const newInstancesMock = [...instancesMock] + render() + + await act(() => { + fireEvent.change(screen.getByTestId('search-rdi-instance-list'), { target: { value: 'minute ago' } }) + }) + + newInstancesMock[0].visible = true + newInstancesMock[1].visible = true + + const expectedActions = [loadInstancesSuccess(newInstancesMock)] + expect(storeMock.getActions()).toEqual(expectedActions) + }) +}) diff --git a/redisinsight/ui/src/pages/rdi/search/SearchRdiList.tsx b/redisinsight/ui/src/pages/rdi/search/SearchRdiList.tsx new file mode 100644 index 0000000000..d84ab99f8b --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/search/SearchRdiList.tsx @@ -0,0 +1,54 @@ +import { EuiFieldSearch } from '@elastic/eui' +import React from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import { RdiInstance } from 'uiSrc/slices/interfaces' +import { instancesSelector, loadInstancesSuccess } from 'uiSrc/slices/rdi/instances' +import { TelemetryEvent, sendEventTelemetry } from 'uiSrc/telemetry' +import { lastConnectionFormat } from 'uiSrc/utils' + +import styles from './styles.module.scss' + +const SearchRdiList = () => { + const { data: instances } = useSelector(instancesSelector) + + const dispatch = useDispatch() + + const onQueryChange = (e: React.ChangeEvent) => { + const value = e?.target?.value?.toLowerCase() + + const visibleItems = instances.map( + (item: RdiInstance) => ({ + ...item, + visible: item.name.toLowerCase().indexOf(value) !== -1 + || item.url?.toString()?.indexOf(value) !== -1 + || item.version?.toString()?.indexOf(value) !== -1 + || lastConnectionFormat(item.lastConnection)?.indexOf(value) !== -1 + }) + ) + + sendEventTelemetry({ + event: TelemetryEvent.CONFIG_RDI_INSTANCES_LIST_SEARCHED, + eventData: { + instancesFullCount: instances.length, + instancesSearchedCount: visibleItems.filter(({ visible }) => (visible))?.length, + } + }) + + dispatch(loadInstancesSuccess(visibleItems)) + } + + return ( + + ) +} + +export default SearchRdiList diff --git a/redisinsight/ui/src/pages/rdi/search/styles.module.scss b/redisinsight/ui/src/pages/rdi/search/styles.module.scss new file mode 100644 index 0000000000..698f4c1899 --- /dev/null +++ b/redisinsight/ui/src/pages/rdi/search/styles.module.scss @@ -0,0 +1,6 @@ +.search { + &:global(.euiFieldSearch) { + border: none !important; + background-color: #171717 !important; + } +} diff --git a/redisinsight/ui/src/slices/interfaces/index.ts b/redisinsight/ui/src/slices/interfaces/index.ts index 9f6d300387..e3294a071d 100644 --- a/redisinsight/ui/src/slices/interfaces/index.ts +++ b/redisinsight/ui/src/slices/interfaces/index.ts @@ -7,3 +7,4 @@ export * from './api' export * from './bulkActions' export * from './redisearch' export * from './cloud' +export * from './rdi' diff --git a/redisinsight/ui/src/slices/interfaces/rdi.ts b/redisinsight/ui/src/slices/interfaces/rdi.ts index c3d7fe45b5..6cc41c0603 100644 --- a/redisinsight/ui/src/slices/interfaces/rdi.ts +++ b/redisinsight/ui/src/slices/interfaces/rdi.ts @@ -1,4 +1,5 @@ import { Nullable } from 'uiSrc/utils' +import { Rdi as RdiInstanceResponse } from 'apiSrc/modules/rdi/models/rdi' export interface IPipeline { config: string @@ -6,7 +7,29 @@ export interface IPipeline { } export interface IStateRdi { - loading: boolean; + loading: boolean error: string data: Nullable } +export interface RdiInstance extends RdiInstanceResponse { + visible?: boolean + loading?: boolean +} + +export interface InitialStateRdiInstances { + loading: boolean + error: string + data: RdiInstance[] + connectedInstance: RdiInstance + editedInstance: InitialStateEditedRdiInstances + loadingChanging: boolean + errorChanging: string + changedSuccessfully: boolean + deletedSuccessfully: boolean +} + +export interface InitialStateEditedRdiInstances { + loading: boolean + error: string + data: Nullable +} diff --git a/redisinsight/ui/src/slices/rdi/instances.ts b/redisinsight/ui/src/slices/rdi/instances.ts new file mode 100644 index 0000000000..9c88e143d6 --- /dev/null +++ b/redisinsight/ui/src/slices/rdi/instances.ts @@ -0,0 +1,299 @@ +import { first, map } from 'lodash' +import { createSlice } from '@reduxjs/toolkit' + +import { AxiosError } from 'axios' +import { ApiEndpoints } from 'uiSrc/constants' +import { apiService } from 'uiSrc/services' +import successMessages from 'uiSrc/components/notifications/success-messages' +import { Nullable, getApiErrorMessage, isStatusSuccessful } from 'uiSrc/utils' +import { Rdi as RdiInstanceResponse } from 'apiSrc/modules/rdi/models/rdi' +import { ExportInstance } from 'apiSrc/modules/rdi/models/export-instance' + +import { AppDispatch, RootState } from '../store' +import { addErrorNotification, addMessageNotification } from '../app/notifications' +import { InitialStateRdiInstances, RdiInstance } from '../interfaces/rdi' + +export const initialState: InitialStateRdiInstances = { + loading: false, + error: '', + data: [], + connectedInstance: { + id: '', + name: '', + url: '', + version: '', + lastConnection: new Date(), + loading: false + }, + editedInstance: { + loading: false, + error: '', + data: null + }, + loadingChanging: false, + errorChanging: '', + changedSuccessfully: false, + deletedSuccessfully: false +} + +// A slice for recipes +const instancesSlice = createSlice({ + name: 'rdiInstances', + initialState, + reducers: { + // load instances + loadInstances: (state) => { + state.loading = true + state.error = '' + }, + loadInstancesSuccess: (state, { payload }: { payload: RdiInstanceResponse[] }) => { + state.data = payload + state.loading = false + }, + loadInstancesFailure: (state, { payload }) => { + state.loading = false + state.error = payload + }, + + // add/edit instance + defaultInstanceChanging: (state) => { + state.loadingChanging = true + state.changedSuccessfully = false + state.errorChanging = '' + }, + defaultInstanceChangingSuccess: (state) => { + state.changedSuccessfully = true + state.loadingChanging = false + }, + defaultInstanceChangingFailure: (state, { payload = '' }) => { + state.loadingChanging = false + state.changedSuccessfully = false + state.errorChanging = payload.toString() + }, + + // test instance connection + testConnection: (state) => { + state.loadingChanging = true + state.errorChanging = '' + }, + testConnectionSuccess: (state) => { + state.loadingChanging = false + }, + testConnectionFailure: (state, { payload = '' }) => { + state.loadingChanging = false + state.errorChanging = payload.toString() + }, + + // delete instances + setDefaultInstance: (state) => { + state.loading = true + state.error = '' + }, + setDefaultInstanceSuccess: (state) => { + state.loading = false + }, + setDefaultInstanceFailure: (state, { payload }) => { + state.loading = false + state.error = payload + }, + + // set connected instance id + setConnectedInstanceId: (state, { payload }: { payload: string }) => { + state.connectedInstance = { + ...state.connectedInstance, + id: payload + } + }, + + // set edited instance + setEditedInstance: (state, { payload }: { payload: Nullable }) => { + state.editedInstance.data = payload + }, + + // reset connected instance + resetConnectedInstance: (state) => { + state.connectedInstance = initialState.connectedInstance + } + } +}) + +// Actions generated from the slice +export const { + loadInstances, + loadInstancesSuccess, + loadInstancesFailure, + defaultInstanceChanging, + defaultInstanceChangingSuccess, + defaultInstanceChangingFailure, + testConnection, + testConnectionSuccess, + testConnectionFailure, + setDefaultInstance, + setDefaultInstanceSuccess, + setDefaultInstanceFailure, + setConnectedInstanceId, + setEditedInstance, + resetConnectedInstance +} = instancesSlice.actions + +// selectors +export const instancesSelector = (state: RootState) => state.rdi.instances + +// The reducer +export default instancesSlice.reducer + +// Asynchronous thunk action +export function fetchInstancesAction(onSuccess?: (data?: RdiInstanceResponse[]) => void) { + return async (dispatch: AppDispatch) => { + dispatch(loadInstances()) + + try { + // mock response + const data: RdiInstanceResponse[] = [] + + const status = 200 + + if (isStatusSuccessful(status)) { + onSuccess?.(data) + dispatch(loadInstancesSuccess(data)) + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(loadInstancesFailure(errorMessage)) + dispatch(addErrorNotification(error)) + } + } +} + +// Asynchronous thunk action +export function createInstancesAction(onSuccess?: (data?: RdiInstanceResponse[]) => void) { + return async (dispatch: AppDispatch) => { + dispatch(loadInstances()) + + try { + // mock response + const data: RdiInstanceResponse[] = [ + { + id: '1', + name: 'My first integration', + url: 'redis-12345.c253.us-central1-1.gce.cloud.redislabs.com:12345', + lastConnection: new Date(), + version: '1.2' + }, + { + id: '2', + name: 'My second integration', + url: 'redis-67890.c253.us-central1-1.gce.cloud.redislabs.com:67890', + lastConnection: new Date(), + version: '1.2' + } + ] + + const status = 200 + + if (isStatusSuccessful(status)) { + onSuccess?.(data) + dispatch(loadInstancesSuccess(data)) + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(loadInstancesFailure(errorMessage)) + dispatch(addErrorNotification(error)) + } + } +} + +// Asynchronous thunk action +export function checkConnectToInstanceAction( + id: string = '', + onSuccessAction?: (id: string) => void, + onFailAction?: () => void, + resetInstance: boolean = true +) { + return async (dispatch: AppDispatch) => { + dispatch(setDefaultInstance()) + resetInstance && dispatch(resetConnectedInstance()) + try { + const { status } = await apiService.get(`${ApiEndpoints.DATABASES}/${id}/connect`) + + if (isStatusSuccessful(status)) { + dispatch(setDefaultInstanceSuccess()) + onSuccessAction?.(id) + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(setDefaultInstanceFailure(errorMessage)) + dispatch(addErrorNotification({ ...error, instanceId: id })) + onFailAction?.() + } + } +} + +// Asynchronous thunk action +export function deleteInstancesAction(instances: RdiInstance[], onSuccess?: () => void) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + dispatch(setDefaultInstance()) + + try { + const state = stateInit() + const instancesIds = map(instances, 'id') + + const status = 200 + + if (isStatusSuccessful(status)) { + dispatch(setDefaultInstanceSuccess()) + dispatch(fetchInstancesAction()) + + if (instancesIds.includes(state.app.context.contextInstanceId)) { + dispatch(resetConnectedInstance()) + } + onSuccess?.() + + if (instances.length === 1) { + dispatch(addMessageNotification(successMessages.DELETE_RDI_INSTANCE(first(instances)?.name ?? ''))) + } else { + dispatch(addMessageNotification(successMessages.DELETE_RDI_INSTANCES(map(instances, 'name')))) + } + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(setDefaultInstanceFailure(errorMessage)) + dispatch(addErrorNotification(error)) + } + } +} + +// Asynchronous thunk action +export function exportInstancesAction( + ids: string[], + withSecrets: boolean, + onSuccess?: (data: ExportInstance) => void, + onFail?: () => void +) { + return async (dispatch: AppDispatch) => { + dispatch(setDefaultInstance()) + + try { + const { data, status } = await apiService.post(ApiEndpoints.RDI_INSTANCES_EXPORT, { + ids, + withSecrets + }) + + if (isStatusSuccessful(status)) { + dispatch(setDefaultInstanceSuccess()) + + onSuccess?.(data) + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(setDefaultInstanceFailure(errorMessage)) + dispatch(addErrorNotification(error)) + onFail?.() + } + } +} diff --git a/redisinsight/ui/src/slices/rdi/rdi.ts b/redisinsight/ui/src/slices/rdi/rdi.ts deleted file mode 100644 index daa71a97f6..0000000000 --- a/redisinsight/ui/src/slices/rdi/rdi.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createSlice } from '@reduxjs/toolkit' -import { RootState } from 'uiSrc/slices/store' - -export const initialState: any = { -} - -const appRdiSlice = createSlice({ - name: 'appRdi', - initialState, - reducers: { - setRdiInitialState: () => initialState, - } -}) - -export const { - setRdiInitialState, -} = appRdiSlice.actions - -export const appActionBarSelector = (state: RootState) => state.rdi - -export default appRdiSlice.reducer diff --git a/redisinsight/ui/src/slices/store.ts b/redisinsight/ui/src/slices/store.ts index f64c7173b6..cadc6c5767 100644 --- a/redisinsight/ui/src/slices/store.ts +++ b/redisinsight/ui/src/slices/store.ts @@ -44,7 +44,7 @@ import redisearchReducer from './browser/redisearch' import recommendationsReducer from './recommendations/recommendations' import triggeredFunctionsReducer from './triggeredFunctions/triggeredFunctions' import insightsPanelReducer from './panels/insights' -import appRDIReducer from './rdi/rdi' +import rdiInstancesReducer from './rdi/instances' import rdiPipelineSlice from './rdi/pipeline' export const history = createBrowserHistory() @@ -114,7 +114,7 @@ export const rootReducer = combineReducers({ insights: insightsPanelReducer, }), rdi: combineReducers({ - rdi: appRDIReducer, + instances: rdiInstancesReducer, pipeline: rdiPipelineSlice, }) }) diff --git a/redisinsight/ui/src/telemetry/events.ts b/redisinsight/ui/src/telemetry/events.ts index f74c205479..6f0a471c9e 100644 --- a/redisinsight/ui/src/telemetry/events.ts +++ b/redisinsight/ui/src/telemetry/events.ts @@ -301,4 +301,11 @@ export enum TelemetryEvent { TRIGGERS_AND_FUNCTIONS_FUNCTION_INVOKE_CLICKED = 'TRIGGERS_AND_FUNCTIONS_FUNCTION_INVOKE_CLICKED', TRIGGERS_AND_FUNCTIONS_FUNCTION_INVOKE_CANCELLED = 'TRIGGERS_AND_FUNCTIONS_FUNCTION_INVOKE_CANCELLED', TRIGGERS_AND_FUNCTIONS_FIND_KEY_CLICKED = 'TRIGGERS_AND_FUNCTIONS_FIND_KEY_CLICKED', + + CONFIG_RDI_INSTANCES_EXPORT_CLICKED = 'CONFIG_RDI_INSTANCES_EXPORT_CLICKED', + CONFIG_RDI_INSTANCES_LIST_SORTED = 'CONFIG_RDI_INSTANCES_LIST_SORTED', + CONFIG_RDI_INSTANCES_SINGLE_DELETE_CLICKED = 'CONFIG_RDI_INSTANCES_SINGLE_DELETE_CLICKED', + CONFIG_RDI_INSTANCES_MULTIPLE_DELETE_CLICKED = 'CONFIG_RDI_INSTANCES_MULTIPLE_DELETE_CLICKED', + CONFIG_RDI_INSTANCES_LIST_SEARCHED = 'CONFIG_RDI_INSTANCES_LIST_SEARCHED', + CONFIG_RDI_INSTANCES_URL_COPIED = 'CONFIG_RDI_INSTANCES_URL_COPIED' } diff --git a/redisinsight/ui/src/utils/test-utils.tsx b/redisinsight/ui/src/utils/test-utils.tsx index 68879390b1..2bccfb914e 100644 --- a/redisinsight/ui/src/utils/test-utils.tsx +++ b/redisinsight/ui/src/utils/test-utils.tsx @@ -51,7 +51,7 @@ import { initialState as initialStateRecommendations } from 'uiSrc/slices/recomm import { initialState as initialStateTriggeredFunctions } from 'uiSrc/slices/triggeredFunctions/triggeredFunctions' import { initialState as initialStateOAuth } from 'uiSrc/slices/oauth/cloud' import { initialState as initialStateInsightsPanel } from 'uiSrc/slices/panels/insights' -import { initialState as initialStateRdi } from 'uiSrc/slices/rdi/rdi' +import { initialState as initialStateRdi } from 'uiSrc/slices/rdi/instances' import { RESOURCES_BASE_URL } from 'uiSrc/services/resourcesService' import { apiService } from 'uiSrc/services' @@ -128,7 +128,7 @@ const initialStateDefault: RootState = { insights: cloneDeep(initialStateInsightsPanel) }, rdi: { - rdi: cloneDeep(initialStateRdi), + instances: cloneDeep(initialStateRdi), } } From 488908270324041f2e4a8f9e95dc7c90f548f505 Mon Sep 17 00:00:00 2001 From: kyle-marcum Date: Tue, 12 Dec 2023 18:16:50 -0600 Subject: [PATCH 008/566] RI-5152: Hooked up mocked api calls for fetch list, delete, and create actions for RDI Instances --- .../src/modules/rdi/entities/rdi.entity.ts | 8 +- .../src/modules/rdi/models/export-instance.ts | 11 -- .../api/src/modules/rdi/models/rdi.ts | 1 + .../api/src/modules/rdi/rdi.controller.ts | 6 +- .../api/src/modules/rdi/rdi.service.ts | 11 +- .../rdi/repository/local.rdi.repository.ts | 60 ++++++----- .../modules/rdi/repository/rdi.repository.ts | 2 +- .../notifications/success-messages.tsx | 10 ++ redisinsight/ui/src/constants/api.ts | 4 +- .../ui/src/pages/rdi/home/RdiPage.spec.tsx | 34 ++++-- .../ui/src/pages/rdi/home/RdiPage.tsx | 20 +++- .../rdi/home/components/EmptyMessage.tsx | 58 +++++----- .../rdi/home/components/styles.module.scss | 12 --- .../ui/src/pages/rdi/home/styles.module.scss | 12 +++ .../RdiInstancesListWrapper.spec.tsx | 18 ---- .../instance-list/RdiInstancesListWrapper.tsx | 8 +- redisinsight/ui/src/slices/rdi/instances.ts | 100 +++--------------- redisinsight/ui/src/telemetry/events.ts | 1 - 18 files changed, 164 insertions(+), 212 deletions(-) delete mode 100644 redisinsight/api/src/modules/rdi/models/export-instance.ts diff --git a/redisinsight/api/src/modules/rdi/entities/rdi.entity.ts b/redisinsight/api/src/modules/rdi/entities/rdi.entity.ts index 3aa11f31ea..96f5f77f65 100644 --- a/redisinsight/api/src/modules/rdi/entities/rdi.entity.ts +++ b/redisinsight/api/src/modules/rdi/entities/rdi.entity.ts @@ -18,11 +18,11 @@ export class RdiEntity { @Expose() @Column({ nullable: true }) - host: string; + host?: string; @Expose() @Column({ nullable: true }) - port: number; + port?: number; @Expose() @Column({ nullable: false }) @@ -39,4 +39,8 @@ export class RdiEntity { @Expose() @Column({ type: 'datetime', nullable: true }) lastConnection: Date; + + @Expose() + @Column({ nullable: false }) + version: string; } diff --git a/redisinsight/api/src/modules/rdi/models/export-instance.ts b/redisinsight/api/src/modules/rdi/models/export-instance.ts deleted file mode 100644 index a1cb90c7ef..0000000000 --- a/redisinsight/api/src/modules/rdi/models/export-instance.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { PickType } from '@nestjs/swagger'; -import { Rdi } from './rdi'; - -export class ExportInstance extends PickType(Rdi, [ - 'id', - 'url', - 'name', - 'username', - 'password', - 'lastConnection', -] as const) {} diff --git a/redisinsight/api/src/modules/rdi/models/rdi.ts b/redisinsight/api/src/modules/rdi/models/rdi.ts index fe8f9db7bc..31e2522488 100644 --- a/redisinsight/api/src/modules/rdi/models/rdi.ts +++ b/redisinsight/api/src/modules/rdi/models/rdi.ts @@ -23,6 +23,7 @@ export class Rdi { @IsEnum(RdiType, { message: `Type must be a valid enum value from: ${Object.values(RdiType)}.`, }) + @IsOptional() type?: RdiType; @ApiPropertyOptional({ diff --git a/redisinsight/api/src/modules/rdi/rdi.controller.ts b/redisinsight/api/src/modules/rdi/rdi.controller.ts index 4deed85ca6..561a6d52fd 100644 --- a/redisinsight/api/src/modules/rdi/rdi.controller.ts +++ b/redisinsight/api/src/modules/rdi/rdi.controller.ts @@ -57,12 +57,12 @@ export class RdiController { return this.rdiService.update(id, dto); } - @Delete('/:id') + @Delete() @ApiEndpoint({ description: 'Delete RDI', responses: [{ status: 200 }], }) - async delete(@Param('id') id: string): Promise { - return this.rdiService.delete(id); + async delete(@Body() body: { ids: string[] }): Promise { + return this.rdiService.delete(body.ids); } } diff --git a/redisinsight/api/src/modules/rdi/rdi.service.ts b/redisinsight/api/src/modules/rdi/rdi.service.ts index 41833e6f4e..bf48a5fcc6 100644 --- a/redisinsight/api/src/modules/rdi/rdi.service.ts +++ b/redisinsight/api/src/modules/rdi/rdi.service.ts @@ -1,8 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { Rdi } from 'src/modules/rdi/models'; +import { v4 as uuidv4 } from 'uuid'; + import { CreateRdiDto, UpdateRdiDto } from 'src/modules/rdi/dto'; +import { Rdi } from 'src/modules/rdi/models'; import { RdiRepository } from 'src/modules/rdi/repository/rdi.repository'; -import { classToClass } from "src/utils"; +import { classToClass } from 'src/utils'; @Injectable() export class RdiService { @@ -30,12 +32,13 @@ export class RdiService { async create(dto: CreateRdiDto): Promise { const model = classToClass(Rdi, dto); + model.id = uuidv4(); model.lastConnection = new Date(); return await this.repository.create(model); } - async delete(id: string): Promise { - + async delete(ids: string[]): Promise { + return await this.repository.delete(ids); } } diff --git a/redisinsight/api/src/modules/rdi/repository/local.rdi.repository.ts b/redisinsight/api/src/modules/rdi/repository/local.rdi.repository.ts index 1b77f22049..da3d86b7b5 100644 --- a/redisinsight/api/src/modules/rdi/repository/local.rdi.repository.ts +++ b/redisinsight/api/src/modules/rdi/repository/local.rdi.repository.ts @@ -1,12 +1,38 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { classToClass } from 'src/utils'; +import { v4 as uuidv4 } from 'uuid'; + import { EncryptionService } from 'src/modules/encryption/encryption.service'; import { ModelEncryptor } from 'src/modules/encryption/model.encryptor'; -import { RdiRepository } from 'src/modules/rdi/repository/rdi.repository'; import { RdiEntity } from 'src/modules/rdi/entities/rdi.entity'; -import { Rdi } from 'src/modules/rdi/models'; +import { Rdi, RdiType } from 'src/modules/rdi/models'; +import { RdiRepository } from 'src/modules/rdi/repository/rdi.repository'; +import { classToClass } from 'src/utils'; + +// mock data +let mockRdiInstances: Rdi[] = [ + { + type: RdiType.API, + id: uuidv4(), + name: 'My first integration', + url: 'redis-12345.c253.us-central1-1.gce.cloud.redislabs.com:12345', + lastConnection: new Date(), + version: '1.2', + username: '', + password: '', + }, + { + type: RdiType.API, + id: uuidv4(), + name: 'My second integration', + url: 'redis-67890.c253.us-central1-1.gce.cloud.redislabs.com:67890', + lastConnection: new Date(), + version: '1.2', + username: '', + password: '', + }, +]; @Injectable() export class LocalRdiRepository extends RdiRepository { @@ -24,10 +50,7 @@ export class LocalRdiRepository extends RdiRepository { /** * @inheritDoc */ - public async get( - id: string, - ignoreEncryptionErrors: boolean = false, - ): Promise { + public async get(id: string, ignoreEncryptionErrors: boolean = false): Promise { const entity = await this.repository.findOneBy({ id }); if (!entity) { @@ -41,12 +64,7 @@ export class LocalRdiRepository extends RdiRepository { * @inheritDoc */ public async list(): Promise { - const entities = await this.repository - .createQueryBuilder('r') - .select([ - 'r.id', 'r.name', 'r.host', 'r.port', 'r.type', 'r.lastConnection', - ]) - .getMany(); + const entities = mockRdiInstances; return entities.map((entity) => classToClass(Rdi, entity)); } @@ -55,15 +73,8 @@ export class LocalRdiRepository extends RdiRepository { * @inheritDoc */ public async create(rdi: Rdi): Promise { - const entity = classToClass(RdiEntity, rdi); - return classToClass( - Rdi, - await this.modelEncryptor.decryptEntity( - await this.repository.save( - await this.modelEncryptor.encryptEntity(entity), - ), - ), - ); + mockRdiInstances.push(rdi); + return rdi; } /** @@ -77,7 +88,8 @@ export class LocalRdiRepository extends RdiRepository { /** * @inheritDoc */ - public async delete(id: string): Promise { - await this.repository.delete(id); + public async delete(ids: string[]): Promise { + mockRdiInstances = mockRdiInstances.filter((instance) => !ids.includes(instance.id)); + await this.repository.delete(ids); } } diff --git a/redisinsight/api/src/modules/rdi/repository/rdi.repository.ts b/redisinsight/api/src/modules/rdi/repository/rdi.repository.ts index 9511c97425..3e00546868 100644 --- a/redisinsight/api/src/modules/rdi/repository/rdi.repository.ts +++ b/redisinsight/api/src/modules/rdi/repository/rdi.repository.ts @@ -33,5 +33,5 @@ export abstract class RdiRepository { * Delete RDI by id * @param id */ - abstract delete(id: string): Promise; + abstract delete(ids: string[]): Promise; } diff --git a/redisinsight/ui/src/components/notifications/success-messages.tsx b/redisinsight/ui/src/components/notifications/success-messages.tsx index f578d4bf11..ecfda9c3a4 100644 --- a/redisinsight/ui/src/components/notifications/success-messages.tsx +++ b/redisinsight/ui/src/components/notifications/success-messages.tsx @@ -18,6 +18,16 @@ export default { ), }), + ADDED_NEW_RDI_INSTANCE: (instanceName: string) => ({ + title: 'Instance has been added', + message: ( + <> + {formatNameShort(instanceName)} + {' '} + has been added to RedisInsight. + + ), + }), DELETE_INSTANCE: (instanceName: string) => ({ title: 'Database has been deleted', message: ( diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index 0e6e8d1ffe..953f9a6187 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -4,9 +4,6 @@ enum ApiEndpoints { DATABASES_TEST_CONNECTION = 'databases/test', DATABASES_EXPORT = 'databases/export', - RDI_INSTANCES = 'rdi/instances', - RDI_INSTANCES_EXPORT = 'rdi/instances/export', - BULK_ACTIONS_IMPORT = 'bulk-actions/import', BULK_ACTIONS_IMPORT_TUTORIAL_DATA = 'bulk-actions/import/tutorial-data', @@ -140,6 +137,7 @@ enum ApiEndpoints { ANALYTICS_SEND_EVENT = 'analytics/send-event', ANALYTICS_SEND_PAGE = 'analytics/send-page', + RDI_INSTANCES = 'rdi', RDI_PIPELINE = 'rdi/pipeline' } diff --git a/redisinsight/ui/src/pages/rdi/home/RdiPage.spec.tsx b/redisinsight/ui/src/pages/rdi/home/RdiPage.spec.tsx index eddb7562e0..93ce8fef02 100644 --- a/redisinsight/ui/src/pages/rdi/home/RdiPage.spec.tsx +++ b/redisinsight/ui/src/pages/rdi/home/RdiPage.spec.tsx @@ -51,7 +51,14 @@ describe('RdiPage', () => { expect(render()).toBeTruthy() }) - it('should render empty message when no instances are found', () => { + it('should render instance list when instances are found', () => { + render() + + expect(screen.getByTestId('rdi-instance-list')).toBeInTheDocument() + expect(screen.queryByTestId('empty-rdi-instance-list')).not.toBeInTheDocument() + }) + + it('should render empty panel when initially loading', () => { const state: RootState = store.getState(); (useSelector as jest.Mock).mockImplementation((callback: (arg0: RootState) => RootState) => callback({ @@ -60,7 +67,8 @@ describe('RdiPage', () => { ...state.rdi, instances: { ...state.rdi.instances, - data: [] + data: [], + loading: true } } })) @@ -68,13 +76,27 @@ describe('RdiPage', () => { render() expect(screen.queryByTestId('rdi-instance-list')).not.toBeInTheDocument() - expect(screen.getByTestId('empty-rdi-instance-list')).toBeInTheDocument() + expect(screen.queryByTestId('empty-rdi-instance-list')).not.toBeInTheDocument() }) - it('should render instance list when instances are found', () => { + it('should render empty message when no instances are found', () => { + const state: RootState = store.getState(); + (useSelector as jest.Mock).mockImplementation((callback: (arg0: RootState) => RootState) => + callback({ + ...state, + rdi: { + ...state.rdi, + instances: { + ...state.rdi.instances, + data: [], + loading: false + } + } + })) + render() - expect(screen.getByTestId('rdi-instance-list')).toBeInTheDocument() - expect(screen.queryByTestId('empty-rdi-instance-list')).not.toBeInTheDocument() + expect(screen.queryByTestId('rdi-instance-list')).not.toBeInTheDocument() + expect(screen.getByTestId('empty-rdi-instance-list')).toBeInTheDocument() }) }) diff --git a/redisinsight/ui/src/pages/rdi/home/RdiPage.tsx b/redisinsight/ui/src/pages/rdi/home/RdiPage.tsx index 04e8c93474..eb68252391 100644 --- a/redisinsight/ui/src/pages/rdi/home/RdiPage.tsx +++ b/redisinsight/ui/src/pages/rdi/home/RdiPage.tsx @@ -1,9 +1,9 @@ -import { EuiPage, EuiPageBody, EuiResizeObserver } from '@elastic/eui' +import { EuiPage, EuiPageBody, EuiPanel, EuiResizeObserver } from '@elastic/eui' import React, { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { - createInstancesAction, + createInstanceAction, fetchInstancesAction, instancesSelector, setEditedInstance @@ -19,7 +19,7 @@ export interface Props {} const RdiPage = () => { const [width, setWidth] = useState(0) - const { data } = useSelector(instancesSelector) + const { data, loading } = useSelector(instancesSelector) const dispatch = useDispatch() @@ -36,7 +36,15 @@ const RdiPage = () => { } const handleAddInstance = () => { - dispatch(createInstancesAction()) + dispatch(createInstanceAction({ + name: 'My third integration', + url: 'redis-11121.c253.us-central1-1.gce.cloud.redislabs.com:11121', + lastConnection: new Date(), + version: '1.2', + username: 'username', + password: 'password', + })) + dispatch(fetchInstancesAction()) dispatch(setEditedInstance(null)) } @@ -50,7 +58,9 @@ const RdiPage = () => {
{!data.length ? ( - + + {!loading && } + ) : (
{ ] return ( - -
- - - No deployments found - Add your first deployment to get started! - More info - - - {moreInfoPanels.map((panel) => ( - - {}}> - - - - - - {panel.title} - {panel.description} - - - - - - - - ))} - -
-
+
+ + + No deployments found + Add your first deployment to get started! + More info + + + {moreInfoPanels.map((panel) => ( + + {}}> + + + + + + {panel.title} + {panel.description} + + + + + + + + ))} + +
) } diff --git a/redisinsight/ui/src/pages/rdi/home/components/styles.module.scss b/redisinsight/ui/src/pages/rdi/home/components/styles.module.scss index f64ada48b3..8853e394e6 100644 --- a/redisinsight/ui/src/pages/rdi/home/components/styles.module.scss +++ b/redisinsight/ui/src/pages/rdi/home/components/styles.module.scss @@ -1,15 +1,3 @@ -.basePanel { - height: calc(100vh - 160px); - - @media (min-width: 768px) { - height: calc(100vh - 123px); - - display: flex; - align-items: center; - justify-content: center; - } -} - .noResultsContainer { display: flex; align-items: center; diff --git a/redisinsight/ui/src/pages/rdi/home/styles.module.scss b/redisinsight/ui/src/pages/rdi/home/styles.module.scss index d50e805263..447e3c527f 100644 --- a/redisinsight/ui/src/pages/rdi/home/styles.module.scss +++ b/redisinsight/ui/src/pages/rdi/home/styles.module.scss @@ -11,3 +11,15 @@ .header { padding-bottom: 16px; } + +.emptyPanel { + height: calc(100vh - 160px); + + @media (min-width: 768px) { + height: calc(100vh - 123px); + + display: flex; + align-items: center; + justify-content: center; + } +} diff --git a/redisinsight/ui/src/pages/rdi/instance-list/RdiInstancesListWrapper.spec.tsx b/redisinsight/ui/src/pages/rdi/instance-list/RdiInstancesListWrapper.spec.tsx index 2eaf2785bf..4cf66f0cc8 100644 --- a/redisinsight/ui/src/pages/rdi/instance-list/RdiInstancesListWrapper.spec.tsx +++ b/redisinsight/ui/src/pages/rdi/instance-list/RdiInstancesListWrapper.spec.tsx @@ -72,9 +72,6 @@ const mockRdiInstancesList = (props: ItemListProps) => { -