diff --git a/redisinsight/api/src/__mocks__/errors.ts b/redisinsight/api/src/__mocks__/errors.ts index 6ec6069dff..9223156846 100644 --- a/redisinsight/api/src/__mocks__/errors.ts +++ b/redisinsight/api/src/__mocks__/errors.ts @@ -6,6 +6,12 @@ export const mockRedisNoPermError: ReplyError = { message: 'NOPERM this user has no permissions.', }; +export const mockRedisUnknownIndexName: ReplyError = { + name: 'ReplyError', + command: 'FT.INFO', + message: 'Unknown Index name', +}; + export const mockRedisWrongNumberOfArgumentsError: ReplyError = { name: 'ReplyError', command: 'GET', diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index bfdc6c1651..77d0b3ef96 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -23,6 +23,7 @@ export default { MASTER_GROUP_NOT_EXIST: "Master group with this name doesn't exist", KEY_NAME_EXIST: 'This key name is already in use.', + REDISEARCH_INDEX_EXIST: 'This index name is already in use.', KEY_NOT_EXIST: 'Key with this name does not exist.', PATH_NOT_EXISTS: () => 'There is no such path.', INDEX_OUT_OF_RANGE: () => 'Index is out of range.', diff --git a/redisinsight/api/src/modules/browser/browser.module.ts b/redisinsight/api/src/modules/browser/browser.module.ts index d9e66d305e..22739eb370 100644 --- a/redisinsight/api/src/modules/browser/browser.module.ts +++ b/redisinsight/api/src/modules/browser/browser.module.ts @@ -8,6 +8,8 @@ import { ConsumerGroupController } from 'src/modules/browser/controllers/stream/ import { ConsumerGroupService } from 'src/modules/browser/services/stream/consumer-group.service'; import { ConsumerController } from 'src/modules/browser/controllers/stream/consumer.controller'; import { ConsumerService } from 'src/modules/browser/services/stream/consumer.service'; +import { RedisearchController } from 'src/modules/browser/controllers/redisearch/redisearch.controller'; +import { RedisearchService } from 'src/modules/browser/services/redisearch/redisearch.service'; import { HashController } from './controllers/hash/hash.controller'; import { KeysController } from './controllers/keys/keys.controller'; import { KeysBusinessService } from './services/keys-business/keys-business.service'; @@ -34,6 +36,7 @@ import { BrowserToolClusterService } from './services/browser-tool-cluster/brows SetController, ZSetController, RejsonRlController, + RedisearchController, HashController, StreamController, ConsumerGroupController, @@ -46,6 +49,7 @@ import { BrowserToolClusterService } from './services/browser-tool-cluster/brows SetBusinessService, ZSetBusinessService, RejsonRlBusinessService, + RedisearchService, HashBusinessService, StreamService, ConsumerGroupService, diff --git a/redisinsight/api/src/modules/browser/controllers/redisearch/redisearch.controller.ts b/redisinsight/api/src/modules/browser/controllers/redisearch/redisearch.controller.ts new file mode 100644 index 0000000000..f9569a1134 --- /dev/null +++ b/redisinsight/api/src/modules/browser/controllers/redisearch/redisearch.controller.ts @@ -0,0 +1,81 @@ +import { + Body, + Controller, + Get, HttpCode, + Param, + Post, +} from '@nestjs/common'; +import { + ApiBody, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { ApiRedisParams } from 'src/decorators/api-redis-params.decorator'; +import { ApiQueryRedisStringEncoding } from 'src/common/decorators'; +import { BaseController } from 'src/modules/browser/controllers/base.controller'; +import { + CreateRedisearchIndexDto, + ListRedisearchIndexesResponse, + SearchRedisearchDto, +} from 'src/modules/browser/dto/redisearch'; +import { RedisearchService } from 'src/modules/browser/services/redisearch/redisearch.service'; +import { GetKeysWithDetailsResponse } from 'src/modules/browser/dto'; + +@ApiTags('RediSearch') +@Controller('redisearch') +export class RedisearchController extends BaseController { + constructor(private service: RedisearchService) { + super(); + } + + @Get('') + @ApiOperation({ description: 'Get list of available indexes' }) + @ApiOkResponse({ type: ListRedisearchIndexesResponse }) + @ApiRedisParams() + @ApiQueryRedisStringEncoding() + async list( + @Param('dbInstance') dbInstance: string, + ): Promise { + return this.service.list( + { + instanceId: dbInstance, + }, + ); + } + + @Post('') + @ApiOperation({ description: 'Create redisearch index' }) + @ApiRedisParams() + @HttpCode(201) + @ApiBody({ type: CreateRedisearchIndexDto }) + async createIndex( + @Param('dbInstance') dbInstance: string, + @Body() dto: CreateRedisearchIndexDto, + ): Promise { + return await this.service.createIndex( + { + instanceId: dbInstance, + }, + dto, + ); + } + + @Post('search') + @HttpCode(200) + @ApiOperation({ description: 'Search for keys in index' }) + @ApiOkResponse({ type: GetKeysWithDetailsResponse }) + @ApiRedisParams() + @ApiQueryRedisStringEncoding() + async search( + @Param('dbInstance') dbInstance: string, + @Body() dto: SearchRedisearchDto, + ): Promise { + return await this.service.search( + { + instanceId: dbInstance, + }, + dto, + ); + } +} diff --git a/redisinsight/api/src/modules/browser/dto/redisearch.ts b/redisinsight/api/src/modules/browser/dto/redisearch.ts new file mode 100644 index 0000000000..b444682e63 --- /dev/null +++ b/redisinsight/api/src/modules/browser/dto/redisearch.ts @@ -0,0 +1,122 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMinSize, IsDefined, IsEnum, IsInt, IsOptional, IsString, ValidateNested +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { RedisString } from 'src/common/constants'; +import { IsRedisString, RedisStringType } from 'src/common/decorators'; + +export enum RedisearchIndexKeyType { + HASH = 'hash', + JSON = 'json', +} + +export enum RedisearchIndexDataType { + TEXT = 'text', + TAG = 'tag', + NUMERIC = 'numeric', + GEO = 'geo', + VECTOR = 'vector', +} + +export class ListRedisearchIndexesResponse { + @ApiProperty({ + description: 'Indexes names', + type: String, + }) + @RedisStringType({ each: true }) + indexes: RedisString[]; +} + +export class CreateRedisearchIndexFieldDto { + @ApiProperty({ + description: 'Name of field to be indexed', + type: String, + }) + @IsDefined() + @RedisStringType() + @IsRedisString() + name: RedisString; + + @ApiProperty({ + description: 'Type of how data must be indexed', + enum: RedisearchIndexDataType, + }) + @IsDefined() + @IsEnum(RedisearchIndexDataType) + type: RedisearchIndexDataType; +} + +export class CreateRedisearchIndexDto { + @ApiProperty({ + description: 'Index Name', + type: String, + }) + @IsDefined() + @RedisStringType() + @IsRedisString() + index: RedisString; + + @ApiProperty({ + description: 'Type of keys to index', + enum: RedisearchIndexKeyType, + }) + @IsDefined() + @IsEnum(RedisearchIndexKeyType) + type: RedisearchIndexKeyType; + + @ApiPropertyOptional({ + description: 'Keys prefixes to find keys for index', + isArray: true, + type: String, + }) + @IsOptional() + @RedisStringType({ each: true }) + @IsRedisString({ each: true }) + prefixes?: RedisString[]; + + @ApiProperty({ + description: 'Fields to index', + isArray: true, + type: CreateRedisearchIndexFieldDto, + }) + @Type(() => CreateRedisearchIndexFieldDto) + @ValidateNested() + @ArrayMinSize(1) + fields: CreateRedisearchIndexFieldDto[]; +} + +export class SearchRedisearchDto { + @ApiProperty({ + description: 'Index Name', + type: String, + }) + @IsDefined() + @RedisStringType() + @IsRedisString() + index: RedisString; + + @ApiProperty({ + description: 'Query to search inside data fields', + type: String, + }) + @IsDefined() + @IsString() + query: string; + + @ApiProperty({ + description: 'Limit number of results to be returned', + type: Number, + }) + @IsDefined() + @IsInt() + limit: number = 500; // todo use @Default from another PR + + @ApiProperty({ + description: 'Offset position to start searching', + type: Number, + }) + @IsDefined() + @IsInt() + offset: number = 0; // todo use @Default from another PR +} diff --git a/redisinsight/api/src/modules/browser/services/redisearch/redisearch.service.spec.ts b/redisinsight/api/src/modules/browser/services/redisearch/redisearch.service.spec.ts new file mode 100644 index 0000000000..71b1e1dd80 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/redisearch/redisearch.service.spec.ts @@ -0,0 +1,285 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + ConflictException, + ForbiddenException, +} from '@nestjs/common'; +import { when } from 'jest-when'; +import { + mockRedisConsumer, + mockRedisNoPermError, + mockRedisUnknownIndexName, + mockStandaloneDatabaseEntity, +} from 'src/__mocks__'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { BrowserToolService } from 'src/modules/browser/services/browser-tool/browser-tool.service'; +import { RedisearchService } from 'src/modules/browser/services/redisearch/redisearch.service'; +import IORedis from 'ioredis'; +import { + RedisearchIndexDataType, + RedisearchIndexKeyType, +} from 'src/modules/browser/dto/redisearch'; + +const nodeClient = Object.create(IORedis.prototype); +nodeClient.sendCommand = jest.fn(); + +const clusterClient = Object.create(IORedis.Cluster.prototype); +clusterClient.sendCommand = jest.fn(); +clusterClient.nodes = jest.fn(); + +const keyName1 = Buffer.from('keyName1'); +const keyName2 = Buffer.from('keyName2'); + +const mockClientOptions: IFindRedisClientInstanceByOptions = { + instanceId: mockStandaloneDatabaseEntity.id, +}; + +const mockCreateRedisearchIndexDto = { + index: 'indexName', + type: RedisearchIndexKeyType.HASH, + prefixes: ['device:', 'user:'], + fields: [ + { + name: 'text:field', + type: RedisearchIndexDataType.TEXT, + }, + { + name: 'coordinates:field', + type: RedisearchIndexDataType.GEO, + }, + ], +}; +const mockSearchRedisearchDto = { + index: 'indexName', + query: 'somequery:', + limit: 10, + offset: 0, +}; + +describe('RedisearchService', () => { + let service: RedisearchService; + let browserTool; + + beforeEach(async () => { + jest.resetAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RedisearchService, + { + provide: BrowserToolService, + useFactory: mockRedisConsumer, + }, + ], + }).compile(); + + service = module.get(RedisearchService); + browserTool = module.get(BrowserToolService); + browserTool.getRedisClient.mockResolvedValue(nodeClient); + clusterClient.nodes.mockReturnValue([nodeClient, nodeClient]); + }); + + describe('list', () => { + it('should get list of indexes for standalone', async () => { + nodeClient.sendCommand.mockResolvedValue([ + keyName1.toString('hex'), + keyName2.toString('hex'), + ]); + + const list = await service.list(mockClientOptions); + + expect(list).toEqual({ + indexes: [ + keyName1, + keyName2, + ], + }); + }); + it('should get list of indexes for cluster (handle unique index name)', async () => { + browserTool.getRedisClient.mockResolvedValue(clusterClient); + nodeClient.sendCommand.mockResolvedValue([ + keyName1.toString('hex'), + keyName2.toString('hex'), + ]); + + const list = await service.list(mockClientOptions); + + expect(list).toEqual({ + indexes: [ + keyName1, + keyName2, + ], + }); + }); + it('should handle ACL error', async () => { + nodeClient.sendCommand.mockRejectedValueOnce(mockRedisNoPermError); + + try { + await service.list(mockClientOptions); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(ForbiddenException); + } + }); + }); + + describe('createIndex', () => { + it('should create index for standalone', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT.INFO' })) + .mockRejectedValue(mockRedisUnknownIndexName); + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT.CREATE' })) + .mockResolvedValue('OK'); + + await service.createIndex(mockClientOptions, mockCreateRedisearchIndexDto); + + expect(nodeClient.sendCommand).toHaveBeenCalledTimes(2); + expect(nodeClient.sendCommand).toHaveBeenCalledWith(jasmine.objectContaining({ + name: 'FT.CREATE', + args: [ + mockCreateRedisearchIndexDto.index, + 'ON', mockCreateRedisearchIndexDto.type, + 'PREFIX', '2', ...mockCreateRedisearchIndexDto.prefixes, + 'SCHEMA', mockCreateRedisearchIndexDto.fields[0].name, mockCreateRedisearchIndexDto.fields[0].type, + mockCreateRedisearchIndexDto.fields[1].name, mockCreateRedisearchIndexDto.fields[1].type, + ], + })); + }); + it('should create index for cluster', async () => { + browserTool.getRedisClient.mockResolvedValue(clusterClient); + when(clusterClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT.INFO' })) + .mockRejectedValue(mockRedisUnknownIndexName); + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT.CREATE' })) + .mockResolvedValueOnce('OK').mockRejectedValue(new Error('ReplyError: MOVED to somenode')); + + await service.createIndex(mockClientOptions, mockCreateRedisearchIndexDto); + + expect(clusterClient.sendCommand).toHaveBeenCalledTimes(1); + expect(nodeClient.sendCommand).toHaveBeenCalledTimes(2); + expect(nodeClient.sendCommand).toHaveBeenCalledWith(jasmine.objectContaining({ + name: 'FT.CREATE', + args: [ + mockCreateRedisearchIndexDto.index, + 'ON', mockCreateRedisearchIndexDto.type, + 'PREFIX', '2', ...mockCreateRedisearchIndexDto.prefixes, + 'SCHEMA', mockCreateRedisearchIndexDto.fields[0].name, mockCreateRedisearchIndexDto.fields[0].type, + mockCreateRedisearchIndexDto.fields[1].name, mockCreateRedisearchIndexDto.fields[1].type, + ], + })); + }); + it('should handle already existing index error', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT.INFO' })) + .mockReturnValue({ any: 'data' }); + + try { + await service.createIndex(mockClientOptions, mockCreateRedisearchIndexDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(ConflictException); + } + }); + it('should handle ACL error (ft.info command)', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT.INFO' })) + .mockRejectedValue(mockRedisNoPermError); + + try { + await service.createIndex(mockClientOptions, mockCreateRedisearchIndexDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(ForbiddenException); + } + }); + it('should handle ACL error (ft.create command)', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT.INFO' })) + .mockRejectedValue(mockRedisUnknownIndexName); + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT.CREATE' })) + .mockRejectedValue(mockRedisNoPermError); + + try { + await service.createIndex(mockClientOptions, mockCreateRedisearchIndexDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(ForbiddenException); + } + }); + }); + + describe('search', () => { + it('should search in standalone', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT.SEARCH' })) + .mockResolvedValue([100, keyName1, keyName2]); + + const res = await service.search(mockClientOptions, mockSearchRedisearchDto); + + expect(res).toEqual({ + cursor: mockSearchRedisearchDto.limit + mockSearchRedisearchDto.offset, + scanned: 2, + total: 100, + keys: [{ + name: keyName1, + }, { + name: keyName2, + }], + }); + + expect(nodeClient.sendCommand).toHaveBeenCalledTimes(1); + expect(nodeClient.sendCommand).toHaveBeenCalledWith(jasmine.objectContaining({ + name: 'FT.SEARCH', + args: [ + mockSearchRedisearchDto.index, + mockSearchRedisearchDto.query, + 'NOCONTENT', + 'LIMIT', `${mockSearchRedisearchDto.offset}`, `${mockSearchRedisearchDto.limit}`, + ], + })); + }); + it('should search in cluster', async () => { + browserTool.getRedisClient.mockResolvedValue(clusterClient); + when(clusterClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT.SEARCH' })) + .mockResolvedValue([100, keyName1, keyName2]); + + const res = await service.search(mockClientOptions, mockSearchRedisearchDto); + + expect(res).toEqual({ + cursor: mockSearchRedisearchDto.limit + mockSearchRedisearchDto.offset, + scanned: 2, + total: 100, + keys: [ + { name: keyName1 }, + { name: keyName2 }, + ], + }); + + expect(clusterClient.sendCommand).toHaveBeenCalledTimes(1); + expect(clusterClient.sendCommand).toHaveBeenCalledWith(jasmine.objectContaining({ + name: 'FT.SEARCH', + args: [ + mockSearchRedisearchDto.index, + mockSearchRedisearchDto.query, + 'NOCONTENT', + 'LIMIT', `${mockSearchRedisearchDto.offset}`, `${mockSearchRedisearchDto.limit}`, + ], + })); + }); + it('should handle ACL error (ft.info command)', async () => { + when(nodeClient.sendCommand) + .calledWith(jasmine.objectContaining({ name: 'FT.SEARCH' })) + .mockRejectedValue(mockRedisNoPermError); + + try { + await service.search(mockClientOptions, mockSearchRedisearchDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(ForbiddenException); + } + }); + }); +}); diff --git a/redisinsight/api/src/modules/browser/services/redisearch/redisearch.service.ts b/redisinsight/api/src/modules/browser/services/redisearch/redisearch.service.ts new file mode 100644 index 0000000000..5ef596cad6 --- /dev/null +++ b/redisinsight/api/src/modules/browser/services/redisearch/redisearch.service.ts @@ -0,0 +1,176 @@ +import { Cluster, Command, Redis } from 'ioredis'; +import { uniq } from 'lodash'; +import { + ConflictException, + Injectable, + Logger, +} from '@nestjs/common'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { catchAclError } from 'src/utils'; +import { IFindRedisClientInstanceByOptions } from 'src/modules/core/services/redis/redis.service'; +import { + CreateRedisearchIndexDto, + ListRedisearchIndexesResponse, + SearchRedisearchDto, +} from 'src/modules/browser/dto/redisearch'; +import { GetKeysWithDetailsResponse } from 'src/modules/browser/dto'; +import { plainToClass } from 'class-transformer'; +import { BrowserToolService } from '../browser-tool/browser-tool.service'; + +@Injectable() +export class RedisearchService { + private logger = new Logger('RedisearchService'); + + constructor( + private browserTool: BrowserToolService, + ) {} + + /** + * Get list of all available redisearch indexes + * @param clientOptions + */ + public async list(clientOptions: IFindRedisClientInstanceByOptions): Promise { + this.logger.log('Getting all redisearch indexes.'); + + try { + const client = await this.browserTool.getRedisClient(clientOptions); + + const nodes = this.getShards(client); + + const res = await Promise.all(nodes.map(async (node) => node.sendCommand( + new Command('FT._LIST', [], { replyEncoding: 'hex' }), + ))); + + return plainToClass(ListRedisearchIndexesResponse, { + indexes: (uniq([].concat(...res))).map((idx) => Buffer.from(idx, 'hex')), + }); + } catch (e) { + this.logger.error('Failed to get redisearch indexes', e); + + throw catchAclError(e); + } + } + + /** + * Creates redisearch index + * @param clientOptions + * @param dto + */ + public async createIndex( + clientOptions: IFindRedisClientInstanceByOptions, + dto: CreateRedisearchIndexDto, + ): Promise { + this.logger.log('Creating redisearch index.'); + + try { + const { + index, type, prefixes, fields, + } = dto; + + const client = await this.browserTool.getRedisClient(clientOptions); + + try { + const indexInfo = await client.sendCommand(new Command('FT.INFO', [dto.index], { + replyEncoding: 'utf8', + })); + + if (indexInfo) { + this.logger.error( + `Failed to create redisearch index. ${ERROR_MESSAGES.REDISEARCH_INDEX_EXIST}`, + ); + return Promise.reject( + new ConflictException(ERROR_MESSAGES.REDISEARCH_INDEX_EXIST), + ); + } + } catch (error) { + if (!error.message?.includes('Unknown Index name')) { + throw error; + } + } + + const nodes = this.getShards(client); + + const commandArgs: any[] = [ + index, 'ON', type, + ]; + + if (prefixes && prefixes.length) { + commandArgs.push('PREFIX', prefixes.length, ...prefixes); + } + + commandArgs.push( + 'SCHEMA', ...[].concat(...fields.map((field) => ([field.name, field.type]))), + ); + + const command = new Command('FT.CREATE', commandArgs, { + replyEncoding: 'utf8', + }); + + await Promise.all(nodes.map(async (node) => { + try { + await node.sendCommand(command); + } catch (e) { + if (!e.message.includes('MOVED')) { + throw e; + } + } + })); + + return undefined; + } catch (e) { + this.logger.error('Failed to create redisearch index', e); + + throw catchAclError(e); + } + } + + /** + * Search for key names using RediSearch module + * Response is the same as for keys "scan" to have the same behaviour in the browser + * @param clientOptions + * @param dto + */ + public async search( + clientOptions: IFindRedisClientInstanceByOptions, + dto: SearchRedisearchDto, + ): Promise { + this.logger.log('Searching keys using redisearch.'); + + try { + const { + index, query, offset, limit, + } = dto; + + const client = await this.browserTool.getRedisClient(clientOptions); + + const [total, ...keyNames] = await client.sendCommand( + new Command('FT.SEARCH', [index, query, 'NOCONTENT', 'LIMIT', offset, limit]), + ); + + return plainToClass(GetKeysWithDetailsResponse, { + cursor: limit + offset, + total, + scanned: keyNames.length + offset, + keys: keyNames.map((name) => ({ name })), + }); + } catch (e) { + this.logger.error('Failed to search keys using redisearch index', e); + + throw catchAclError(e); + } + } + + /** + * Get array of shards (client per each master node) + * for STANDALONE will return array with a single shard + * @param client + * @private + */ + private getShards(client: Redis | Cluster): Redis[] { + if (client instanceof Cluster) { + return client.nodes('master'); + } + + return [client]; + } +} diff --git a/redisinsight/api/test/api/redisearch/GET-databases-id-redisearch.test.ts b/redisinsight/api/test/api/redisearch/GET-databases-id-redisearch.test.ts new file mode 100644 index 0000000000..3af7962709 --- /dev/null +++ b/redisinsight/api/test/api/redisearch/GET-databases-id-redisearch.test.ts @@ -0,0 +1,58 @@ +import { + expect, + describe, + before, + deps, + requirements, + getMainCheckFn, +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).get(`/instance/${instanceId}/redisearch`); + +const mainCheckFn = getMainCheckFn(endpoint); + +describe('GET /databases/:id/redisearch', () => { + requirements('!rte.bigData', 'rte.modules.search'); + + describe('Common', () => { + before(async () => rte.data.generateRedisearchIndexes(true)); + + [ + { + name: 'Should get index list', + checkFn: async ({ body }) => { + expect(body.indexes.length).to.eq(2) + expect(body.indexes).to.include( + constants.TEST_SEARCH_HASH_INDEX_1, + constants.TEST_SEARCH_HASH_INDEX_2, + ); + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should get index list', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + }, + { + name: 'Should throw error if no permissions for "ft._list" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -ft._list') + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-search.test.ts b/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-search.test.ts new file mode 100644 index 0000000000..07f52842af --- /dev/null +++ b/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-search.test.ts @@ -0,0 +1,113 @@ +import { + expect, + describe, + before, + Joi, + deps, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + getMainCheckFn, JoiRedisString +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/redisearch/search`); + +// input data schema +const dataSchema = Joi.object({ + index: Joi.string().allow('').required(), + query: Joi.string().allow('').required(), + limit: Joi.number().integer(), + offset: Joi.number().integer(), +}).strict(); + +const validInputData = { + index: constants.TEST_SEARCH_HASH_INDEX_1, + query: '*', + limit: 10, + offset: 0, +}; + +const responseSchema = Joi.object({ + cursor: Joi.number().integer().required(), + scanned: Joi.number().integer().required(), + total: Joi.number().integer().required(), + keys: Joi.array().items(Joi.object({ + name: JoiRedisString.required(), + })).required(), +}).required().strict(true); +const mainCheckFn = getMainCheckFn(endpoint); + +describe('POST /databases/:id/redisearch/search', () => { + requirements('!rte.bigData', 'rte.modules.search'); + before(async () => rte.data.generateRedisearchIndexes(true)); + + describe('Main', () => { + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should search data (limit 10)', + data: validInputData, + responseSchema, + checkFn: async ({ body }) => { + expect(body.keys.length).to.eq(10); + expect(body.cursor).to.eq(10); + expect(body.scanned).to.eq(10); + expect(body.total).to.eq(2000); + }, + }, + { + name: 'Should search 100 entries (continue from previous 10)', + data: { + ...validInputData, + offset: 10, + limit: 100, + }, + responseSchema, + checkFn: async ({ body }) => { + expect(body.keys.length).to.eq(100); + expect(body.cursor).to.eq(110); + expect(body.scanned).to.eq(110); + expect(body.total).to.eq(2000); + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should search', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: validInputData, + responseSchema, + }, + { + name: 'Should throw error if no permissions for "ft.search" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + index: constants.getRandomString(), + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -ft.search') + }, + ].map(mainCheckFn); + }); + }); +}); diff --git a/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch.test.ts b/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch.test.ts new file mode 100644 index 0000000000..014fd324c3 --- /dev/null +++ b/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch.test.ts @@ -0,0 +1,153 @@ +import { + expect, + describe, + it, + before, + Joi, + deps, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall, getMainCheckFn, + _, +} from '../deps'; +const { server, request, constants, rte } = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/instance/${instanceId}/redisearch`); + +// input data schema +const dataSchema = Joi.object({ + index: Joi.string().allow('').required(), + type: Joi.string().valid('hash', 'json').required(), + // prefixes: Joi.array().items(Joi.string()).allow(null), + // fields: Joi.array().items(Joi.object({ + // name: Joi.string().required(), + // type: Joi.string().valid('text', 'tag', 'numeric', 'geo', 'vector').required(), + // }).required()), +}).strict(); + +const validInputData = { + index: constants.TEST_SEARCH_HASH_INDEX_1, + type: constants.TEST_SEARCH_HASH_TYPE, + prefixes: ['*'], + fields: [{ + name: '*', + type: 'text', + }], +}; + +const mainCheckFn = getMainCheckFn(endpoint); + +describe('POST /databases/:id/redisearch', () => { + requirements('!rte.bigData', 'rte.modules.search'); + + describe('Main', () => { + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + beforeEach(rte.data.truncate); + + [ + { + name: 'Should create index', + data: { + ...validInputData, + index: constants.TEST_SEARCH_HASH_INDEX_1, + }, + statusCode: 201, + before: async () => { + await validateApiCall({ + endpoint: () => request(server).get(`/instance/${constants.TEST_INSTANCE_ID}/redisearch`), + checkFn: ({ body }) => { + expect(body.indexes.length).to.eq(0); + }, + }); + }, + after: async () => { + await validateApiCall({ + endpoint: () => request(server).get(`/instance/${constants.TEST_INSTANCE_ID}/redisearch`), + checkFn: ({ body }) => { + expect(body.indexes.length).to.eq(1); + expect(body.indexes).to.include(constants.TEST_SEARCH_HASH_INDEX_1); + }, + }); + }, + }, + { + name: 'Should throw Conflict Error if such index name already exists', + data: { + ...validInputData, + index: constants.TEST_SEARCH_HASH_INDEX_1, + }, + statusCode: 201, + after: async () => { + await validateApiCall({ + endpoint, + statusCode: 409, + data: { + ...validInputData, + index: constants.TEST_SEARCH_HASH_INDEX_1, + }, + responseBody: { + statusCode: 409, + message: 'This index name is already in use.', + error: 'Conflict' + }, + }); + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + [ + { + name: 'Should create regular index', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + index: constants.getRandomString(), + }, + statusCode: 201, + }, + { + name: 'Should throw error if no permissions for "ft.info" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + index: constants.getRandomString(), + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -ft.info') + }, + { + name: 'Should throw error if no permissions for "ft.create" command', + endpoint: () => endpoint(constants.TEST_INSTANCE_ACL_ID), + data: { + ...validInputData, + index: constants.getRandomString(), + }, + statusCode: 403, + responseBody: { + statusCode: 403, + error: 'Forbidden', + }, + before: () => rte.data.setAclUserRules('~* +@all -ft.create') + }, + ].map(mainCheckFn); + }); + }); +}); diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index b9ac12897b..0bbc269633 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -285,8 +285,11 @@ export const constants = { TEST_GRAPH_NODE_2: TEST_RUN_ID + 'n2', // RediSearch + TEST_SEARCH_HASH_TYPE: 'hash', TEST_SEARCH_HASH_INDEX_1: TEST_RUN_ID + '_hash_search_idx_1' + CLUSTER_HASH_SLOT, TEST_SEARCH_HASH_KEY_PREFIX_1: TEST_RUN_ID + '_hash_search:', + TEST_SEARCH_HASH_INDEX_2: TEST_RUN_ID + '_hash_search_idx_2' + CLUSTER_HASH_SLOT, + TEST_SEARCH_HASH_KEY_PREFIX_2: TEST_RUN_ID + '_hash_search:', TEST_SEARCH_JSON_INDEX_1: TEST_RUN_ID + '_json_search_idx_1' + CLUSTER_HASH_SLOT, TEST_SEARCH_JSON_KEY_PREFIX_1: TEST_RUN_ID + '_json_search:', diff --git a/redisinsight/api/test/helpers/data/redis.ts b/redisinsight/api/test/helpers/data/redis.ts index 457b45073c..745ffbb841 100644 --- a/redisinsight/api/test/helpers/data/redis.ts +++ b/redisinsight/api/test/helpers/data/redis.ts @@ -416,6 +416,13 @@ export const initDataHelper = (rte) => { await waitForInfoSync(); }; + const generateRedisearchIndexes = async (clean: boolean) => { + await generateNKeys(10_000, clean); + + await sendCommand('ft.create', [constants.TEST_SEARCH_HASH_INDEX_1, 'on', 'hash', 'schema', 'field', 'text']); + await sendCommand('ft.create', [constants.TEST_SEARCH_HASH_INDEX_2, 'on', 'hash', 'schema', '*', 'text']); + }; + const generateNReJSONs = async (number: number = 300, clean: boolean) => { const jsonValue = JSON.stringify(constants.TEST_REJSON_VALUE_1); await generateAnyKeys([ @@ -461,6 +468,7 @@ export const initDataHelper = (rte) => { generateHugeNumberOfTinyStringKeys, generateHugeStream, generateNKeys, + generateRedisearchIndexes, generateNReJSONs, generateNTimeSeries, generateStrings, diff --git a/redisinsight/ui/src/assets/img/icons/vector.svg b/redisinsight/ui/src/assets/img/icons/vector.svg new file mode 100644 index 0000000000..c367a6e31e --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/vector.svg @@ -0,0 +1,3 @@ + + + diff --git a/redisinsight/ui/src/components/multi-search/styles.module.scss b/redisinsight/ui/src/components/multi-search/styles.module.scss index 8cb38cdb12..59552e9211 100644 --- a/redisinsight/ui/src/components/multi-search/styles.module.scss +++ b/redisinsight/ui/src/components/multi-search/styles.module.scss @@ -31,7 +31,7 @@ max-width: 100% !important; border: none !important; height: 100%; - padding: 0 12px; + padding: 0 6px 0 10px; background-image: none !important; } diff --git a/redisinsight/ui/src/components/notifications/success-messages.tsx b/redisinsight/ui/src/components/notifications/success-messages.tsx index 5d79a27013..afbc304fc0 100644 --- a/redisinsight/ui/src/components/notifications/success-messages.tsx +++ b/redisinsight/ui/src/components/notifications/success-messages.tsx @@ -146,5 +146,9 @@ export default { NO_CLAIMED_MESSAGES: () => ({ title: 'No messages claimed', message: 'No messages exceed the minimum idle time.', + }), + CREATE_INDEX: () => ({ + title: 'Index has been created', + message: 'Open the list of indexes to see it.' }) } diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index fec8cefa32..4d8f5981ae 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -78,6 +78,9 @@ enum ApiEndpoints { NOTIFICATIONS = 'notifications', NOTIFICATIONS_READ = 'notifications/read', + + REDISEARCH = 'redisearch', + REDISEARCH_SEARCH = 'redisearch/search', } export const DEFAULT_SEARCH_MATCH = '*' diff --git a/redisinsight/ui/src/constants/apiErrors.ts b/redisinsight/ui/src/constants/apiErrors.ts index 9ef97a381d..cff19a5c90 100644 --- a/redisinsight/ui/src/constants/apiErrors.ts +++ b/redisinsight/ui/src/constants/apiErrors.ts @@ -3,7 +3,8 @@ enum ApiErrors { KeytarUnavailable = 'KeytarUnavailable', KeytarEncryption = 'KeytarEncryptionError', KeytarDecryption = 'KeytarDecryptionError', - ClientNotFound = 'ClientNotFoundError' + ClientNotFound = 'ClientNotFoundError', + RedisearchIndexNotFound = 'no such index', } export const ApiEncryptionErrors: string[] = [ diff --git a/redisinsight/ui/src/constants/texts.tsx b/redisinsight/ui/src/constants/texts.tsx index bb36075032..f8f02510d3 100644 --- a/redisinsight/ui/src/constants/texts.tsx +++ b/redisinsight/ui/src/constants/texts.tsx @@ -4,6 +4,7 @@ import { EuiText, EuiSpacer, EuiLink } from '@elastic/eui' import { getRouterLinkProps } from 'uiSrc/services' export const NoResultsFoundText = (No results found.) +export const NoSelectedIndexText = (Select an index and enter a query to search per values of keys.) export const NoKeysToDisplayText = (path: string, onClick: ()=> void) => ( No keys to display. diff --git a/redisinsight/ui/src/mocks/handlers/browser/index.ts b/redisinsight/ui/src/mocks/handlers/browser/index.ts new file mode 100644 index 0000000000..14008797fc --- /dev/null +++ b/redisinsight/ui/src/mocks/handlers/browser/index.ts @@ -0,0 +1,8 @@ +import { DefaultBodyType, MockedRequest, RestHandler } from 'msw' + +import redisearch from './redisearchHandlers' + +const handlers: RestHandler>[] = [].concat( + redisearch, +) +export default handlers diff --git a/redisinsight/ui/src/mocks/handlers/browser/redisearchHandlers.ts b/redisinsight/ui/src/mocks/handlers/browser/redisearchHandlers.ts new file mode 100644 index 0000000000..17b2fa3afa --- /dev/null +++ b/redisinsight/ui/src/mocks/handlers/browser/redisearchHandlers.ts @@ -0,0 +1,23 @@ +import { rest, RestHandler } from 'msw' +import { ApiEndpoints } from 'uiSrc/constants' +import { getMswURL } from 'uiSrc/utils/test-utils' +import { getUrl, stringToBuffer } from 'uiSrc/utils' +import { ListRedisearchIndexesResponse } from 'apiSrc/modules/browser/dto/redisearch' +import { INSTANCE_ID_MOCK } from '../instances/instancesHandlers' + +export const REDISEARCH_LIST_DATA_MOCK_UTF8 = ['idx: 1', 'idx:2'] +export const REDISEARCH_LIST_DATA_MOCK = { + indexes: [...REDISEARCH_LIST_DATA_MOCK_UTF8].map((str) => stringToBuffer(str)), +} + +const handlers: RestHandler[] = [ + // fetchRedisearchListAction + rest.get(getMswURL( + getUrl(INSTANCE_ID_MOCK, ApiEndpoints.REDISEARCH) + ), async (req, res, ctx) => res( + ctx.status(200), + ctx.json(REDISEARCH_LIST_DATA_MOCK), + )) +] + +export default handlers diff --git a/redisinsight/ui/src/mocks/handlers/index.ts b/redisinsight/ui/src/mocks/handlers/index.ts index 7847ecccfd..c0ff799a7c 100644 --- a/redisinsight/ui/src/mocks/handlers/index.ts +++ b/redisinsight/ui/src/mocks/handlers/index.ts @@ -3,6 +3,13 @@ import instances from './instances' import content from './content' import app from './app' import analytics from './analytics' +import browser from './browser' // @ts-ignore -export const handlers: RestHandler[] = [].concat(instances, content, app, analytics) +export const handlers: RestHandler[] = [].concat( + instances, + content, + app, + analytics, + browser, +) diff --git a/redisinsight/ui/src/pages/browser/BrowserPage.spec.tsx b/redisinsight/ui/src/pages/browser/BrowserPage.spec.tsx index 7815f9ad07..908d2c14a2 100644 --- a/redisinsight/ui/src/pages/browser/BrowserPage.spec.tsx +++ b/redisinsight/ui/src/pages/browser/BrowserPage.spec.tsx @@ -103,7 +103,7 @@ describe('BrowserPage', () => { it('should render', () => { expect(render()).toBeTruthy() - const afterRenderActions = [resetErrors(), setConnectedInstanceId('instanceId'), loadKeys()] + const afterRenderActions = [setConnectedInstanceId('instanceId'), loadKeys(), resetErrors()] expect(store.getActions().slice(0, afterRenderActions.length)).toEqual([...afterRenderActions]) }) @@ -142,6 +142,6 @@ describe('BrowserPage', () => { fireEvent.click(screen.getByTestId('onCloseKey-btn')) - expect(store.getActions()).toEqual([...afterRenderActions, resetKeyInfo(), toggleBrowserFullScreen(true)]) + expect(store.getActions()).toEqual([...afterRenderActions, toggleBrowserFullScreen(true)]) }) }) diff --git a/redisinsight/ui/src/pages/browser/BrowserPage.tsx b/redisinsight/ui/src/pages/browser/BrowserPage.tsx index 5d435b1360..e9a726a27a 100644 --- a/redisinsight/ui/src/pages/browser/BrowserPage.tsx +++ b/redisinsight/ui/src/pages/browser/BrowserPage.tsx @@ -4,48 +4,38 @@ import { useDispatch, useSelector } from 'react-redux' import cx from 'classnames' import { EuiResizableContainer } from '@elastic/eui' -import { bufferToString, formatLongName, getDbIndex, isEqualBuffers, Nullable, setTitle } from 'uiSrc/utils' -import { SCAN_COUNT_DEFAULT, SCAN_TREE_COUNT_DEFAULT } from 'uiSrc/constants/api' import { - getBasedOnViewTypeEvent, - sendEventTelemetry, + formatLongName, + getDbIndex, + isEqualBuffers, + Nullable, + setTitle, +} from 'uiSrc/utils' +import { sendPageViewTelemetry, - TelemetryEvent, TelemetryPageView, } from 'uiSrc/telemetry' import { - fetchKeys, - fetchMoreKeys, - keysDataSelector, keysSelector, resetKeyInfo, selectedKeyDataSelector, setInitialStateByType, toggleBrowserFullScreen, } from 'uiSrc/slices/browser/keys' -import { connectedInstanceSelector, setConnectedInstanceId } from 'uiSrc/slices/instances/instances' import { - setBrowserKeyListDataLoaded, setBrowserSelectedKey, - appContextSelector, appContextBrowser, setBrowserPanelSizes, setLastPageContext, - updateBrowserTreeSelectedLeaf, } from 'uiSrc/slices/app/context' -import { appAnalyticsInfoSelector } from 'uiSrc/slices/app/info' import { resetErrors } from 'uiSrc/slices/app/notifications' +import { appAnalyticsInfoSelector } from 'uiSrc/slices/app/info' import InstanceHeader from 'uiSrc/components/instance-header' -import { KeyViewType } from 'uiSrc/slices/interfaces/keys' import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' -import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' -import AddKey from './components/add-key/AddKey' -import KeyList from './components/key-list/KeyList' -import KeyTree from './components/key-tree' -import KeysHeader from './components/keys-header' -import KeyDetailsWrapper from './components/key-details/KeyDetailsWrapper' -import BulkActions from './components/bulk-actions' +import BrowserLeftPanel from './components/browser-left-panel' +import BrowserRightPanel from './components/browser-right-panel' import styles from './styles.module.scss' @@ -54,30 +44,31 @@ export const firstPanelId = 'keys' export const secondPanelId = 'keyDetails' const BrowserPage = () => { + const { instanceId } = useParams<{ instanceId: string }>() + const { identified: analyticsIdentified } = useSelector(appAnalyticsInfoSelector) const { name: connectedInstanceName, db } = useSelector(connectedInstanceSelector) - const { contextInstanceId } = useSelector(appContextSelector) const { - keyList: { selectedKey: selectedKeyContext, isDataLoaded }, panelSizes, + keyList: { selectedKey: selectedKeyContext }, bulkActions: { opened: bulkActionOpenContext }, } = useSelector(appContextBrowser) - const keysState = useSelector(keysDataSelector) - const { loading, viewType, isBrowserFullScreen } = useSelector(keysSelector) - const { type, length } = useSelector(selectedKeyDataSelector) ?? { type: '', length: 0 } + const { isBrowserFullScreen } = useSelector(keysSelector) + const { type } = useSelector(selectedKeyDataSelector) ?? { type: '', length: 0 } const [isPageViewSent, setIsPageViewSent] = useState(false) const [arePanelsCollapsed, setArePanelsCollapsed] = useState(false) const [selectedKey, setSelectedKey] = useState>(selectedKeyContext) + const [isAddKeyPanelOpen, setIsAddKeyPanelOpen] = useState(false) + const [isCreateIndexPanelOpen, setIsCreateIndexPanelOpen] = useState(false) const [isBulkActionsPanelOpen, setIsBulkActionsPanelOpen] = useState(bulkActionOpenContext) + const [sizes, setSizes] = useState(panelSizes) - const selectedKeyRef = useRef>(selectedKey) const prevSelectedType = useRef(type) - const keyListRef = useRef() + const selectedKeyRef = useRef>(selectedKey) - const { instanceId } = useParams<{ instanceId: string }>() const dispatch = useDispatch() const dbName = `${formatLongName(connectedInstanceName, 33, 0, '...')} ${getDbIndex(db)}` @@ -88,10 +79,6 @@ const BrowserPage = () => { updateWindowDimensions() globalThis.addEventListener('resize', updateWindowDimensions) - if (!isDataLoaded || contextInstanceId !== instanceId) { - loadKeys(viewType) - } - // componentWillUnmount return () => { globalThis.removeEventListener('resize', updateWindowDimensions) @@ -125,25 +112,6 @@ const BrowserPage = () => { })) }, []) - const handleToggleFullScreen = () => { - dispatch(toggleBrowserFullScreen()) - - const browserViewEvent = !isBrowserFullScreen - ? TelemetryEvent.BROWSER_KEY_DETAILS_FULL_SCREEN_ENABLED - : TelemetryEvent.BROWSER_KEY_DETAILS_FULL_SCREEN_DISABLED - const treeViewEvent = !isBrowserFullScreen - ? TelemetryEvent.TREE_VIEW_KEY_DETAILS_FULL_SCREEN_ENABLED - : TelemetryEvent.TREE_VIEW_KEY_DETAILS_FULL_SCREEN_DISABLED - sendEventTelemetry({ - event: getBasedOnViewTypeEvent(viewType, browserViewEvent, treeViewEvent), - eventData: { - databaseId: instanceId, - keyType: type, - length, - } - }) - } - const sendPageView = (instanceId: string) => { sendPageViewTelemetry({ name: TelemetryPageView.BROWSER_PAGE, @@ -152,35 +120,36 @@ const BrowserPage = () => { setIsPageViewSent(true) } - const loadKeys = (keyViewType: KeyViewType = KeyViewType.Browser) => { - dispatch(setConnectedInstanceId(instanceId)) - dispatch(fetchKeys( - '0', - keyViewType === KeyViewType.Browser ? SCAN_COUNT_DEFAULT : SCAN_TREE_COUNT_DEFAULT, - () => dispatch(setBrowserKeyListDataLoaded(true)), - () => dispatch(setBrowserKeyListDataLoaded(false)) - )) - } - - const handleAddKeyPanel = (value: boolean, keyName?: RedisResponseBuffer) => { - if (value && !isAddKeyPanelOpen && !isBulkActionsPanelOpen) { + const handlePanel = (value: boolean, keyName?: RedisResponseBuffer) => { + if (value && !isAddKeyPanelOpen && !isBulkActionsPanelOpen && !isCreateIndexPanelOpen) { dispatch(resetKeyInfo()) } - setSelectedKey(keyName ?? null) + dispatch(toggleBrowserFullScreen(false)) - setIsAddKeyPanelOpen(value) - setIsBulkActionsPanelOpen(false) + setSelectedKey(keyName ?? null) + closeRightPanels() } - const handleBulkActionsPanel = (value: boolean, keyName?: RedisResponseBuffer) => { - if (value && !isAddKeyPanelOpen && !isBulkActionsPanelOpen) { - dispatch(resetKeyInfo()) - } - setSelectedKey(keyName ?? null) - dispatch(toggleBrowserFullScreen(false)) - setIsAddKeyPanelOpen(false) + const handleAddKeyPanel = useCallback((value: boolean, keyName?: RedisResponseBuffer) => { + handlePanel(value, keyName) + setIsAddKeyPanelOpen(value) + }, []) + + const handleBulkActionsPanel = useCallback((value: boolean) => { + handlePanel(value) setIsBulkActionsPanelOpen(value) - } + }, []) + + const handleCreateIndexPanel = useCallback((value: boolean) => { + handlePanel(value) + setIsCreateIndexPanelOpen(value) + }, []) + + const closeRightPanels = useCallback(() => { + setIsAddKeyPanelOpen(false) + setIsBulkActionsPanelOpen(false) + setIsCreateIndexPanelOpen(false) + }, []) const selectKey = ({ rowData }: { rowData: any }) => { if (!isEqualBuffers(rowData.name, selectedKey)) { @@ -188,53 +157,12 @@ const BrowserPage = () => { dispatch(setInitialStateByType(prevSelectedType.current)) setSelectedKey(rowData.name) - setIsAddKeyPanelOpen(false) - setIsBulkActionsPanelOpen(false) + closeRightPanels() prevSelectedType.current = rowData.type } } - const closePanel = () => { - dispatch(resetKeyInfo()) - dispatch(toggleBrowserFullScreen(true)) - - setSelectedKey(null) - setIsAddKeyPanelOpen(false) - setIsBulkActionsPanelOpen(false) - } - - const loadMoreItems = ( - oldKeys: IKeyPropTypes[], - { startIndex, stopIndex }: { startIndex: number; stopIndex: number } - ) => { - if (keysState.nextCursor !== '0') { - dispatch(fetchMoreKeys(oldKeys, keysState.nextCursor, stopIndex - startIndex + 1)) - } - } - - const handleScanMoreClick = (config: { startIndex: number; stopIndex: number }) => { - keyListRef.current?.handleLoadMoreItems?.(config) - } - - const handleEditKey = (key: RedisResponseBuffer, newKey: RedisResponseBuffer) => { - setSelectedKey(newKey) - - if (viewType === KeyViewType.Tree) { - dispatch(updateBrowserTreeSelectedLeaf({ key: bufferToString(key), newKey: bufferToString(newKey) })) - } - } - - const isRightPanelOpen = selectedKey !== null || isAddKeyPanelOpen || isBulkActionsPanelOpen - - const onEditKey = useCallback( - (key: RedisResponseBuffer, newKey: RedisResponseBuffer) => handleEditKey(key, newKey), - [], - ) - - const onSelectKey = useCallback( - () => setSelectedKey(null), - [], - ) + const isRightPanelOpen = selectedKey !== null || isAddKeyPanelOpen || isBulkActionsPanelOpen || isCreateIndexPanelOpen return (
@@ -256,36 +184,15 @@ const BrowserPage = () => { }), }} > -
- - {viewType === KeyViewType.Browser && ( - - )} - {viewType === KeyViewType.Tree && ( - - )} -
+ { }), }} > - {isAddKeyPanelOpen && !isBulkActionsPanelOpen && ( - - )} - {!isAddKeyPanelOpen && !isBulkActionsPanelOpen && ( - - )} - {isBulkActionsPanelOpen && !isAddKeyPanelOpen && ( - - )} + )} diff --git a/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx b/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx new file mode 100644 index 0000000000..1ad1e1e108 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/browser-left-panel/BrowserLeftPanel.tsx @@ -0,0 +1,138 @@ +import React, { useCallback, useEffect, useRef } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' + +import { + appContextBrowser, + appContextSelector, + setBrowserKeyListDataLoaded, +} from 'uiSrc/slices/app/context' +import { + fetchKeys, + fetchMoreKeys, + keysDataSelector, + keysSelector, +} from 'uiSrc/slices/browser/keys' +import { KeyViewType, SearchMode } from 'uiSrc/slices/interfaces/keys' +import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' +import { setConnectedInstanceId } from 'uiSrc/slices/instances/instances' +import { SCAN_COUNT_DEFAULT, SCAN_TREE_COUNT_DEFAULT } from 'uiSrc/constants/api' +import { redisearchDataSelector, redisearchSelector } from 'uiSrc/slices/browser/redisearch' + +import KeyList from '../key-list' +import KeyTree from '../key-tree' +import KeysHeader from '../keys-header' + +import styles from './styles.module.scss' + +export interface Props { + arePanelsCollapsed: boolean + selectKey: ({ rowData }: { rowData: any }) => void + panelsState: { + handleAddKeyPanel: (value: boolean) => void + handleBulkActionsPanel: (value: boolean) => void + handleCreateIndexPanel: (value: boolean) => void + } +} + +const BrowserLeftPanel = (props: Props) => { + const { + selectKey, + panelsState, + } = props + + const { + handleAddKeyPanel, + handleBulkActionsPanel, + handleCreateIndexPanel, + } = panelsState + + const { instanceId } = useParams<{ instanceId: string }>() + const patternKeysState = useSelector(keysDataSelector) + const redisearchKeysState = useSelector(redisearchDataSelector) + const { loading: redisearchLoading, isSearched: redisearchIsSearched } = useSelector(redisearchSelector) + const { loading: patternLoading, viewType, searchMode, isSearched: patternIsSearched } = useSelector(keysSelector) + const { keyList: { isDataLoaded } } = useSelector(appContextBrowser) + const { contextInstanceId } = useSelector(appContextSelector) + + const keyListRef = useRef() + + const dispatch = useDispatch() + + const keysState = searchMode === SearchMode.Pattern ? patternKeysState : redisearchKeysState + const loading = searchMode === SearchMode.Pattern ? patternLoading : redisearchLoading + const isSearched = searchMode === SearchMode.Pattern ? patternIsSearched : redisearchIsSearched + + useEffect(() => { + if (!isDataLoaded || contextInstanceId !== instanceId) { + loadKeys(viewType) + } + }, []) + + const loadKeys = useCallback((keyViewType: KeyViewType = KeyViewType.Browser) => { + dispatch(setConnectedInstanceId(instanceId)) + + dispatch(fetchKeys( + searchMode, + '0', + keyViewType === KeyViewType.Browser ? SCAN_COUNT_DEFAULT : SCAN_TREE_COUNT_DEFAULT, + () => dispatch(setBrowserKeyListDataLoaded(true)), + () => dispatch(setBrowserKeyListDataLoaded(false)) + )) + }, [searchMode]) + + const loadMoreItems = ( + oldKeys: IKeyPropTypes[], + { startIndex, stopIndex }: { startIndex: number; stopIndex: number } + ) => { + if (keysState.nextCursor !== '0') { + dispatch(fetchMoreKeys( + searchMode, + oldKeys, + keysState.nextCursor, + stopIndex - startIndex + 1 + )) + } + } + + const handleScanMoreClick = (config: { startIndex: number; stopIndex: number }) => { + keyListRef.current?.handleLoadMoreItems?.(config) + } + + return ( +
+ + {viewType === KeyViewType.Browser && ( + + )} + {viewType === KeyViewType.Tree && ( + + )} +
+ ) +} + +export default React.memo(BrowserLeftPanel) diff --git a/redisinsight/ui/src/pages/browser/components/browser-left-panel/index.ts b/redisinsight/ui/src/pages/browser/components/browser-left-panel/index.ts new file mode 100644 index 0000000000..ed98208ca3 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/browser-left-panel/index.ts @@ -0,0 +1,3 @@ +import BrowserLeftPanel from './BrowserLeftPanel' + +export default BrowserLeftPanel diff --git a/redisinsight/ui/src/pages/browser/components/browser-left-panel/styles.module.scss b/redisinsight/ui/src/pages/browser/components/browser-left-panel/styles.module.scss new file mode 100644 index 0000000000..ad311e286c --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/browser-left-panel/styles.module.scss @@ -0,0 +1,5 @@ +.container { + display: flex; + flex-direction: column; + height: 100%; +} diff --git a/redisinsight/ui/src/pages/browser/components/browser-right-panel/BrowserRightPanel.tsx b/redisinsight/ui/src/pages/browser/components/browser-right-panel/BrowserRightPanel.tsx new file mode 100644 index 0000000000..e66fc57208 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/browser-right-panel/BrowserRightPanel.tsx @@ -0,0 +1,149 @@ +import { every } from 'lodash' +import React, { useCallback } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useParams } from 'react-router-dom' + +import AddKey from 'uiSrc/pages/browser/components/add-key/AddKey' +import BulkActions from 'uiSrc/pages/browser/components/bulk-actions' +import CreateRedisearchIndex from 'uiSrc/pages/browser/components/create-redisearch-index/' +import KeyDetailsWrapper from 'uiSrc/pages/browser/components/key-details/KeyDetailsWrapper' + +import { updateBrowserTreeSelectedLeaf } from 'uiSrc/slices/app/context' +import { + keysSelector, + selectedKeyDataSelector, + toggleBrowserFullScreen +} from 'uiSrc/slices/browser/keys' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { KeyViewType } from 'uiSrc/slices/interfaces/keys' +import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { bufferToString, Nullable } from 'uiSrc/utils' + +export interface Props { + selectedKey: Nullable + setSelectedKey: (keyName: Nullable) => void + arePanelsCollapsed: boolean + panelsState: { + isAddKeyPanelOpen: boolean + handleAddKeyPanel: (value: boolean) => void + isBulkActionsPanelOpen: boolean + handleBulkActionsPanel: (value: boolean) => void + isCreateIndexPanelOpen: boolean + handleCreateIndexPanel?: (value: boolean) => void + closeRightPanels: () => void + } +} + +const BrowserRightPanel = (props: Props) => { + const { + selectedKey, + arePanelsCollapsed, + setSelectedKey, + panelsState + } = props + + const { + isAddKeyPanelOpen, + handleAddKeyPanel, + isBulkActionsPanelOpen, + handleBulkActionsPanel, + isCreateIndexPanelOpen, + closeRightPanels + } = panelsState + + const { isBrowserFullScreen, viewType } = useSelector(keysSelector) + const { type, length } = useSelector(selectedKeyDataSelector) ?? { type: '', length: 0 } + + const { instanceId } = useParams<{ instanceId: string }>() + const dispatch = useDispatch() + + const closePanel = () => { + dispatch(toggleBrowserFullScreen(true)) + + setSelectedKey(null) + closeRightPanels() + } + + const onCloseRedisearchPanel = () => { + closePanel() + sendEventTelemetry({ + event: TelemetryEvent.SEARCH_INDEX_ADD_CANCELLED, + eventData: { + databaseId: instanceId + } + }) + } + + const handleToggleFullScreen = () => { + dispatch(toggleBrowserFullScreen()) + + const browserViewEvent = !isBrowserFullScreen + ? TelemetryEvent.BROWSER_KEY_DETAILS_FULL_SCREEN_ENABLED + : TelemetryEvent.BROWSER_KEY_DETAILS_FULL_SCREEN_DISABLED + const treeViewEvent = !isBrowserFullScreen + ? TelemetryEvent.TREE_VIEW_KEY_DETAILS_FULL_SCREEN_ENABLED + : TelemetryEvent.TREE_VIEW_KEY_DETAILS_FULL_SCREEN_DISABLED + sendEventTelemetry({ + event: getBasedOnViewTypeEvent(viewType, browserViewEvent, treeViewEvent), + eventData: { + databaseId: instanceId, + keyType: type, + length, + } + }) + } + + const handleEditKey = (key: RedisResponseBuffer, newKey: RedisResponseBuffer) => { + setSelectedKey(newKey) + + if (viewType === KeyViewType.Tree) { + dispatch(updateBrowserTreeSelectedLeaf({ key: bufferToString(key), newKey: bufferToString(newKey) })) + } + } + + const onEditKey = useCallback( + (key: RedisResponseBuffer, newKey: RedisResponseBuffer) => handleEditKey(key, newKey), + [], + ) + + const onSelectKey = useCallback( + () => setSelectedKey(null), + [], + ) + + return ( + <> + {every([!isAddKeyPanelOpen, !isBulkActionsPanelOpen, !isCreateIndexPanelOpen], Boolean) && ( + + )} + {isAddKeyPanelOpen && every([!isBulkActionsPanelOpen, !isCreateIndexPanelOpen], Boolean) && ( + + )} + {isBulkActionsPanelOpen && every([!isAddKeyPanelOpen, !isCreateIndexPanelOpen], Boolean) && ( + + )} + {isCreateIndexPanelOpen && every([!isAddKeyPanelOpen, !isBulkActionsPanelOpen], Boolean) && ( + + )} + + ) +} + +export default React.memo(BrowserRightPanel) diff --git a/redisinsight/ui/src/pages/browser/components/browser-right-panel/index.ts b/redisinsight/ui/src/pages/browser/components/browser-right-panel/index.ts new file mode 100644 index 0000000000..f25c8856cc --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/browser-right-panel/index.ts @@ -0,0 +1,3 @@ +import BrowserRightPanel from './BrowserRightPanel' + +export default BrowserRightPanel diff --git a/redisinsight/ui/src/pages/browser/components/create-redisearch-index/CreateRedisearchIndex.tsx b/redisinsight/ui/src/pages/browser/components/create-redisearch-index/CreateRedisearchIndex.tsx new file mode 100644 index 0000000000..b20e27bdc9 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/create-redisearch-index/CreateRedisearchIndex.tsx @@ -0,0 +1,267 @@ +import { + EuiButton, + EuiComboBox, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormFieldset, + EuiFormRow, + EuiHealth, + EuiPanel, + EuiSuperSelect, + EuiTextColor +} from '@elastic/eui' +import { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types' +import cx from 'classnames' +import React, { ChangeEvent, useEffect, useRef, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import Divider from 'uiSrc/components/divider/Divider' +import AddItemsActions from 'uiSrc/pages/browser/components/add-items-actions/AddItemsActions' +import { createIndexStateSelector, createRedisearchIndexAction } from 'uiSrc/slices/browser/redisearch' +import { stringToBuffer } from 'uiSrc/utils' +import { CreateRedisearchIndexDto } from 'apiSrc/modules/browser/dto/redisearch' + +import { FIELD_TYPE_OPTIONS, KEY_TYPE_OPTIONS, RedisearchIndexKeyType } from './constants' + +import styles from './styles.module.scss' + +export interface Props { + onClosePanel: () => void +} + +const keyTypeOptions = KEY_TYPE_OPTIONS.map((item) => { + const { value, color, text } = item + return { + value, + inputDisplay: ( + + {text} + + ), + } +}) + +const fieldTypeOptions = FIELD_TYPE_OPTIONS.map(({ value, text }) => ({ + value, + inputDisplay: text, +})) + +const initialFieldValue = (id = 0) => ({ id, identifier: '', fieldType: fieldTypeOptions[0].value }) + +const CreateRedisearchIndex = ({ onClosePanel }: Props) => { + const { loading } = useSelector(createIndexStateSelector) + + const [keyTypeSelected, setKeyTypeSelected] = useState(keyTypeOptions[0].value) + const [prefixes, setPrefixes] = useState([]) + const [indexName, setIndexName] = useState('') + const [fields, setFields] = useState([initialFieldValue()]) + + const lastAddedIdentifier = useRef(null) + const prevCountFields = useRef(0) + + const dispatch = useDispatch() + + useEffect(() => { + if (prevCountFields.current !== 0 && prevCountFields.current < fields.length) { + lastAddedIdentifier.current?.focus() + } + prevCountFields.current = fields.length + }, [fields.length]) + + const addField = () => { + const lastFieldId = fields[fields.length - 1].id + setFields([...fields, initialFieldValue(lastFieldId + 1)]) + } + + const removeField = (id: number) => { + setFields((fields) => fields.filter((item) => item.id !== id)) + } + + const clearFieldsValues = (id: number) => { + setFields((fields) => fields.map((item) => (item.id === id ? initialFieldValue(id) : item))) + } + + const handleFieldChange = (formField: string, id: number, value: string) => { + setFields((fields) => fields.map((item) => ((item.id === id) ? { ...item, [formField]: value } : item))) + } + + const submitData = () => { + const data: CreateRedisearchIndexDto = { + index: stringToBuffer(indexName), + type: keyTypeSelected, + prefixes: prefixes.map((p) => stringToBuffer(p.label as string)), + fields: fields.map((item) => ({ + name: stringToBuffer(item.identifier), + type: item.fieldType + })) + } + + dispatch(createRedisearchIndexAction(data, onClosePanel)) + } + + const isClearDisabled = (item: any): boolean => fields.length === 1 && !(item.identifier.length) + + return ( + <> +
+
+
+ + + + setIndexName(e.target.value)} + autoComplete="off" + data-testid="index-name" + /> + + + + + + setKeyTypeSelected(value)} + data-testid="key-type" + /> + + + + + + + + setPrefixes([...prefixes, { label: searchValue }])} + onChange={(selectedOptions) => setPrefixes(selectedOptions)} + className={styles.combobox} + data-testid="prefix-combobox" + /> + + + + + { + fields.map((item, index) => ( + + + + + + + ) => handleFieldChange( + 'identifier', + item.id, + e.target.value + )} + inputRef={index === fields.length - 1 ? lastAddedIdentifier : null} + autoComplete="off" + data-testid={`identifier-${item.id}`} + /> + + + + + handleFieldChange( + 'fieldType', + item.id, + value + )} + data-testid={`field-type-${item.id}`} + /> + + + + + + + + )) + } +
+
+
+ + + + onClosePanel()} + className="btn-cancel btn-back" + data-testid="create-index-cancel-btn" + > + Cancel + + + + + Create Index + + + + + + ) +} + +export default CreateRedisearchIndex diff --git a/redisinsight/ui/src/pages/browser/components/create-redisearch-index/CreateRedisearchIndexWrapper.spec.tsx b/redisinsight/ui/src/pages/browser/components/create-redisearch-index/CreateRedisearchIndexWrapper.spec.tsx new file mode 100644 index 0000000000..9d03ad2f1c --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/create-redisearch-index/CreateRedisearchIndexWrapper.spec.tsx @@ -0,0 +1,103 @@ +import { cloneDeep } from 'lodash' +import React from 'react' +import { createIndex } from 'uiSrc/slices/browser/redisearch' +import { render, screen, fireEvent, cleanup, mockedStore } from 'uiSrc/utils/test-utils' + +import CreateRedisearchIndexWrapper from './CreateRedisearchIndexWrapper' + +const onClose = jest.fn() + +let store: typeof mockedStore + +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('CreateRedisearchIndexWrapper', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call onClose after click cross icon', () => { + render() + + fireEvent.click(screen.getByTestId('create-index-close-panel')) + expect(onClose).toBeCalled() + onClose.mockRestore() + }) + + it('should call onClose after click cancel', () => { + render() + + fireEvent.click(screen.getByTestId('create-index-cancel-btn')) + expect(onClose).toBeCalled() + onClose.mockRestore() + }) + + it('should add prefix and delete it', () => { + const { container } = render() + + const comboboxInput = container + .querySelector('[data-testid="prefix-combobox"] [data-test-subj="comboBoxSearchInput"]') as HTMLInputElement + + fireEvent.change( + comboboxInput, + { target: { value: 'val1' } } + ) + + fireEvent.keyDown(comboboxInput, { key: 'Enter', code: 13, charCode: 13 }) + + const containerLabels = container.querySelector('[data-test-subj="comboBoxInput"]')! + expect(containerLabels.querySelector('[title="val1"]')).toBeInTheDocument() + + fireEvent.click(containerLabels.querySelector('[title^="Remove val1"]')!) + expect(containerLabels.querySelector('[title="val1"]')).not.toBeInTheDocument() + }) + + it('should be preselected hash type', () => { + render() + + expect(screen.getByTestId('key-type')).toHaveTextContent('Hash') + }) + + it('should call proper action on submit', () => { + render() + + const expectedActions = [createIndex()] + fireEvent.click(screen.getByTestId('create-index-btn')) + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should properly change all fields', () => { + const { queryByText, container } = render() + + const containerLabels = container.querySelector('[data-test-subj="comboBoxInput"]')! + const comboboxInput = container + .querySelector('[data-testid="prefix-combobox"] [data-test-subj="comboBoxSearchInput"]') as HTMLInputElement + + fireEvent.change(screen.getByTestId('index-name'), { target: { value: 'index' } }) + fireEvent.change( + comboboxInput, + { target: { value: 'val1' } } + ) + + fireEvent.keyDown(comboboxInput, { key: 'Enter', code: 13, charCode: 13 }) + + fireEvent.click(screen.getByTestId('key-type')) + fireEvent.click(queryByText('JSON') || document) + + fireEvent.change(screen.getByTestId('identifier-0'), { target: { value: 'identifier' } }) + + fireEvent.click(screen.getByTestId('field-type-0')) + fireEvent.click(queryByText('GEO') || document) + + expect(screen.getByTestId('index-name')).toHaveValue('index') + expect(screen.getByTestId('key-type')).toHaveTextContent('JSON') + expect(containerLabels.querySelector('[title="val1"]')).toBeInTheDocument() + expect(screen.getByTestId('identifier-0')).toHaveValue('identifier') + expect(screen.getByTestId('field-type-0')).toHaveTextContent('GEO') + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/create-redisearch-index/CreateRedisearchIndexWrapper.tsx b/redisinsight/ui/src/pages/browser/components/create-redisearch-index/CreateRedisearchIndexWrapper.tsx new file mode 100644 index 0000000000..56da8d5b74 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/create-redisearch-index/CreateRedisearchIndexWrapper.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiText, + EuiTitle, + EuiToolTip +} from '@elastic/eui' +import cx from 'classnames' +import CreateRedisearchIndex from './CreateRedisearchIndex' + +import styles from './styles.module.scss' + +export interface Props { + onClosePanel: () => void +} + +const CreateRedisearchIndexWrapper = ({ onClosePanel }: Props) => ( +
+ +
+ + +

New Index

+
+ + + +
+ + Use CLI or Workbench to create more advanced indexes. See more details in the + {' '} + + documentation. + + + +
+ +
+
+) + +export default CreateRedisearchIndexWrapper diff --git a/redisinsight/ui/src/pages/browser/components/create-redisearch-index/constants.ts b/redisinsight/ui/src/pages/browser/components/create-redisearch-index/constants.ts new file mode 100644 index 0000000000..a67ddbf541 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/create-redisearch-index/constants.ts @@ -0,0 +1,50 @@ +import { GROUP_TYPES_COLORS, KeyTypes } from 'uiSrc/constants' + +export enum FieldTypes { + TEXT = 'text', + TAG = 'tag', + NUMERIC = 'numeric', + GEO = 'geo', + VECTOR = 'vector', +} + +export enum RedisearchIndexKeyType { + HASH = 'hash', + JSON = 'json', +} + +export const KEY_TYPE_OPTIONS = [ + { + text: 'Hash', + value: RedisearchIndexKeyType.HASH, + color: GROUP_TYPES_COLORS[KeyTypes.Hash], + }, + { + text: 'JSON', + value: RedisearchIndexKeyType.JSON, + color: GROUP_TYPES_COLORS[KeyTypes.JSON], + }, +] + +export const FIELD_TYPE_OPTIONS = [ + { + text: 'TEXT', + value: FieldTypes.TEXT, + }, + { + text: 'TAG', + value: FieldTypes.TAG, + }, + { + text: 'NUMERIC', + value: FieldTypes.NUMERIC, + }, + { + text: 'GEO', + value: FieldTypes.GEO, + }, + { + text: 'VECTOR', + value: FieldTypes.VECTOR, + } +] diff --git a/redisinsight/ui/src/pages/browser/components/create-redisearch-index/index.ts b/redisinsight/ui/src/pages/browser/components/create-redisearch-index/index.ts new file mode 100644 index 0000000000..065294bb25 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/create-redisearch-index/index.ts @@ -0,0 +1,3 @@ +import CreateRedisearchIndex from './CreateRedisearchIndexWrapper' + +export default CreateRedisearchIndex diff --git a/redisinsight/ui/src/pages/browser/components/create-redisearch-index/styles.module.scss b/redisinsight/ui/src/pages/browser/components/create-redisearch-index/styles.module.scss new file mode 100644 index 0000000000..4c78c760a4 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/create-redisearch-index/styles.module.scss @@ -0,0 +1,69 @@ +.page { + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + + .container { + background-color: var(--euiColorEmptyShade); + height: 100%; + } + + .headerWrapper { + padding: 24px 0; + min-height: 108px; + border-bottom: 1px solid var(--separatorColor); + flex-shrink: 0; + } + + .header { + padding: 0 20px; + } + + .closeBtnTooltip { + position: absolute; + top: 22px; + right: 18px; + + svg { + width: 20px; + height: 20px; + } + } + + .controlsDivider { + margin-top: 32px; + margin-bottom: 40px; + } + + .contentFields { + padding: 24px 18px 0; + + .fieldsContainer { + margin: 0 auto; + width: 100%; + max-width: 680px; + } + } + + .row { + margin-bottom: 20px !important; + } + + .combobox { + max-width: none !important; + :global(.euiFormControlLayout) { + max-width: none !important; + } + :global(.euiBadge__content) { + min-height: 22px; + } + } + + .footer { + margin-top: 24px; + background-color: var(--browserTableRowEven) !important; + border: 0 solid var(--euiColorLightShade) !important; + border-top-width: 1px !important; + } +} diff --git a/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.tsx b/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.tsx index 2528f09ce3..9e311f716e 100644 --- a/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.tsx +++ b/redisinsight/ui/src/pages/browser/components/filter-key-type/FilterKeyType.tsx @@ -28,7 +28,7 @@ const FilterKeyType = () => { const [isInfoPopoverOpen, setIsInfoPopoverOpen] = useState(false) const { version } = useSelector(connectedInstanceOverviewSelector) - const { filter, viewType } = useSelector(keysSelector) + const { filter, viewType, searchMode } = useSelector(keysSelector) const dispatch = useDispatch() useEffect(() => { @@ -77,7 +77,11 @@ const FilterKeyType = () => { setTypeSelected(value) setIsSelectOpen(false) dispatch(setFilter(value || null)) - dispatch(fetchKeys('0', viewType === KeyViewType.Browser ? SCAN_COUNT_DEFAULT : SCAN_TREE_COUNT_DEFAULT)) + dispatch(fetchKeys( + searchMode, + '0', + viewType === KeyViewType.Browser ? SCAN_COUNT_DEFAULT : SCAN_TREE_COUNT_DEFAULT, + )) // reset browser tree context dispatch(resetBrowserTree()) diff --git a/redisinsight/ui/src/pages/browser/components/filter-key-type/styles.module.scss b/redisinsight/ui/src/pages/browser/components/filter-key-type/styles.module.scss index 7b359a5020..699abf6f8d 100644 --- a/redisinsight/ui/src/pages/browser/components/filter-key-type/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/filter-key-type/styles.module.scss @@ -3,6 +3,7 @@ height: 36px; width: 154px; overflow: hidden; + left: 100px; :global { .euiFormControlLayout { diff --git a/redisinsight/ui/src/pages/browser/components/key-list/KeyList.spec.tsx b/redisinsight/ui/src/pages/browser/components/key-list/KeyList.spec.tsx index 85e2926937..e1f8010dd1 100644 --- a/redisinsight/ui/src/pages/browser/components/key-list/KeyList.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-list/KeyList.spec.tsx @@ -1,7 +1,7 @@ import React from 'react' import { cloneDeep } from 'lodash' import { render, waitFor } from 'uiSrc/utils/test-utils' -import { KeysStoreData, KeyViewType } from 'uiSrc/slices/interfaces/keys' +import { KeysStoreData, KeyViewType, SearchMode } from 'uiSrc/slices/interfaces/keys' import { keysSelector, setLastBatchKeys } from 'uiSrc/slices/browser/keys' import { apiService } from 'uiSrc/services' import KeyList from './KeyList' @@ -46,17 +46,19 @@ const propsMock = { jest.mock('uiSrc/slices/browser/keys', () => ({ ...jest.requireActual('uiSrc/slices/browser/keys'), - setLastBatchKeys: jest.fn(), + // TODO: find solution for mock "setLastBatchKeys" action + // setLastBatchKeys: jest.fn(), keysSelector: jest.fn().mockResolvedValue({ viewType: 'Browser', + searchMode: 'Pattern', isSearch: false, isFiltered: false, }), })) -afterEach(() => { - setLastBatchKeys.mockRestore() -}) +// afterEach(() => { +// setLastBatchKeys.mockRestore() +// }) describe('KeyList', () => { it('should render', () => { @@ -71,8 +73,10 @@ describe('KeyList', () => { expect(rows).toHaveLength(3) }) - it('should call "setLastBatchKeys" after unmount for Browser view', () => { + // TODO: find solution for mock "setLastBatchKeys" action + it.skip('should call "setLastBatchKeys" after unmount for Browser view', () => { keysSelector.mockImplementation(() => ({ + searchMode: SearchMode.Pattern, viewType: KeyViewType.Browser, isSearch: false, isFiltered: false, @@ -86,7 +90,8 @@ describe('KeyList', () => { expect(setLastBatchKeys).toBeCalledTimes(1) }) - it('should not call "setLastBatchKeys" after unmount for Tree view', () => { + // TODO: find solution for mock "setLastBatchKeys" action + it.skip('should not call "setLastBatchKeys" after unmount for Tree view', () => { keysSelector.mockImplementation(() => ({ viewType: KeyViewType.Tree, isSearch: false, @@ -120,7 +125,8 @@ describe('KeyList', () => { }) it('apiService.post should be called with only keys without info', async () => { - const params = { params: { encoding: 'buffer' } } + const controller = new AbortController() + const params = { params: { encoding: 'buffer' }, signal: controller.signal } const apiServiceMock = jest.fn().mockResolvedValue(cloneDeep(propsMock.keysState.keys)) apiService.post = apiServiceMock @@ -137,7 +143,7 @@ describe('KeyList', () => { />) await waitFor(async () => { - expect(apiServiceMock).toBeCalledTimes(2) + expect(apiServiceMock).toBeCalledTimes(3) expect(apiServiceMock.mock.calls[0]).toEqual([ '/instance//keys/get-metadata', diff --git a/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx b/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx index fb776463e3..f2b092da24 100644 --- a/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx +++ b/redisinsight/ui/src/pages/browser/components/key-list/KeyList.tsx @@ -20,12 +20,14 @@ import { bufferToString, bufferFormatRangeItems, isEqualBuffers, + Nullable, } from 'uiSrc/utils' import { NoKeysToDisplayText, NoResultsFoundText, FullScanNoResultsFoundText, ScanNoResultsFoundText, + NoSelectedIndexText, } from 'uiSrc/constants/texts' import { fetchKeysMetadata, @@ -42,12 +44,13 @@ import { } from 'uiSrc/slices/app/context' import { GroupBadge } from 'uiSrc/components' import { SCAN_COUNT_DEFAULT } from 'uiSrc/constants/api' -import { KeysStoreData, KeyViewType } from 'uiSrc/slices/interfaces/keys' +import { KeysStoreData, SearchMode } from 'uiSrc/slices/interfaces/keys' import VirtualTable from 'uiSrc/components/virtual-table/VirtualTable' import { ITableColumn } from 'uiSrc/components/virtual-table/interfaces' import { Pages, TableCellAlignment, TableCellTextAlignment } from 'uiSrc/constants' import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { redisearchSelector } from 'uiSrc/slices/browser/redisearch' import { GetKeyInfoResponse } from 'apiSrc/modules/browser/dto' import styles from './styles.module.scss' @@ -72,12 +75,14 @@ const KeyList = forwardRef((props: Props, ref) => { const selectedKey = useSelector(selectedKeySelector) const { total, nextCursor, previousResultCount } = useSelector(keysDataSelector) - const { isSearched, isFiltered, viewType } = useSelector(keysSelector) + const { isSearched, isFiltered, viewType, searchMode } = useSelector(keysSelector) + const { selectedIndex } = useSelector(redisearchSelector) const { keyList: { scrollTopPosition, isNotRendered: isNotRenderedContext } } = useSelector(appContextBrowser) const [, rerender] = useState({}) - const [firstDataLoaded, setFirstDataLoaded] = useState(!!keysState.keys.length) + const [firstDataLoaded, setFirstDataLoaded] = useState(!!keysState.keys.length) + const controller = useRef>(null) const itemsRef = useRef(keysState.keys) const isNotRendered = useRef(isNotRenderedContext) const renderedRowsIndexesRef = useRef({ startIndex: 0, lastIndex: 0 }) @@ -90,15 +95,13 @@ const KeyList = forwardRef((props: Props, ref) => { } })) - useEffect(() => - () => { - if (viewType === KeyViewType.Tree) { - return - } - rerender(() => { - dispatch(setLastBatchKeys(itemsRef.current?.slice(-SCAN_COUNT_DEFAULT))) - }) - }, []) + useEffect(() => { + cancelAllMetadataRequests() + + return () => { + dispatch(setLastBatchKeys(itemsRef.current?.slice(-SCAN_COUNT_DEFAULT - 1), searchMode)) + } + }, [searchMode]) useEffect(() => { itemsRef.current = [...keysState.keys] @@ -110,10 +113,14 @@ const KeyList = forwardRef((props: Props, ref) => { isNotRendered.current = false dispatch(setBrowserIsNotRendered(isNotRendered.current)) if (itemsRef.current.length === 0) { + setFirstDataLoaded(true) rerender({}) return } + cancelAllMetadataRequests() + controller.current = new AbortController() + const { startIndex, lastIndex } = renderedRowsIndexesRef.current onRowsRendered(startIndex, lastIndex) rerender({}) @@ -131,6 +138,10 @@ const KeyList = forwardRef((props: Props, ref) => { rerender({}) }, [selectedKey]) + const cancelAllMetadataRequests = () => { + controller.current?.abort() + } + const onNoKeysLinkClick = () => { sendEventTelemetry({ event: getBasedOnViewTypeEvent( @@ -148,9 +159,15 @@ const KeyList = forwardRef((props: Props, ref) => { if (isNotRendered.current) { return '' } + if (searchMode === SearchMode.Redisearch && !selectedIndex) { + return NoSelectedIndexText + } if (total === 0) { return NoKeysToDisplayText(Pages.workbench(instanceId), onNoKeysLinkClick) } + if (isSearched && searchMode === SearchMode.Redisearch) { + return keysState.scanned < total ? NoResultsFoundText : FullScanNoResultsFoundText + } if (isSearched) { return keysState.scanned < total ? ScanNoResultsFoundText : FullScanNoResultsFoundText } @@ -223,6 +240,7 @@ const KeyList = forwardRef((props: Props, ref) => { dispatch(fetchKeysMetadata( emptyItems.map(({ name }) => name), + controller.current?.signal, (loadedItems) => onSuccessFetchedMetadata(startIndex + firstEmptyItemIndex, loadedItems), () => { rerender({}) } diff --git a/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx b/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx index fcdb91c533..74ed005e53 100644 --- a/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx +++ b/redisinsight/ui/src/pages/browser/components/keys-header/KeysHeader.tsx @@ -1,51 +1,45 @@ +/* eslint-disable react/destructuring-assignment */ +import { EuiButton, EuiButtonIcon, EuiIcon, EuiLink, EuiPopover, EuiText, EuiToolTip, } from '@elastic/eui' +import cx from 'classnames' /* eslint-disable react/no-this-in-sfc */ -import React, { Ref, useRef, FC, SVGProps } from 'react' +import React, { FC, Ref, SVGProps, useCallback, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' -import cx from 'classnames' import AutoSizer from 'react-virtualized-auto-sizer' -import { - EuiButton, - EuiButtonIcon, - EuiIcon, - EuiToolTip, -} from '@elastic/eui' - -import { - changeKeyViewType, - fetchKeys, - keysDataSelector, - keysSelector, - resetKeysData, -} from 'uiSrc/slices/browser/keys' -import { - resetBrowserTree, - setBrowserKeyListDataLoaded, -} from 'uiSrc/slices/app/context' -import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' -import { sendEventTelemetry, TelemetryEvent, getBasedOnViewTypeEvent } from 'uiSrc/telemetry' -import { SCAN_COUNT_DEFAULT, SCAN_TREE_COUNT_DEFAULT } from 'uiSrc/constants/api' -import { KeysStoreData, KeyViewType } from 'uiSrc/slices/interfaces/keys' +import { ReactComponent as BulkActionsIcon } from 'uiSrc/assets/img/icons/bulk_actions.svg' +import { ReactComponent as TreeViewIcon } from 'uiSrc/assets/img/icons/treeview.svg' +import { ReactComponent as VectorIcon } from 'uiSrc/assets/img/icons/vector.svg' +import { ReactComponent as RediSearchIcon } from 'uiSrc/assets/img/modules/RedisSearchLight.svg' import KeysSummary from 'uiSrc/components/keys-summary' -import { localStorageService } from 'uiSrc/services' import { BrowserStorageItem } from 'uiSrc/constants' -import { ReactComponent as TreeViewIcon } from 'uiSrc/assets/img/icons/treeview.svg' -import { ReactComponent as BulkActionsIcon } from 'uiSrc/assets/img/icons/bulk_actions.svg' +import { SCAN_COUNT_DEFAULT, SCAN_TREE_COUNT_DEFAULT } from 'uiSrc/constants/api' +import { localStorageService } from 'uiSrc/services' +import { resetBrowserTree, setBrowserKeyListDataLoaded, } from 'uiSrc/slices/app/context' + +import { changeKeyViewType, changeSearchMode, fetchKeys, keysSelector, resetKeysData, } from 'uiSrc/slices/browser/keys' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { REDISEARCH_MODULES } from 'uiSrc/slices/interfaces' +import { KeysStoreData, KeyViewType, SearchMode } from 'uiSrc/slices/interfaces/keys' +import { getBasedOnViewTypeEvent, sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import AutoRefresh from '../auto-refresh' import FilterKeyType from '../filter-key-type' +import RediSearchIndexesList from '../redisearch-key-list' import SearchKeyList from '../search-key-list' -import AutoRefresh from '../auto-refresh' import styles from './styles.module.scss' const HIDE_REFRESH_LABEL_WIDTH = 600 const FULL_SCREEN_RESOLUTION = 1260 +export const REDISEARCH_MAX_KEYS_COUNT = 10_000 -interface IViewType { +interface ISwitchType { tooltipText: string - type: KeyViewType + type: T + disabled?: boolean ariaLabel: string dataTestId: string getClassName: () => string + onClick: () => void isActiveView: () => boolean getIconType: () => string | FC> } @@ -54,9 +48,11 @@ export interface Props { loading: boolean keysState: KeysStoreData nextCursor: string + isSearched: boolean loadKeys: (type?: KeyViewType) => void handleAddKeyPanel: (value: boolean) => void handleBulkActionsPanel: (value: boolean) => void + handleCreateIndexPanel: (value: boolean) => void handleScanMoreClick: (config: any) => void } @@ -64,22 +60,25 @@ const KeysHeader = (props: Props) => { const { loading, keysState, + isSearched, loadKeys, handleAddKeyPanel, handleBulkActionsPanel, + handleCreateIndexPanel, handleScanMoreClick, nextCursor, } = props - const { lastRefreshTime } = useSelector(keysDataSelector) - const { id: instanceId } = useSelector(connectedInstanceSelector) - const { viewType, isSearched, isFiltered } = useSelector(keysSelector) + const { id: instanceId, modules } = useSelector(connectedInstanceSelector) + const { viewType, searchMode, isFiltered } = useSelector(keysSelector) const rootDivRef: Ref = useRef(null) + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + const dispatch = useDispatch() - const viewTypes: IViewType[] = [ + const viewTypes: ISwitchType[] = [ { type: KeyViewType.Browser, tooltipText: 'Browser', @@ -92,6 +91,7 @@ const KeysHeader = (props: Props) => { getIconType() { return 'menu' }, + onClick() { handleSwitchView(this.type) } }, { type: KeyViewType.Tree, @@ -105,12 +105,56 @@ const KeysHeader = (props: Props) => { getIconType() { return TreeViewIcon }, + onClick() { handleSwitchView(this.type) } + }, + ] + + const searchModes: ISwitchType[] = [ + { + type: SearchMode.Pattern, + tooltipText: 'Filter by Key Name or Pattern', + ariaLabel: 'Filter by Key Name or Pattern button', + dataTestId: 'search-mode-pattern-btn', + isActiveView() { return searchMode === this.type }, + getClassName() { + return cx(styles.viewTypeBtn, styles.iconVector, { [styles.active]: this.isActiveView() }) + }, + getIconType() { + return VectorIcon + }, + onClick() { handleSwitchSearchMode(this.type) } + }, + { + type: SearchMode.Redisearch, + tooltipText: 'Search by Values of Keys', + ariaLabel: 'Search by Values of Keys button', + dataTestId: 'search-mode-redisearch-btn', + disabled: !modules?.some(({ name }) => + REDISEARCH_MODULES.some((search) => name === search)), + isActiveView() { return searchMode === this.type }, + getClassName() { + return cx(styles.viewTypeBtn, { [styles.active]: this.isActiveView() }) + }, + getIconType() { + return RediSearchIcon + }, + onClick() { + if (this.disabled) { + showPopover() + } else { + handleSwitchSearchMode(this.type) + } + } }, ] const scanMoreStyle = { marginLeft: 10, height: '36px !important', + // RediSearch can't return more than 10_000 results + display: searchMode === SearchMode.Redisearch && keysState.keys.length >= REDISEARCH_MAX_KEYS_COUNT + ? 'none' + : 'inline-block' } const handleRefreshKeys = (enableAutoRefresh: boolean) => { @@ -127,6 +171,7 @@ const KeysHeader = (props: Props) => { }) } dispatch(fetchKeys( + searchMode, '0', viewType === KeyViewType.Browser ? SCAN_COUNT_DEFAULT : SCAN_TREE_COUNT_DEFAULT, () => dispatch(setBrowserKeyListDataLoaded(true)), @@ -190,13 +235,28 @@ const KeysHeader = (props: Props) => { } }) } - dispatch(resetKeysData()) + dispatch(resetKeysData(searchMode)) dispatch(changeKeyViewType(type)) dispatch(resetBrowserTree()) localStorageService.set(BrowserStorageItem.browserViewType, type) loadKeys(type) } + const handleSwitchSearchMode = (mode: SearchMode) => { + if (searchMode !== mode) { + sendEventTelemetry({ + event: TelemetryEvent.SEARCH_MODE_CHANGED, + eventData: { + databaseId: instanceId, + previous: searchMode, + current: mode + } + }) + } + + dispatch(changeSearchMode(mode)) + } + const AddKeyBtn = ( { className={view.getClassName()} iconType={view.getIconType()} aria-label={view.ariaLabel} - onClick={() => handleSwitchView(view.type)} + onClick={() => view.onClick()} data-testid={view.dataTestId} /> @@ -250,13 +310,84 @@ const KeysHeader = (props: Props) => {
) + const showPopover = useCallback(() => { + setIsPopoverOpen(true) + }, []) + + const hidePopover = useCallback(() => { + setIsPopoverOpen(false) + }, []) + + const SwitchModeBtn = (item: ISwitchType) => ( + item.onClick?.()} + data-testid={item.dataTestId} + /> + ) + + const SearchModeSwitch = (width: number) => ( +
HIDE_REFRESH_LABEL_WIDTH, + [styles.fullScreen]: width > FULL_SCREEN_RESOLUTION + }) + } + data-testid="search-mode-switcher" + > + {searchModes.map((mode) => ( + !mode.disabled ? ( + + {SwitchModeBtn(mode)} + + ) + : ( + + + + {'RediSearch module is not loaded. Create a '} + + free Redis database + + {' with module support on Redis Cloud.'} + + + + )))} + +
+ ) + return (
{({ width }) => (
- + {SearchModeSwitch(width)} + {searchMode === SearchMode.Pattern ? ( + + ) : ( + + )} {ViewSwitch(width)}
@@ -269,7 +400,11 @@ const KeysHeader = (props: Props) => { { HIDE_REFRESH_LABEL_WIDTH} containerClassName={styles.refreshContainer} onRefresh={handleRefreshKeys} diff --git a/redisinsight/ui/src/pages/browser/components/keys-header/styles.module.scss b/redisinsight/ui/src/pages/browser/components/keys-header/styles.module.scss index d52c756ba4..9dab01eb57 100644 --- a/redisinsight/ui/src/pages/browser/components/keys-header/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/keys-header/styles.module.scss @@ -51,7 +51,8 @@ margin-left: 18px; } -.viewTypeSwitch { +.viewTypeSwitch, +.searchModeSwitch { padding: 0 18px; flex-shrink: 0; &.middleScreen { @@ -92,3 +93,58 @@ right: 18px; position: absolute; } + +.popoverPanelWrapper { + width: 225px; + z-index: 10000 !important; +} + +.noModuleInfo { + font-size: 12px !important; + line-height: 18px !important; + color: var(--euiTooltipTextSecondColor) !important; +} + +.iconVector svg { + height: 17px !important; +} + +.searchModeSwitch { + padding: 0 10px 0 0 !important; + + *:first-of-type .viewTypeBtn { + border-radius: 4px 0 0 4px !important; + } + *:last-of-type .viewTypeBtn { + border-radius: 0 4px 4px 0 !important; + } + + .viewTypeBtn { + border: 1px solid var(--euiColorSecondary) !important; + background-color: var(--browserViewTypePassive) !important; + + &:hover { + transform: none !important; + } + + svg { + color: var(--euiColorFullShade) !important; + } + + svg > g > path { + fill: var(--euiColorFullShade) !important; + } + + &.active { + background-color: var(--euiColorSecondary) !important; + + svg { + color: var(--euiColorPrimaryText) !important; + } + + svg > g > path { + fill: var(--euiColorPrimaryText) !important; + } + } + } +} diff --git a/redisinsight/ui/src/pages/browser/components/redisearch-key-list/RediSearchIndexesList.spec.tsx b/redisinsight/ui/src/pages/browser/components/redisearch-key-list/RediSearchIndexesList.spec.tsx new file mode 100644 index 0000000000..d23c703f5a --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/redisearch-key-list/RediSearchIndexesList.spec.tsx @@ -0,0 +1,121 @@ +import { cloneDeep } from 'lodash' +import React from 'react' +import { instance, mock } from 'ts-mockito' +import { useSelector } from 'react-redux' + +import { + cleanup, + clearStoreActions, + fireEvent, + mockedStore, + render, + screen, +} from 'uiSrc/utils/test-utils' +import { loadKeys, loadList, redisearchListSelector, setSelectedIndex } from 'uiSrc/slices/browser/redisearch' +import { bufferToString, stringToBuffer } from 'uiSrc/utils' +import RediSearchIndexesList, { Props } from './RediSearchIndexesList' + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +const mockedProps = mock() + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn() +})) + +jest.mock('uiSrc/slices/browser/keys', () => ({ + ...jest.requireActual('uiSrc/slices/browser/keys'), + keysSelector: jest.fn().mockReturnValue({ + searchMode: 'Redisearch', + }), +})) + +jest.mock('uiSrc/slices/browser/redisearch', () => ({ + ...jest.requireActual('uiSrc/slices/browser/redisearch'), + redisearchListSelector: jest.fn().mockReturnValue({ + data: [], + loading: false, + error: '', + }), +})) + +describe('RediSearchIndexesList', () => { + beforeEach(() => { + const state: any = store.getState(); + + (useSelector as jest.Mock).mockImplementation((callback: (arg0: any) => any) => callback({ + ...state, + browser: { + ...state.browser, + keys: { + ...state.browser.keys, + searchMode: 'Redisearch', + }, + redisearch: { ...state.browser.redisearch, loading: false } + } + })) + }) + + it('should render', () => { + expect(render()).toBeTruthy() + const searchInput = screen.getByTestId('select-search-mode') + expect(searchInput).toBeInTheDocument() + }) + + it('"loadList" should be called after render', () => { + render( + + ) + + const expectedActions = [ + loadList() + ] + expect(clearStoreActions(store.getActions())).toEqual( + clearStoreActions(expectedActions) + ) + }) + + it('"onCreateIndex" should be called after click Create Index', () => { + const onCreateIndexMock = jest.fn() + const { queryByText } = render( + + ) + + fireEvent.click(screen.getByTestId('select-search-mode')) + fireEvent.click(queryByText('Create Index') || document) + + expect(onCreateIndexMock).toBeCalled() + }) + + it('"setSelectedIndex" and "loadKeys" should be called after select Index', () => { + const index = stringToBuffer('idx'); + + (redisearchListSelector as jest.Mock).mockReturnValue({ + data: [index], + loading: false, + error: '', + }) + + const { queryByText } = render( + + ) + + fireEvent.click(screen.getByTestId('select-search-mode')) + fireEvent.click(queryByText(bufferToString(index)) || document) + + const expectedActions = [ + loadList(), + setSelectedIndex(index), + loadKeys(), + ] + expect(clearStoreActions(store.getActions())).toEqual( + clearStoreActions(expectedActions) + ) + }) +}) diff --git a/redisinsight/ui/src/pages/browser/components/redisearch-key-list/RediSearchIndexesList.tsx b/redisinsight/ui/src/pages/browser/components/redisearch-key-list/RediSearchIndexesList.tsx new file mode 100644 index 0000000000..3fe56a1b9c --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/redisearch-key-list/RediSearchIndexesList.tsx @@ -0,0 +1,141 @@ +import { + EuiButtonEmpty, + EuiOutsideClickDetector, + EuiSuperSelect, + EuiSuperSelectOption, +} from '@elastic/eui' +import cx from 'classnames' +import React, { useEffect, useState } from 'react' +import { isString } from 'lodash' +import { useDispatch, useSelector } from 'react-redux' + +import { + setSelectedIndex, + redisearchSelector, + redisearchListSelector, + fetchRedisearchListAction, +} from 'uiSrc/slices/browser/redisearch' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { KeyViewType } from 'uiSrc/slices/interfaces/keys' +import { fetchKeys, keysSelector } from 'uiSrc/slices/browser/keys' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { bufferToString, formatLongName, Nullable } from 'uiSrc/utils' +import { SCAN_COUNT_DEFAULT, SCAN_TREE_COUNT_DEFAULT } from 'uiSrc/constants/api' + +import styles from './styles.module.scss' + +export const CREATE = 'create' + +export interface Props { + onCreateIndex: (value: boolean) => void +} + +const RediSearchIndexesList = (props: Props) => { + const { onCreateIndex } = props + + const { viewType, searchMode } = useSelector(keysSelector) + const { selectedIndex = '' } = useSelector(redisearchSelector) + const { data: list = [], loading } = useSelector(redisearchListSelector) + const { id: instanceId } = useSelector(connectedInstanceSelector) + + const [isSelectOpen, setIsSelectOpen] = useState(false) + const [index, setIndex] = useState>(JSON.stringify(selectedIndex)) + + const dispatch = useDispatch() + + useEffect(() => { + setIndex(JSON.stringify(selectedIndex || '')) + }, [selectedIndex]) + + useEffect(() => { + dispatch(fetchRedisearchListAction()) + }, []) + + const options: EuiSuperSelectOption[] = list.map( + (index) => { + const value = formatLongName(bufferToString(index)) + + return { + value: JSON.stringify(index), + inputDisplay: value, + dropdownDisplay: value, + 'data-test-subj': `mode-option-type-${value}`, + } + } + ) + + options.unshift({ + value: JSON.stringify(CREATE), + inputDisplay: CREATE, + dropdownDisplay: ( +
Create Index
+ ) + }) + + const onChangeIndex = (initValue: string) => { + const value = JSON.parse(initValue) + + if (isString(value) && value === CREATE) { + onCreateIndex(true) + setIsSelectOpen(false) + + sendEventTelemetry({ + event: TelemetryEvent.SEARCH_INDEX_ADD_BUTTON_CLICKED, + eventData: { + databaseId: instanceId + } + }) + + return + } + + setIndex(initValue) + setIsSelectOpen(false) + + dispatch(setSelectedIndex(value)) + dispatch(fetchKeys( + searchMode, + '0', + viewType === KeyViewType.Browser ? SCAN_COUNT_DEFAULT : SCAN_TREE_COUNT_DEFAULT, + )) + + sendEventTelemetry({ + event: TelemetryEvent.SEARCH_INDEX_ADD_BUTTON_CLICKED, + eventData: { + databaseId: instanceId, + totalNumberOfIndexes: list.length + } + }) + } + + return ( + setIsSelectOpen(false)} + > +
+ + {!selectedIndex && ( + setIsSelectOpen(true)} + data-testid="select-index-placeholder" + > + Select Index + + )} +
+
+ ) +} + +export default RediSearchIndexesList diff --git a/redisinsight/ui/src/pages/browser/components/redisearch-key-list/index.ts b/redisinsight/ui/src/pages/browser/components/redisearch-key-list/index.ts new file mode 100644 index 0000000000..a207f24f09 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/redisearch-key-list/index.ts @@ -0,0 +1,3 @@ +import RediSearchIndexesList from './RediSearchIndexesList' + +export default RediSearchIndexesList diff --git a/redisinsight/ui/src/pages/browser/components/redisearch-key-list/styles.module.scss b/redisinsight/ui/src/pages/browser/components/redisearch-key-list/styles.module.scss new file mode 100644 index 0000000000..7f99c62fc0 --- /dev/null +++ b/redisinsight/ui/src/pages/browser/components/redisearch-key-list/styles.module.scss @@ -0,0 +1,142 @@ +.container { + height: 36px; + width: 154px; + min-width: 80px; + overflow: hidden; + position: relative; + + :global { + .euiFormControlLayout { + .euiSuperSelectControl { + align-items: center; + height: 36px !important; + background-color: var(--browserTableRowEven) !important; + box-shadow: none !important; + border: 1px solid var(--separatorColor) !important; + border-radius: 4px; + padding: 0 25px 0 10px !important; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + &.euiSuperSelect--isOpen__button { + background-color: var(--browserTableRowEven) !important; + } + + &:focus { + background-color: var(--browserTableRowEven) !important; + } + &:not(:disabled):hover { + background-color: var(--hoverInListColorDarken) !important; + } + } + } + .euiPopover:not(.euiSuperSelect) { + position: absolute; + z-index: 10; + top: 10px; + + svg { + width: 18px !important; + height: 18px !important; + } + } + .euiPopover.euiPopover-isOpen { + .euiIcon { + color: var(--euiTextSubduedColorHover); + } + } + .euiFormControlLayoutIcons { + z-index: 6; + + .euiLoadingSpinner { + margin-right: -22px; + } + + .euiIcon { + width: 16px; + height: 12px; + } + } + } +} + +.searchMode { + position: relative; + line-height: 14px !important; + height: auto; + padding: 8px 4px !important; + background-color: var(--browserTableRowEven); + word-break: break-word; + + &:hover, + &:focus { + background-color: var(--hoverInListColorDarken) !important; + } + + :global { + .euiContextMenu__icon { + margin-left: 8px; + margin-right: 2px; + width: 16px; + height: 14px; + } + } +} + +.controlsIcon { + cursor: pointer; + margin-left: 6px; + height: 16px !important; + width: 16px !important; + &:global(.euiIcon) { + color: var(--inputTextColor) !important; + } +} + +.createIndexBtn { + display: flex; + align-items: center; + justify-content: center; + + margin-left: -28px; + border-bottom: 1px solid var(--separatorColor); + position: absolute; + width: 100%; + height: 100%; + top: 0; + + text-decoration: underline; + cursor: pointer; +} + +.placeholder { + position: absolute !important; + font-size: 14px !important; + z-index: 5; + width: 100%; + height: 36px !important; + top: 0; + left: 0; + transform: none !important; + + :global { + .euiButtonContent.euiButtonEmpty__content { + padding-left: 16px !important; + padding-right: 30px; + justify-content: left; + + .euiButtonEmpty__text { + padding-right: 10px; + text-overflow: initial !important; + color: var(--inputPlaceholderColor) !important; + } + } + } + + &:hover, + &:active { + transform: none !important; + } +} diff --git a/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.spec.tsx b/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.spec.tsx index fb77c175fd..eb7c12970e 100644 --- a/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.spec.tsx +++ b/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.spec.tsx @@ -9,7 +9,7 @@ import { render, screen, } from 'uiSrc/utils/test-utils' -import { loadKeys, setSearchMatch } from 'uiSrc/slices/browser/keys' +import { loadKeys, setPatternSearchMatch } from 'uiSrc/slices/browser/keys' import { resetBrowserTree } from 'uiSrc/slices/app/context' import SearchKeyList from './SearchKeyList' @@ -38,7 +38,7 @@ describe('SearchKeyList', () => { fireEvent.keyDown(screen.getByTestId('search-key'), { key: keys.ENTER }) - const expectedActions = [setSearchMatch(searchTerm), resetBrowserTree(), loadKeys()] + const expectedActions = [setPatternSearchMatch(searchTerm), resetBrowserTree(), loadKeys()] expect(clearStoreActions(store.getActions())).toEqual( clearStoreActions(expectedActions) diff --git a/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx b/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx index 853b7ec317..ec2bb61d66 100644 --- a/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx +++ b/redisinsight/ui/src/pages/browser/components/search-key-list/SearchKeyList.tsx @@ -1,32 +1,49 @@ import { keys } from '@elastic/eui' import React, { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' +import cx from 'classnames' + import MultiSearch from 'uiSrc/components/multi-search/MultiSearch' import { SCAN_COUNT_DEFAULT, SCAN_TREE_COUNT_DEFAULT } from 'uiSrc/constants/api' import { replaceSpaces } from 'uiSrc/utils' import { fetchKeys, keysSelector, setFilter, setSearchMatch } from 'uiSrc/slices/browser/keys' import { resetBrowserTree } from 'uiSrc/slices/app/context' -import { KeyViewType } from 'uiSrc/slices/interfaces/keys' +import { SearchMode, KeyViewType } from 'uiSrc/slices/interfaces/keys' +import { redisearchSelector } from 'uiSrc/slices/browser/redisearch' import styles from './styles.module.scss' +const placeholders = { + [SearchMode.Pattern]: 'Filter by Key Name or Pattern', + [SearchMode.Redisearch]: 'Search per Values of Keys', +} + const SearchKeyList = () => { const dispatch = useDispatch() - const { search = '', viewType, filter } = useSelector(keysSelector) + const { search, viewType, filter, searchMode } = useSelector(keysSelector) + const { search: redisearchQuery } = useSelector(redisearchSelector) const [options, setOptions] = useState(filter ? [filter] : []) - const [value, setValue] = useState(search) + const [value, setValue] = useState(search || '') useEffect(() => { setOptions(filter ? [filter] : []) }, [filter]) + useEffect(() => { + setValue(searchMode === SearchMode.Pattern ? search : redisearchQuery) + }, [searchMode, search, redisearchQuery]) + const handleApply = (match = value) => { - dispatch(setSearchMatch(match)) + dispatch(setSearchMatch(match, searchMode)) // reset browser tree context dispatch(resetBrowserTree()) - dispatch(fetchKeys('0', viewType === KeyViewType.Browser ? SCAN_COUNT_DEFAULT : SCAN_TREE_COUNT_DEFAULT)) + dispatch(fetchKeys( + searchMode, + '0', + viewType === KeyViewType.Browser ? SCAN_COUNT_DEFAULT : SCAN_TREE_COUNT_DEFAULT + )) } const handleChangeValue = (initValue: string) => { @@ -52,7 +69,7 @@ const SearchKeyList = () => { } return ( -
+
{ onChange={handleChangeValue} onChangeOptions={handleChangeOptions} onClear={onClear} - options={options} - placeholder="Filter by Key Name or Pattern" + options={searchMode === SearchMode.Pattern ? options : []} + placeholder={placeholders[searchMode]} className={styles.input} data-testid="search-key" /> diff --git a/redisinsight/ui/src/pages/browser/components/search-key-list/styles.module.scss b/redisinsight/ui/src/pages/browser/components/search-key-list/styles.module.scss index f95651f831..2546da7084 100644 --- a/redisinsight/ui/src/pages/browser/components/search-key-list/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/components/search-key-list/styles.module.scss @@ -4,6 +4,14 @@ margin-left: 48px; position: relative; + &.redisearchMode { + margin-left: 8px; + + > div > div { + border-radius: 4px 0 0 4px; + } + } + :global(.euiFormControlLayout) { max-width: calc(100%) !important; height: 36px !important; diff --git a/redisinsight/ui/src/pages/browser/styles.module.scss b/redisinsight/ui/src/pages/browser/styles.module.scss index 946fb45ccb..7d52a15f4f 100644 --- a/redisinsight/ui/src/pages/browser/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/styles.module.scss @@ -97,8 +97,3 @@ $breakpoint-to-hide-resize-panel: 1124px; } } -.leftPanelContent { - display: flex; - flex-direction: column; - height: 100%; -} diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/top-keys/Table.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/top-keys/Table.tsx index f5eff4bf17..eda469b676 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/top-keys/Table.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/top-keys/Table.tsx @@ -22,9 +22,9 @@ import { } from 'uiSrc/utils' import { numberWithSpaces } from 'uiSrc/utils/numbers' import { GroupBadge } from 'uiSrc/components' -import { setFilter, setSearchMatch, resetKeysData, fetchKeys, keysSelector } from 'uiSrc/slices/browser/keys' +import { setFilter, setSearchMatch, resetKeysData, fetchKeys, keysSelector, changeSearchMode } from 'uiSrc/slices/browser/keys' import { SCAN_COUNT_DEFAULT, SCAN_TREE_COUNT_DEFAULT } from 'uiSrc/constants/api' -import { KeyViewType } from 'uiSrc/slices/interfaces/keys' +import { KeyViewType, SearchMode } from 'uiSrc/slices/interfaces/keys' import { setBrowserKeyListDataLoaded, setBrowserSelectedKey, resetBrowserTree, setBrowserTreeDelimiter } from 'uiSrc/slices/app/context' import { Pages } from 'uiSrc/constants' import { Key } from 'apiSrc/modules/database-analysis/models/key' @@ -50,11 +50,13 @@ const Table = (props: Props) => { const { viewType } = useSelector(keysSelector) const handleRedirect = (name: string) => { + dispatch(changeSearchMode(SearchMode.Pattern)) dispatch(setBrowserTreeDelimiter(delimiter)) dispatch(setFilter(null)) - dispatch(setSearchMatch(name)) + dispatch(setSearchMatch(name, SearchMode.Pattern)) dispatch(resetKeysData()) dispatch(fetchKeys( + SearchMode.Pattern, '0', viewType === KeyViewType.Browser ? SCAN_COUNT_DEFAULT : SCAN_TREE_COUNT_DEFAULT, () => dispatch(setBrowserKeyListDataLoaded(true)), diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/top-namespace/Table.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/top-namespace/Table.tsx index f6cc80db5e..3ea871152c 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/top-namespace/Table.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/top-namespace/Table.tsx @@ -16,9 +16,9 @@ import { extrapolate, formatBytes, formatExtrapolation, formatLongName, Nullable import { numberWithSpaces } from 'uiSrc/utils/numbers' import { GroupBadge } from 'uiSrc/components' import { Pages } from 'uiSrc/constants' -import { setFilter, setSearchMatch, resetKeysData, fetchKeys, keysSelector } from 'uiSrc/slices/browser/keys' +import { setFilter, setSearchMatch, resetKeysData, fetchKeys, keysSelector, changeSearchMode } from 'uiSrc/slices/browser/keys' import { SCAN_COUNT_DEFAULT, SCAN_TREE_COUNT_DEFAULT } from 'uiSrc/constants/api' -import { KeyViewType } from 'uiSrc/slices/interfaces/keys' +import { KeyViewType, SearchMode } from 'uiSrc/slices/interfaces/keys' import { setBrowserTreeDelimiter, setBrowserKeyListDataLoaded, resetBrowserTree } from 'uiSrc/slices/app/context' import { NspSummary } from 'apiSrc/modules/database-analysis/models/nsp-summary' import { NspTypeSummary } from 'apiSrc/modules/database-analysis/models/nsp-type-summary' @@ -62,11 +62,13 @@ const NameSpacesTable = (props: Props) => { }, [isExtrapolated]) const handleRedirect = (nsp: string, filter: string) => { + dispatch(changeSearchMode(SearchMode.Pattern)) dispatch(setBrowserTreeDelimiter(delimiter)) dispatch(setFilter(filter)) - dispatch(setSearchMatch(`${nsp}${delimiter}*`)) + dispatch(setSearchMatch(`${nsp}${delimiter}*`, SearchMode.Pattern)) dispatch(resetKeysData()) dispatch(fetchKeys( + SearchMode.Pattern, '0', viewType === KeyViewType.Browser ? SCAN_COUNT_DEFAULT : SCAN_TREE_COUNT_DEFAULT, () => dispatch(setBrowserKeyListDataLoaded(true)), diff --git a/redisinsight/ui/src/pages/instance/InstancePage.tsx b/redisinsight/ui/src/pages/instance/InstancePage.tsx index cf1f7c66f1..161236e844 100644 --- a/redisinsight/ui/src/pages/instance/InstancePage.tsx +++ b/redisinsight/ui/src/pages/instance/InstancePage.tsx @@ -27,6 +27,7 @@ import { setInitialPubSubState } from 'uiSrc/slices/pubsub/pubsub' import { setBulkActionsInitialState } from 'uiSrc/slices/browser/bulkActions' import { setClusterDetailsInitialState } from 'uiSrc/slices/analytics/clusterDetails' import { setDatabaseAnalysisInitialState } from 'uiSrc/slices/analytics/dbAnalysis' +import { setRedisearchInitialState } from 'uiSrc/slices/browser/redisearch' import InstancePageRouter from './InstancePageRouter' import styles from './styles.module.scss' @@ -99,6 +100,7 @@ const InstancePage = ({ routes = [] }: Props) => { dispatch(setClusterDetailsInitialState()) dispatch(setDatabaseAnalysisInitialState()) dispatch(setInitialAnalyticsSettings()) + dispatch(setRedisearchInitialState()) setTimeout(() => { dispatch(resetOutput()) }, 0) diff --git a/redisinsight/ui/src/slices/browser/bulkActions.ts b/redisinsight/ui/src/slices/browser/bulkActions.ts index 09195e3ccf..bf9e4687eb 100644 --- a/redisinsight/ui/src/slices/browser/bulkActions.ts +++ b/redisinsight/ui/src/slices/browser/bulkActions.ts @@ -1,4 +1,4 @@ -import { createSlice, PayloadAction, SliceCaseReducers } from '@reduxjs/toolkit' +import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { BulkActionsType, MAX_BULK_ACTION_ERRORS_LENGTH } from 'uiSrc/constants' import { IBulkActionOverview } from 'apiSrc/modules/bulk-actions/interfaces/bulk-action-overview.interface' @@ -20,7 +20,7 @@ export const initialState: StateBulkActions = { } // A slice for recipes -const bulkActionsSlice = createSlice>({ +const bulkActionsSlice = createSlice({ name: 'bulkActions', initialState, reducers: { diff --git a/redisinsight/ui/src/slices/browser/keys.ts b/redisinsight/ui/src/slices/browser/keys.ts index a0f0548887..be94264d1b 100644 --- a/redisinsight/ui/src/slices/browser/keys.ts +++ b/redisinsight/ui/src/slices/browser/keys.ts @@ -37,8 +37,17 @@ import { fetchReJSON } from './rejson' import { setHashInitialState, fetchHashFields } from './hash' import { setListInitialState, fetchListElements } from './list' import { fetchStreamEntries, setStreamInitialState } from './stream' +import { + deleteRedisearchKeyFromList, + editRedisearchKeyFromList, + fetchMoreRedisearchKeysAction, + fetchRedisearchKeysAction, + resetRedisearchKeysData, + setLastBatchRedisearchKeys, + setQueryRedisearch, +} from './redisearch' import { addErrorNotification, addMessageNotification } from '../app/notifications' -import { KeysStore, KeyViewType } from '../interfaces/keys' +import { KeysStore, KeyViewType, SearchMode } from '../interfaces/keys' import { AppDispatch, RootState } from '../store' import { StreamViewType } from '../interfaces/stream' import { RedisResponseBuffer, RedisString } from '../interfaces' @@ -53,6 +62,7 @@ export const initialState: KeysStore = { isSearched: false, isFiltered: false, isBrowserFullScreen: false, + searchMode: SearchMode.Pattern, viewType: localStorageService?.get(BrowserStorageItem.browserViewType) ?? KeyViewType.Browser, data: { total: 0, @@ -135,7 +145,7 @@ const keysSlice = createSlice({ state.error = payload }, - setLastBatchKeys: (state, { payload }) => { + setLastBatchPatternKeys: (state, { payload }) => { const newKeys = state.data.keys newKeys.splice(-payload.length, payload.length, ...payload) state.data.keys = newKeys @@ -195,7 +205,7 @@ const keysSlice = createSlice({ error: payload, } }, - deleteKeyFromList: (state, { payload }) => { + deletePatternKeyFromList: (state, { payload }) => { remove(state.data?.keys, (key) => isEqualBuffers(key.name, payload)) state.data = { @@ -227,10 +237,11 @@ const keysSlice = createSlice({ error: payload, } }, - editKeyFromList: (state, { payload }) => { + editPatternKeyFromList: (state, { payload }) => { const keys = state.data.keys.map((key) => { if (isEqualBuffers(key.name, payload?.key)) { key.name = payload?.newKey + key.nameString = bufferToString(payload?.newKey) } return key }) @@ -274,7 +285,7 @@ const keysSlice = createSlice({ } }, - setSearchMatch: (state, { payload }) => { + setPatternSearchMatch: (state, { payload }) => { state.search = payload }, setFilter: (state, { payload }) => { @@ -285,6 +296,10 @@ const keysSlice = createSlice({ state.viewType = payload }, + changeSearchMode: (state, { payload }:{ payload: SearchMode }) => { + state.searchMode = payload + }, + resetAddKey: (state) => { state.addKey = cloneDeep(initialState.addKey) }, @@ -302,7 +317,7 @@ const keysSlice = createSlice({ } ), - resetKeysData: (state) => { + resetPatternKeysData: (state) => { // state.data.keys = [] state.data.keys.length = 0 }, @@ -338,7 +353,7 @@ export const { defaultSelectedKeyAction, defaultSelectedKeyActionSuccess, defaultSelectedKeyActionFailure, - setLastBatchKeys, + setLastBatchPatternKeys, addKey, addKeySuccess, addKeyFailure, @@ -346,17 +361,18 @@ export const { deleteKey, deleteKeySuccess, deleteKeyFailure, - deleteKeyFromList, - editKeyFromList, + deletePatternKeyFromList, + editPatternKeyFromList, updateSelectedKeyLength, - setSearchMatch, + setPatternSearchMatch, setFilter, changeKeyViewType, resetKeyInfo, resetKeys, - resetKeysData, + resetPatternKeysData, toggleBrowserFullScreen, setViewFormat, + changeSearchMode, } = keysSlice.actions // A selector @@ -389,7 +405,7 @@ export function setInitialStateByType(type: string) { } } // Asynchronous thunk action -export function fetchKeys(cursor: string, count: number, onSuccess?: () => void, onFailed?: () => void) { +export function fetchPatternKeysAction(cursor: string, count: number, onSuccess?: () => void, onFailed?: () => void) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { dispatch(loadKeys()) @@ -476,7 +492,7 @@ export function fetchKeys(cursor: string, count: number, onSuccess?: () => void, } // Asynchronous thunk action -export function fetchMoreKeys(oldKeys: IKeyPropTypes[] = [], cursor: string, count: number) { +export function fetchMorePatternKeysAction(oldKeys: IKeyPropTypes[] = [], cursor: string, count: number) { return async (dispatch: AppDispatch, stateInit: () => RootState) => { dispatch(loadMoreKeys()) @@ -872,6 +888,7 @@ export function editKeyTTL(key: string, ttl: number) { // Asynchronous thunk action export function fetchKeysMetadata( keys: RedisString[], + signal: AbortSignal, onSuccessAction?: (data: GetKeyInfoResponse[]) => void, onFailAction?: () => void ) { @@ -884,13 +901,79 @@ export function fetchKeysMetadata( ApiEndpoints.KEYS_METADATA ), { keys }, - { params: { encoding: state.app.info.encoding } } + { params: { encoding: state.app.info.encoding }, signal } ) onSuccessAction?.(data) - } catch (error) { - onFailAction?.() - console.error(error) + } catch (_err) { + if (!axios.isCancel(_err)) { + const error = _err as AxiosError + onFailAction?.() + console.error(error) + } } } } + +// Asynchronous thunk action +export function fetchKeys( + searchMode: SearchMode, + cursor: string, + count: number, + onSuccess?: () => void, + onFailed?: () => void, +) { + return searchMode === SearchMode.Pattern + ? fetchPatternKeysAction(cursor, count, onSuccess, onFailed,) + : fetchRedisearchKeysAction(cursor, count, onSuccess, onFailed,) +} + +// Asynchronous thunk action +export function fetchMoreKeys( + searchMode: SearchMode, + oldKeys: IKeyPropTypes[] = [], + cursor: string, + count: number, +) { + return searchMode === SearchMode.Pattern + ? fetchMorePatternKeysAction(oldKeys, cursor, count) + : fetchMoreRedisearchKeysAction(oldKeys, cursor, count) +} + +export function setLastBatchKeys(keys: GetKeyInfoResponse[], searchMode: SearchMode) { + return searchMode === SearchMode.Pattern + ? setLastBatchPatternKeys(keys) + : setLastBatchRedisearchKeys(keys) +} + +export function setSearchMatch(query: string, searchMode: SearchMode) { + return searchMode === SearchMode.Pattern + ? setPatternSearchMatch(query) + : setQueryRedisearch(query) +} + +export function resetKeysData(searchMode: SearchMode) { + return searchMode === SearchMode.Pattern + ? resetPatternKeysData() + : resetRedisearchKeysData() +} + +export function deleteKeyFromList(key: RedisResponseBuffer) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + const state = stateInit() + + return state.browser.keys?.searchMode === SearchMode.Pattern + ? dispatch(deletePatternKeyFromList(key)) + : dispatch(deleteRedisearchKeyFromList(key)) + } +} + +export function editKeyFromList(key: RedisResponseBuffer) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + const state = stateInit() + + return state.browser.keys?.searchMode === SearchMode.Pattern + ? dispatch(editPatternKeyFromList(key)) + : dispatch(editRedisearchKeyFromList(key)) + } +} diff --git a/redisinsight/ui/src/slices/browser/redisearch.ts b/redisinsight/ui/src/slices/browser/redisearch.ts new file mode 100644 index 0000000000..c9ce47e897 --- /dev/null +++ b/redisinsight/ui/src/slices/browser/redisearch.ts @@ -0,0 +1,390 @@ +import axios, { AxiosError } from 'axios' +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { remove } from 'lodash' + +import successMessages from 'uiSrc/components/notifications/success-messages' +import { ApiEndpoints } from 'uiSrc/constants' +import { apiService } from 'uiSrc/services' +import { bufferToString, getApiErrorMessage, getUrl, isEqualBuffers, isStatusSuccessful, Nullable } from 'uiSrc/utils' +import { DEFAULT_SEARCH_MATCH } from 'uiSrc/constants/api' +import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' +import ApiErrors from 'uiSrc/constants/apiErrors' + +import { GetKeysWithDetailsResponse } from 'apiSrc/modules/browser/dto' +import { CreateRedisearchIndexDto, ListRedisearchIndexesResponse } from 'apiSrc/modules/browser/dto/redisearch' + +import { AppDispatch, RootState } from '../store' +import { RedisResponseBuffer, StateRedisearch } from '../interfaces' +import { addErrorNotification, addMessageNotification } from '../app/notifications' + +export const initialState: StateRedisearch = { + loading: false, + error: '', + search: '', + isSearched: false, + selectedIndex: null, + data: { + total: 0, + scanned: 0, + nextCursor: '0', + keys: [], + shardsMeta: {}, + previousResultCount: 0, + lastRefreshTime: null, + }, + list: { + loading: false, + error: '', + data: [] + }, + createIndex: { + loading: false, + error: '', + }, +} + +// A slice for recipes +const redisearchSlice = createSlice({ + name: 'redisearch', + initialState, + reducers: { + setRedisearchInitialState: () => initialState, + + // load redisearch keys + loadKeys: (state) => { + state.loading = true + state.error = '' + }, + loadKeysSuccess: (state, { payload: [data, isSearched] }: PayloadAction<[GetKeysWithDetailsResponse, boolean]>) => { + state.data = { + ...state.data, + ...data, + nextCursor: `${data.cursor}`, + previousResultCount: data.keys?.length, + } + state.loading = false + state.isSearched = isSearched + state.data.lastRefreshTime = Date.now() + }, + loadKeysFailure: (state, { payload }) => { + state.error = payload + state.loading = false + }, + + // load more redisearch keys + loadMoreKeys: (state) => { + state.loading = true + state.error = '' + }, + loadMoreKeysSuccess: (state, { payload }: PayloadAction) => { + state.data.keys = payload.keys + state.data.total = payload.total + state.data.scanned = payload.scanned + state.data.nextCursor = `${payload.cursor}` + state.data.previousResultCount = payload.keys.length + + state.loading = false + }, + loadMoreKeysFailure: (state, { payload }) => { + state.loading = false + state.error = payload + }, + + // load list of indexes + loadList: (state) => { + state.list = { + ...state.list, + loading: true, + error: '', + } + }, + loadListSuccess: (state, { payload }: PayloadAction) => { + state.list = { + ...state.list, + loading: false, + data: payload, + } + }, + loadListFailure: (state, { payload }) => { + state.list = { + ...state.list, + loading: false, + error: payload, + } + }, + createIndex: (state) => { + state.createIndex = { + ...state.createIndex, + loading: true, + error: '', + } + }, + createIndexSuccess: (state) => { + state.createIndex = { + ...state.createIndex, + loading: false, + } + }, + createIndexFailure: (state, { payload }: PayloadAction) => { + state.createIndex = { + ...state.createIndex, + loading: false, + error: payload, + } + }, + + // create an index + setSelectedIndex: (state, { payload }: PayloadAction) => { + state.selectedIndex = payload + }, + + setLastBatchRedisearchKeys: (state, { payload }) => { + const newKeys = state.data.keys + newKeys.splice(-payload.length, payload.length, ...payload) + state.data.keys = newKeys + }, + + setQueryRedisearch: (state, { payload }: PayloadAction) => { + state.search = payload + }, + + resetRedisearchKeysData: (state) => { + state.data.keys.length = 0 + }, + + deleteRedisearchKeyFromList: (state, { payload }) => { + remove(state.data?.keys, (key) => isEqualBuffers(key.name, payload)) + + state.data = { + ...state.data, + total: state.data.total - 1, + scanned: state.data.scanned - 1, + } + }, + + editRedisearchKeyFromList: (state, { payload }) => { + const keys = state.data.keys.map((key) => { + if (isEqualBuffers(key.name, payload?.key)) { + key.name = payload?.newKey + key.nameString = bufferToString(payload?.newKey) + } + return key + }) + + state.data = { + ...state.data, + keys, + } + }, + }, +}) + +// Actions generated from the slice +export const { + loadKeys, + loadKeysSuccess, + loadKeysFailure, + loadMoreKeys, + loadMoreKeysSuccess, + loadMoreKeysFailure, + loadList, + loadListSuccess, + loadListFailure, + createIndex, + createIndexSuccess, + createIndexFailure, + setRedisearchInitialState, + setSelectedIndex, + setLastBatchRedisearchKeys, + setQueryRedisearch, + resetRedisearchKeysData, + deleteRedisearchKeyFromList, + editRedisearchKeyFromList, +} = redisearchSlice.actions + +// Selectors +export const redisearchSelector = (state: RootState) => state.browser.redisearch +export const redisearchDataSelector = (state: RootState) => state.browser.redisearch.data +export const redisearchListSelector = (state: RootState) => state.browser.redisearch.list +export const createIndexStateSelector = (state: RootState) => state.browser.redisearch.createIndex + +// The reducer +export default redisearchSlice.reducer + +// eslint-disable-next-line import/no-mutable-exports +export let controller: Nullable = null + +// Asynchronous thunk action +export function fetchRedisearchKeysAction( + cursor: string, + count: number, + onSuccess?: (value: GetKeysWithDetailsResponse) => void, + onFailed?: () => void, +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + dispatch(loadKeys()) + + try { + controller?.abort() + controller = new AbortController() + + const state = stateInit() + const { encoding } = state.app.info + const { selectedIndex: index, search: query } = state.browser.redisearch + const { data, status } = await apiService.post( + getUrl( + state.connections.instances.connectedInstance?.id, + ApiEndpoints.REDISEARCH_SEARCH + ), + { + offset: +cursor, limit: count, query: query || DEFAULT_SEARCH_MATCH, index, + }, + { + params: { encoding }, + signal: controller.signal, + } + ) + + controller = null + + if (isStatusSuccessful(status)) { + dispatch(loadKeysSuccess([data, !!query])) + onSuccess?.(data) + } + } catch (_err) { + if (!axios.isCancel(_err)) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(loadKeysFailure(errorMessage)) + + if (error?.response?.data?.message?.toString().endsWith(ApiErrors.RedisearchIndexNotFound)) { + dispatch(setRedisearchInitialState()) + dispatch(fetchRedisearchListAction()) + } + onFailed?.() + } + } + } +} +// Asynchronous thunk action +export function fetchMoreRedisearchKeysAction( + oldKeys: IKeyPropTypes[] = [], + cursor: string, + count: number, +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + dispatch(loadMoreKeys()) + + try { + controller?.abort() + controller = new AbortController() + + const state = stateInit() + const { encoding } = state.app.info + const { selectedIndex: index, search: query } = state.browser.redisearch + const { data, status } = await apiService.post( + getUrl( + state.connections.instances.connectedInstance?.id, + ApiEndpoints.REDISEARCH_SEARCH + ), + { + offset: +cursor, limit: count, query: query || DEFAULT_SEARCH_MATCH, index + }, + { + params: { encoding }, + signal: controller.signal, + } + ) + + controller = null + + if (isStatusSuccessful(status)) { + dispatch(loadMoreKeysSuccess({ + ...data, + keys: oldKeys.concat(data.keys) + })) + } + } catch (_err) { + if (!axios.isCancel(_err)) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(loadMoreKeysFailure(errorMessage)) + } + } + } +} + +export function fetchRedisearchListAction( + onSuccess?: (value: RedisResponseBuffer[]) => void, + onFailed?: () => void, +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + dispatch(loadList()) + + try { + const state = stateInit() + const { encoding } = state.app.info + const { data, status } = await apiService.get( + getUrl( + state.connections.instances.connectedInstance?.id, + ApiEndpoints.REDISEARCH + ), + { + params: { encoding }, + } + ) + + if (isStatusSuccessful(status)) { + dispatch(loadListSuccess(data.indexes)) + onSuccess?.(data.indexes) + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(loadListFailure(errorMessage)) + onFailed?.() + } + } +} +export function createRedisearchIndexAction( + data: CreateRedisearchIndexDto, + onSuccess?: () => void, + onFailed?: () => void, +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + dispatch(createIndex()) + + try { + const state = stateInit() + const { encoding } = state.app.info + const { status } = await apiService.post( + getUrl( + state.connections.instances.connectedInstance?.id, + ApiEndpoints.REDISEARCH + ), + { + ...data + }, + { + params: { encoding }, + } + ) + + if (isStatusSuccessful(status)) { + dispatch(createIndexSuccess()) + dispatch(addMessageNotification(successMessages.CREATE_INDEX())) + dispatch(fetchRedisearchListAction()) + onSuccess?.() + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(createIndexFailure(errorMessage)) + onFailed?.() + } + } +} diff --git a/redisinsight/ui/src/slices/interfaces/app.ts b/redisinsight/ui/src/slices/interfaces/app.ts index fdf464bea6..0b927e5ff4 100644 --- a/redisinsight/ui/src/slices/interfaces/app.ts +++ b/redisinsight/ui/src/slices/interfaces/app.ts @@ -1,8 +1,9 @@ import { AxiosError } from 'axios' import { Nullable } from 'uiSrc/utils' -import { GetServerInfoResponse } from 'apiSrc/dto/server.dto' import { ICommands } from 'uiSrc/constants' import { IKeyPropTypes } from 'uiSrc/constants/prop-types/keys' +import { GetServerInfoResponse } from 'apiSrc/dto/server.dto' +import { RedisString as RedisStringAPI } from 'apiSrc/common/constants/redis-string' export interface IError extends AxiosError { id: string @@ -168,10 +169,10 @@ export enum RedisResponseBufferType { Buffer = 'Buffer' } -export interface RedisResponseBuffer { +export type RedisResponseBuffer = { type: RedisResponseBufferType data: UintArray -} +} & Exclude export type RedisString = string | RedisResponseBuffer diff --git a/redisinsight/ui/src/slices/interfaces/index.ts b/redisinsight/ui/src/slices/interfaces/index.ts index e69a08d47a..18340801ab 100644 --- a/redisinsight/ui/src/slices/interfaces/index.ts +++ b/redisinsight/ui/src/slices/interfaces/index.ts @@ -5,3 +5,4 @@ export * from './workbench' export * from './monitor' export * from './api' export * from './bulkActions' +export * from './redisearch' diff --git a/redisinsight/ui/src/slices/interfaces/keys.ts b/redisinsight/ui/src/slices/interfaces/keys.ts index 8f17f980c8..2dc1ab32a2 100644 --- a/redisinsight/ui/src/slices/interfaces/keys.ts +++ b/redisinsight/ui/src/slices/interfaces/keys.ts @@ -16,6 +16,11 @@ export enum KeyViewType { Tree = 'Tree', } +export enum SearchMode { + Pattern = 'Pattern', + Redisearch = 'Redisearch', +} + export interface KeysStore { loading: boolean error: string @@ -25,6 +30,7 @@ export interface KeysStore { isSearched: boolean isBrowserFullScreen: boolean viewType: KeyViewType + searchMode: SearchMode data: KeysStoreData selectedKey: { loading: boolean diff --git a/redisinsight/ui/src/slices/interfaces/redisearch.ts b/redisinsight/ui/src/slices/interfaces/redisearch.ts new file mode 100644 index 0000000000..02d5082497 --- /dev/null +++ b/redisinsight/ui/src/slices/interfaces/redisearch.ts @@ -0,0 +1,21 @@ +import { Nullable } from 'uiSrc/utils' +import { RedisResponseBuffer } from './app' +import { KeysStoreData } from './keys' + +export interface StateRedisearch { + loading: boolean + error: string + search: string + isSearched: boolean + data: KeysStoreData + selectedIndex: Nullable + list: { + loading: boolean + error: string + data: RedisResponseBuffer[] + } + createIndex: { + loading: boolean + error: string + } +} diff --git a/redisinsight/ui/src/slices/store.ts b/redisinsight/ui/src/slices/store.ts index 09aa5bec8c..c397cfaa6d 100644 --- a/redisinsight/ui/src/slices/store.ts +++ b/redisinsight/ui/src/slices/store.ts @@ -35,6 +35,7 @@ import slowLogReducer from './analytics/slowlog' import analyticsSettingsReducer from './analytics/settings' import clusterDetailsReducer from './analytics/clusterDetails' import databaseAnalysisReducer from './analytics/dbAnalysis' +import redisearchReducer from './browser/redisearch' export const history = createBrowserHistory() @@ -65,6 +66,7 @@ export const rootReducer = combineReducers({ rejson: rejsonReducer, stream: streamReducer, bulkActions: bulkActionsReducer, + redisearch: redisearchReducer, }), cli: combineReducers({ settings: cliSettingsReducer, diff --git a/redisinsight/ui/src/slices/tests/browser/hash.spec.ts b/redisinsight/ui/src/slices/tests/browser/hash.spec.ts index dbf4023f51..7022652149 100644 --- a/redisinsight/ui/src/slices/tests/browser/hash.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/hash.spec.ts @@ -9,11 +9,11 @@ import { } from 'uiSrc/utils/test-utils' import successMessages from 'uiSrc/components/notifications/success-messages' import { bufferToString, stringToBuffer } from 'uiSrc/utils' +import { deleteRedisearchKeyFromList } from 'uiSrc/slices/browser/redisearch' import { defaultSelectedKeyAction, refreshKeyInfo, deleteKeySuccess, - deleteKeyFromList, updateSelectedKeyRefreshTime, } from '../../browser/keys' import reducer, { @@ -642,7 +642,7 @@ describe('hash slice', () => { removeHashFieldsSuccess(), removeFieldsFromList(fields), deleteKeySuccess(), - deleteKeyFromList(key), + deleteRedisearchKeyFromList(key), addMessageNotification(successMessages.DELETED_KEY(key)) ] diff --git a/redisinsight/ui/src/slices/tests/browser/keys.spec.ts b/redisinsight/ui/src/slices/tests/browser/keys.spec.ts index 4f78244f90..d4d0374698 100644 --- a/redisinsight/ui/src/slices/tests/browser/keys.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/keys.spec.ts @@ -6,6 +6,7 @@ import { parseKeysListResponse, stringToBuffer } from 'uiSrc/utils' import { cleanup, initialStateDefault, mockedStore } from 'uiSrc/utils/test-utils' import { addErrorNotification, addMessageNotification } from 'uiSrc/slices/app/notifications' import successMessages from 'uiSrc/components/notifications/success-messages' +import { SearchMode } from 'uiSrc/slices/interfaces/keys' import { CreateHashWithExpireDto, CreateListWithExpireDto, @@ -41,8 +42,8 @@ import reducer, { deleteKey, deleteKeySuccess, deleteKeyFailure, - deleteKeyFromList, - editKeyFromList, + deletePatternKeyFromList, + editPatternKeyFromList, defaultSelectedKeyActionSuccess, editKey, defaultSelectedKeyActionFailure, @@ -53,7 +54,7 @@ import reducer, { addListKey, addStringKey, addZsetKey, - setLastBatchKeys, + setLastBatchPatternKeys, updateSelectedKeyRefreshTime, resetKeyInfo, resetKeys, @@ -401,7 +402,7 @@ describe('keys slice', () => { } // Act - const nextState = reducer(prevState, setLastBatchKeys(data)) + const nextState = reducer(prevState, setLastBatchPatternKeys(data)) // Assert const rootState = Object.assign(initialStateDefault, { @@ -716,7 +717,7 @@ describe('keys slice', () => { }) }) - describe('editKeyFromList', () => { + describe('editPatternKeyFromList', () => { it('should properly set the state before the edit key', () => { // Arrange @@ -734,12 +735,12 @@ describe('keys slice', () => { const state = { ...initialState, data: { - keys: [{ name: data.newKey }], + keys: [{ name: data.newKey, nameString: data.newKey }], }, } // Act - const nextState = reducer(initialStateMock, editKeyFromList(data)) + const nextState = reducer(initialStateMock, editPatternKeyFromList(data)) // Assert const rootState = Object.assign(initialStateDefault, { @@ -834,7 +835,7 @@ describe('keys slice', () => { apiService.post = jest.fn().mockResolvedValue(responsePayload) // Act - await store.dispatch(fetchKeys(0, 20)) + await store.dispatch(fetchKeys(SearchMode.Pattern, 0, 20)) // Assert const expectedActions = [ @@ -861,7 +862,7 @@ describe('keys slice', () => { apiService.post = jest.fn().mockRejectedValue(responsePayload) // Act - await store.dispatch(fetchKeys('0', 20)) + await store.dispatch(fetchKeys(SearchMode.Pattern, '0', 20)) // Assert const expectedActions = [ @@ -912,7 +913,7 @@ describe('keys slice', () => { apiService.post = jest.fn().mockResolvedValue(responsePayload) // Act - await store.dispatch(fetchMoreKeys([], '0', 20)) + await store.dispatch(fetchMoreKeys(SearchMode.Pattern, [], '0', 20)) // Assert const expectedActions = [ @@ -935,7 +936,7 @@ describe('keys slice', () => { apiService.post = jest.fn().mockRejectedValue(responsePayload) // Act - await store.dispatch(fetchMoreKeys('0', 20)) + await store.dispatch(fetchMoreKeys(SearchMode.Pattern, [], '0', 20)) // Assert const expectedActions = [ @@ -1195,7 +1196,7 @@ describe('keys slice', () => { }) describe('deleteKey', () => { - it('call both deleteKey, deleteKeySuccess and deleteKeyFromList when delete is successed', async () => { + it('call both deleteKey, deleteKeySuccess and deletePatternKeyFromList when delete is successed', async () => { // Arrange const data = { name: 'string', @@ -1214,7 +1215,7 @@ describe('keys slice', () => { const expectedActions = [ deleteKey(), deleteKeySuccess(), - deleteKeyFromList(data.name), + deletePatternKeyFromList(data.name), addMessageNotification(successMessages.DELETED_KEY(data.name)), ] expect(store.getActions()).toEqual(expectedActions) @@ -1222,7 +1223,7 @@ describe('keys slice', () => { }) describe('editKey', () => { - it('call both editKey, editKeySuccess and editKeyFromList when editing is successed', async () => { + it('call both editKey, editKeySuccess and editPatternKeyFromList when editing is successed', async () => { // Arrange const key = 'string' const newKey = 'string2' @@ -1234,7 +1235,7 @@ describe('keys slice', () => { await store.dispatch(editKey(key, newKey)) // Assert - const expectedActions = [defaultSelectedKeyAction(), editKeyFromList({ key, newKey })] + const expectedActions = [defaultSelectedKeyAction(), editPatternKeyFromList({ key, newKey })] expect(store.getActions()).toEqual(expectedActions) }) }) @@ -1276,7 +1277,7 @@ describe('keys slice', () => { const expectedActions = [ defaultSelectedKeyAction(), deleteKeySuccess(), - deleteKeyFromList(key), + deletePatternKeyFromList(key), defaultSelectedKeyActionSuccess(), ] expect(store.getActions()).toEqual(expectedActions) @@ -1314,11 +1315,13 @@ describe('keys slice', () => { const apiServiceMock = jest.fn().mockResolvedValue(responsePayload) const onSuccessMock = jest.fn() apiService.post = apiServiceMock + const controller = new AbortController() // Act await store.dispatch( fetchKeysMetadata( data.map(({ name }) => ({ name })), + controller.signal, onSuccessMock ) ) @@ -1327,7 +1330,7 @@ describe('keys slice', () => { expect(apiServiceMock).toBeCalledWith( '/instance//keys/get-metadata', { keys: data.map(({ name }) => ({ name })) }, - { params: { encoding: 'buffer' } }, + { params: { encoding: 'buffer' }, signal: controller.signal }, ) expect(onSuccessMock).toBeCalledWith(data) diff --git a/redisinsight/ui/src/slices/tests/browser/list.spec.ts b/redisinsight/ui/src/slices/tests/browser/list.spec.ts index 7bb2cac625..88ed6d740a 100644 --- a/redisinsight/ui/src/slices/tests/browser/list.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/list.spec.ts @@ -8,11 +8,11 @@ import { mockStore, } from 'uiSrc/utils/test-utils' import successMessages from 'uiSrc/components/notifications/success-messages' -import { DeleteListElementsDto, PushElementToListDto } from 'apiSrc/modules/browser/dto' import { stringToBuffer } from 'uiSrc/utils' +import { deleteRedisearchKeyFromList } from 'uiSrc/slices/browser/redisearch' +import { DeleteListElementsDto, PushElementToListDto } from 'apiSrc/modules/browser/dto' import { defaultSelectedKeyAction, - deleteKeyFromList, deleteKeySuccess, refreshKeyInfo, updateSelectedKeyRefreshTime, @@ -866,7 +866,7 @@ describe('list slice', () => { deleteListElements(), deleteListElementsSuccess(), deleteKeySuccess(), - deleteKeyFromList(data.keyName), + deleteRedisearchKeyFromList(data.keyName), addMessageNotification(successMessages.DELETED_KEY(data.keyName)) ] diff --git a/redisinsight/ui/src/slices/tests/browser/redisearch.spec.ts b/redisinsight/ui/src/slices/tests/browser/redisearch.spec.ts new file mode 100644 index 0000000000..c5ef3868f9 --- /dev/null +++ b/redisinsight/ui/src/slices/tests/browser/redisearch.spec.ts @@ -0,0 +1,901 @@ +import { AxiosError } from 'axios' +import { cloneDeep, omit } from 'lodash' +import successMessages from 'uiSrc/components/notifications/success-messages' +import { apiService } from 'uiSrc/services' +import { cleanup, initialStateDefault, mockedStore } from 'uiSrc/utils/test-utils' +import { addErrorNotification, addMessageNotification } from 'uiSrc/slices/app/notifications' +import { stringToBuffer } from 'uiSrc/utils' +import { REDISEARCH_LIST_DATA_MOCK } from 'uiSrc/mocks/handlers/browser/redisearchHandlers' +import { SearchMode } from 'uiSrc/slices/interfaces/keys' +import { fetchKeys, fetchMoreKeys } from 'uiSrc/slices/browser/keys' +import reducer, { + initialState, + loadKeys, + loadKeysSuccess, + loadKeysFailure, + loadMoreKeys, + loadMoreKeysSuccess, + loadMoreKeysFailure, + loadList, + loadListSuccess, + loadListFailure, + setSelectedIndex, + setLastBatchRedisearchKeys, + setQueryRedisearch, + createIndex, + createIndexSuccess, + createIndexFailure, + fetchRedisearchListAction, + createRedisearchIndexAction, + redisearchDataSelector, + redisearchSelector, + setRedisearchInitialState, + resetRedisearchKeysData, + deleteRedisearchKeyFromList, + editRedisearchKeyFromList, +} from '../../browser/redisearch' + +let store: typeof mockedStore +let dateNow: jest.SpyInstance +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('uiSrc/services', () => ({ + ...jest.requireActual('uiSrc/services'), +})) + +describe('redisearch slice', () => { + beforeAll(() => { + dateNow = jest.spyOn(Date, 'now').mockImplementation(() => 1629128049027) + }) + + afterAll(() => { + dateNow.mockRestore() + }) + + describe('reducer, actions and selectors', () => { + it('should return the initial state on first run', () => { + // Arrange + const nextState = initialState + + // Act + const result = reducer(undefined, {}) + + // Assert + expect(result).toEqual(nextState) + }) + }) + + describe('loadKeys', () => { + it('should properly set the state before the fetch data', () => { + // Arrange + const state = { + ...initialState, + loading: true, + } + + // Act + const nextState = reducer(initialState, loadKeys()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { + redisearch: nextState, + }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + expect(redisearchDataSelector(rootState)).toEqual(state.data) + }) + }) + + describe('loadKeysSuccess', () => { + it('should properly set the state with fetched data', () => { + // Arrange + const data = { + total: 249, + cursor: 228, + scanned: 228, + keys: [ + { + name: stringToBuffer('bull:mail-queue:155'), + type: 'hash', + ttl: 2147474450, + size: 3041, + }, + { + name: stringToBuffer('bull:mail-queue:223'), + type: 'hash', + ttl: -1, + size: 3041, + }, + ], + } + + const state = { + ...initialState, + loading: false, + error: '', + data: { + ...data, + shardsMeta: {}, + nextCursor: '228', + lastRefreshTime: Date.now(), + previousResultCount: data.keys.length, + }, + } + + // Act + const nextState = reducer( + initialState, + loadKeysSuccess([data, false]) + ) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { redisearch: nextState }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + }) + }) + + describe('loadKeysFailure', () => { + it('should properly set the error', () => { + // Arrange + const data = 'some error' + const state = { + ...initialState, + loading: false, + error: data, + data: { + ...initialState.data, + keys: [], + nextCursor: '0', + total: 0, + scanned: 0, + shardsMeta: {}, + previousResultCount: 0, + }, + } + + // Act + const nextState = reducer(initialState, loadKeysFailure(data)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { redisearch: nextState }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + }) + }) + + describe('loadMoreKeys', () => { + it('should properly set the state before the fetch data', () => { + // Arrange + const state = { + ...initialState, + loading: true, + error: '', + data: { + ...initialState.data, + keys: [], + nextCursor: '0', + total: 0, + scanned: 0, + shardsMeta: {}, + previousResultCount: 0, + }, + } + + // Act + const nextState = reducer(initialState, loadMoreKeys()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { redisearch: nextState }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + }) + }) + + describe('loadMoreKeysSuccess', () => { + it('should properly set the state with fetched data', () => { + // Arrange + + const data = { + total: 0, + cursor: 0, + shardsMeta: {}, + scanned: 0, + keys: [ + { + name: stringToBuffer('bull:mail-queue:155'), + type: 'hash', + ttl: 2147474450, + size: 3041, + }, + { + name: stringToBuffer('bull:mail-queue:223'), + type: 'hash', + ttl: -1, + size: 3041, + }, + ], + } + + const state = { + ...initialState, + loading: false, + error: '', + data: { + ...omit(data, 'cursor'), + nextCursor: `${data.cursor}`, + previousResultCount: data.keys.length, + lastRefreshTime: initialState.data.lastRefreshTime + }, + } + + // Act + const nextState = reducer(initialState, loadMoreKeysSuccess(data)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { redisearch: nextState }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + }) + + it('should properly set the state with empty data', () => { + // Arrange + const data = { + total: 0, + cursor: '0', + keys: [], + scanned: 0, + shardsMeta: {}, + } + + // Act + const nextState = reducer(initialState, loadMoreKeysSuccess(data)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { redisearch: nextState }, + }) + expect(redisearchSelector(rootState)).toEqual(initialState) + }) + }) + + describe('loadMoreKeysFailure', () => { + it('should properly set the error', () => { + // Arrange + const data = 'some error' + const state = { + ...initialState, + loading: false, + error: data, + data: { + ...initialState.data, + keys: [], + nextCursor: '0', + total: 0, + scanned: 0, + shardsMeta: {}, + previousResultCount: 0, + }, + } + + // Act + const nextState = reducer(initialState, loadMoreKeysFailure(data)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { redisearch: nextState }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + }) + }) + + describe('loadList', () => { + it('should properly set the state before the fetch data', () => { + // Arrange + const state = { + ...initialState, + list: { + ...initialState.list, + loading: true, + } + } + + // Act + const nextState = reducer(initialState, loadList()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { + redisearch: nextState, + }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + expect(redisearchDataSelector(rootState)).toEqual(state.data) + }) + }) + + describe('loadListSuccess', () => { + it('should properly set the state with fetched data', () => { + // Arrange + const data = REDISEARCH_LIST_DATA_MOCK + const state = { + ...initialState, + list: { + data, + error: '', + loading: false, + } + } + + // Act + const nextState = reducer(initialState, loadListSuccess(data)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { + redisearch: nextState, + }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + expect(redisearchDataSelector(rootState)).toEqual(state.data) + }) + + it('should properly set the state with empty data', () => { + // Arrange + const data: any[] = [] + + const state = { + ...initialState, + list: { + data, + error: '', + loading: false, + } + } + + // Act + const nextState = reducer(initialState, loadListSuccess(data)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { + redisearch: nextState, + }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + expect(redisearchDataSelector(rootState)).toEqual(state.data) + }) + }) + + describe('loadListFailure', () => { + it('should properly set the error', () => { + // Arrange + const data = 'some error' + const state = { + ...initialState, + list: { + data: [], + loading: false, + error: data, + } + } + + // Act + const nextState = reducer(initialState, loadListFailure(data)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { + redisearch: nextState, + }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + expect(redisearchDataSelector(rootState)).toEqual(state.data) + }) + }) + + describe('setSelectedIndex', () => { + it('should properly set the selected index', () => { + // Arrange + + const index = stringToBuffer('idx') + const state = { + ...initialState, + selectedIndex: index, + } + + // Act + const nextState = reducer(initialState, setSelectedIndex(index)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { redisearch: nextState }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + }) + }) + + describe('setQueryRedisearch', () => { + it('should properly set the selected index', () => { + // Arrange + + const query = 'query' + const state = { + ...initialState, + search: query, + } + + // Act + const nextState = reducer(initialState, setQueryRedisearch(query)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { redisearch: nextState }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + }) + }) + + describe('setLastBatchKeys', () => { + it('should properly set the state', () => { + // Arrange + const strToKey = (name:string) => ({ name, nameString: name, ttl: 1, size: 1, type: 'hash' }) + const data = ['44', '55', '66'].map(strToKey) + + const state = { + ...initialState, + data: { + ...initialState.data, + keys: ['1', '2', '3', '44', '55', '66'].map(strToKey), + } + } + + const prevState = { + ...initialState, + data: { + ...initialState.data, + keys: ['1', '2', '3', '4', '5', '6'].map(strToKey), + } + } + + // Act + const nextState = reducer(prevState, setLastBatchRedisearchKeys(data)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { redisearch: nextState }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + }) + }) + + describe('createIndex', () => { + it('should properly set the state before the fetch data', () => { + // Arrange + const state = { + ...initialState, + createIndex: { + ...initialState.createIndex, + loading: true, + error: '' + } + } + + // Act + const nextState = reducer(initialState, createIndex()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { + redisearch: nextState, + }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + }) + }) + + describe('createIndexSuccess', () => { + it('should properly set the state', () => { + // Arrange + const state = { + ...initialState, + createIndex: { + ...initialState.createIndex, + loading: false, + } + } + + // Act + const nextState = reducer( + initialState, + createIndexSuccess() + ) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { redisearch: nextState }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + }) + }) + + describe('loadKeysFailure', () => { + it('should properly set the error', () => { + // Arrange + const data = 'some error' + const state = { + ...initialState, + createIndex: { + ...initialState.createIndex, + loading: false, + error: data + } + } + + // Act + const nextState = reducer(initialState, createIndexFailure(data)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { redisearch: nextState }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + }) + }) + + describe('resetRedisearchKeysData', () => { + it('should reset keys data', () => { + const strToKey = (name:string) => ({ name, nameString: name, ttl: 1, size: 1, type: 'hash' }) + + // Arrange + const state = { + ...initialState, + data: { + ...initialState.data, + keys: [], + } + } + + const prevState = { + ...initialState, + data: { + ...initialState.data, + keys: ['1', '2', '3', '4', '5', '6'].map(strToKey), + } + } + + // Act + const nextState = reducer(prevState, resetRedisearchKeysData()) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { redisearch: nextState }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + }) + }) + + describe('deleteRedisearchKeyFromList', () => { + it('should delete keys from list', () => { + const scanned = 5 + const total = 5 + const strToKey = (name:string) => ({ name: stringToBuffer(name), nameString: name, ttl: 1, size: 1, type: 'hash' }) + + // Arrange + const state = { + ...initialState, + data: { + ...initialState.data, + keys: ['1', '2', '3', '5', '6'].map(strToKey), + scanned: scanned - 1, + total: total - 1 + } + } + + const prevState = { + ...initialState, + data: { + ...initialState.data, + scanned, + total, + keys: ['1', '2', '3', '4', '5', '6'].map(strToKey), + } + } + + // Act + const nextState = reducer(prevState, deleteRedisearchKeyFromList(strToKey('4')?.name)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { redisearch: nextState }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + }) + }) + + describe('editRedisearchKeyFromList', () => { + it('should rename key in the list', () => { + const strToKey = (name:string) => ({ name: stringToBuffer(name), nameString: name, ttl: 1, size: 1, type: 'hash' }) + + // Arrange + const state = { + ...initialState, + data: { + ...initialState.data, + keys: ['1', '2', '3', '44', '5', '6'].map(strToKey), + } + } + + const prevState = { + ...initialState, + data: { + ...initialState.data, + keys: ['1', '2', '3', '4', '5', '6'].map(strToKey), + } + } + + // Act + const nextState = reducer( + prevState, + editRedisearchKeyFromList({ key: strToKey('4')?.name, newKey: strToKey('44')?.name }), + ) + + // Assert + const rootState = Object.assign(initialStateDefault, { + browser: { redisearch: nextState }, + }) + expect(redisearchSelector(rootState)).toEqual(state) + }) + }) + + describe('thunks', () => { + describe('fetchRedisearchListAction', () => { + it('call both fetchRedisearchListAction, loadListSuccess when fetch is successed', async () => { + // Arrange + const data = REDISEARCH_LIST_DATA_MOCK + const responsePayload = { data, status: 200 } + + apiService.get = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(fetchRedisearchListAction()) + + // Assert + const expectedActions = [ + loadList(), + loadListSuccess(responsePayload.data.indexes), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + }) + + describe('fetchRedisearchKeysAction', () => { + it('call both loadKeys and loadKeysSuccess when fetch is successed', async () => { + // Arrange + const data = { + total: 10, + cursor: 20, + scanned: 20, + keys: [ + { + name: stringToBuffer('bull:mail-queue:155'), + type: 'hash', + ttl: 2147474450, + size: 3041, + }, + { + name: stringToBuffer('bull:mail-queue:223'), + type: 'hash', + ttl: -1, + size: 3041, + }, + ], + } + const responsePayload = { + data: { + total: data.total, + scanned: data.scanned, + cursor: 20, + keys: [...data.keys], + }, + status: 200, + } + + apiService.post = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(fetchKeys(SearchMode.Redisearch, '0', 20)) + + // Assert + const expectedActions = [ + loadKeys(), + loadKeysSuccess([data, false]), + ] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to load keys', async () => { + // Arrange + const errorMessage = 'some error' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.post = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch(fetchKeys(SearchMode.Redisearch, '0', 20)) + + // Assert + const expectedActions = [ + loadKeys(), + addErrorNotification(responsePayload as AxiosError), + loadKeysFailure(errorMessage), + ] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to load keys: Index not found', async () => { + // Arrange + const errorMessage = 'idx: no such index' + const responsePayload = { + response: { + status: 404, + data: { message: errorMessage }, + }, + } + + apiService.post = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch(fetchKeys(SearchMode.Redisearch, '0', 20)) + + // Assert + const expectedActions = [ + loadKeys(), + addErrorNotification(responsePayload as AxiosError), + loadKeysFailure(errorMessage), + setRedisearchInitialState(), + loadList(), + ] + expect(store.getActions()).toEqual(expectedActions) + }) + }) + + describe('fetchMoreRedisearchKeysAction', () => { + it('call both loadMoreKeys and loadMoreKeysSuccess when fetch is successed', async () => { + // Arrange + const data = { + total: 0, + nextCursor: '0', + scanned: 20, + shardsMeta: {}, + keys: [ + { + name: stringToBuffer('bull:mail-queue:155'), + type: 'hash', + ttl: 2147474450, + size: 3041, + }, + { + name: stringToBuffer('bull:mail-queue:223'), + type: 'hash', + ttl: -1, + size: 3041, + }, + ], + } + + const responsePayload = { + data: { + total: data.total, + scanned: data.scanned, + cursor: 20, + keys: [...data.keys], + }, + status: 200, + } + + apiService.post = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(fetchMoreKeys(SearchMode.Redisearch, [], '0', 20)) + + // Assert + const expectedActions = [ + loadMoreKeys(), + loadMoreKeysSuccess(responsePayload.data), + ] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to fetch more keys', async () => { + // Arrange + const errorMessage = 'some error' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.post = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch(fetchMoreKeys(SearchMode.Redisearch, [], '0', 20)) + + // Assert + const expectedActions = [ + loadMoreKeys(), + addErrorNotification(responsePayload as AxiosError), + loadMoreKeysFailure(errorMessage), + ] + expect(store.getActions()).toEqual(expectedActions) + }) + }) + + describe('createRedisearchIndexAction', () => { + it('should call proper actions on success', async () => { + // Arrange + const data = { + index: stringToBuffer('index'), + type: 'hash', + prefixes: ['prefix1', 'prefix 2'].map((p) => stringToBuffer(p)), + fields: [{ name: stringToBuffer('field'), type: 'numeric' }] + } + + const responsePayload = { status: 200 } + apiService.post = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(createRedisearchIndexAction(data)) + + // Assert + const expectedActions = [ + createIndex(), + createIndexSuccess(), + addMessageNotification(successMessages.CREATE_INDEX()), + loadList() + ] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to create index', async () => { + // Arrange + const errorMessage = 'some error' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.post = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch(createRedisearchIndexAction({})) + + // Assert + const expectedActions = [ + createIndex(), + addErrorNotification(responsePayload as AxiosError), + createIndexFailure(errorMessage), + ] + expect(store.getActions()).toEqual(expectedActions) + }) + }) + }) +}) diff --git a/redisinsight/ui/src/slices/tests/browser/set.spec.ts b/redisinsight/ui/src/slices/tests/browser/set.spec.ts index e60c88ba16..da1ff7f282 100644 --- a/redisinsight/ui/src/slices/tests/browser/set.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/set.spec.ts @@ -10,9 +10,9 @@ import { import { addErrorNotification, addMessageNotification } from 'uiSrc/slices/app/notifications' import successMessages from 'uiSrc/components/notifications/success-messages' import { stringToBuffer } from 'uiSrc/utils' +import { deleteRedisearchKeyFromList } from 'uiSrc/slices/browser/redisearch' import { defaultSelectedKeyAction, - deleteKeyFromList, deleteKeySuccess, refreshKeyInfo, updateSelectedKeyRefreshTime, @@ -675,7 +675,7 @@ describe('set slice', () => { removeSetMembersSuccess(), removeMembersFromList(members), deleteKeySuccess(), - deleteKeyFromList(key), + deleteRedisearchKeyFromList(key), addMessageNotification(successMessages.DELETED_KEY(key)) ] diff --git a/redisinsight/ui/src/slices/tests/browser/zset.spec.ts b/redisinsight/ui/src/slices/tests/browser/zset.spec.ts index 639cc2412e..e2990f7a13 100644 --- a/redisinsight/ui/src/slices/tests/browser/zset.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/zset.spec.ts @@ -11,10 +11,10 @@ import { import { addErrorNotification, addMessageNotification } from 'uiSrc/slices/app/notifications' import successMessages from 'uiSrc/components/notifications/success-messages' import { stringToBuffer } from 'uiSrc/utils' +import { deleteRedisearchKeyFromList } from 'uiSrc/slices/browser/redisearch' import { AddMembersToZSetDto, ZSetMemberDto } from 'apiSrc/modules/browser/dto' import { defaultSelectedKeyAction, - deleteKeyFromList, deleteKeySuccess, refreshKeyInfo, updateSelectedKeyRefreshTime, @@ -966,7 +966,7 @@ describe('zset slice', () => { removeZsetMembersSuccess(), removeMembersFromList(members), deleteKeySuccess(), - deleteKeyFromList(key), + deleteRedisearchKeyFromList(key), addMessageNotification(successMessages.DELETED_KEY(key)) ] diff --git a/redisinsight/ui/src/styles/components/_forms.scss b/redisinsight/ui/src/styles/components/_forms.scss index f707579751..df208b6296 100644 --- a/redisinsight/ui/src/styles/components/_forms.scss +++ b/redisinsight/ui/src/styles/components/_forms.scss @@ -83,6 +83,7 @@ body { .euiFieldSearch, .euiSelect, .euiSuperSelectControl, + .euiComboBox .euiComboBox__inputWrap, .euiTextArea { background-color: var(--euiColorEmptyShade) !important; max-width: 100% !important; @@ -151,9 +152,38 @@ body { .euiFlexGroup { .euiFlexItem:not(:first-child) { .euiFieldText, - .euiFormControlLayout--group { + .euiFormControlLayout--group, + .euiSuperSelectControl { border-left: 0 !important; } } } + + .euiFormRow .euiSuperSelectControl:not(.euiSuperSelectControl--compressed), + .euiFormRow .euiSelect:not(.euiSelect--compressed), + .euiFormRow .euiFormControlLayout:not(.euiFormControlLayout--compressed), + .euiFormRow .euiFieldText:not(.euiFieldText--compressed), + .euiFormRow .euiFieldNumber:not(.euiFieldNumber--compressed) { + height: 43px; + } +} + + +.euiComboBox .euiComboBox__inputWrap { + padding-top: 5px !important; + padding-left: 8px !important; + min-height: 43px !important; + .euiBadge { + background-color: var(--comboBoxBadgeBgColor); + color: var(--euiTextSubduedColor); + border: 0; + } + + .euiBadge__text { + line-height: 20px; + } + + .euiComboBoxPlaceholder { + color: var(--inputPlaceholderColor) !important; + } } diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss index bc4767f06b..2dee6e4865 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_dark_theme.lazy.scss @@ -116,6 +116,8 @@ --buttonDangerToastColor: #{$buttonDangerToastColor}; --buttonDangerToastHoverColor: #{$buttonDangerToastHoverColor}; + --comboBoxBadgeBgColor: #{$comboBoxBadgeBgColor}; + --cliOutputResponseColor: #{$cliOutputResponseColor}; --cliOutputResponseFailColor: #{$cliOutputResponseFailColor}; diff --git a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss index 31d57da5a3..bcfb32c4f2 100644 --- a/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/dark_theme/_theme_color.scss @@ -75,6 +75,8 @@ $buttonWarningHoverColor: #b00c03; $buttonDangerToastColor: #e8524a; $buttonDangerToastHoverColor: #bf3932; +$comboBoxBadgeBgColor: #363636; + $hoverInListColor: #070707; $hoverInListColorLight: #465282; $hoverInListColorDarken: #292f47; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss index 1bbeba18bb..a9fbfa450e 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_light_theme.lazy.scss @@ -118,6 +118,8 @@ --buttonDangerToastColor: #{$buttonDangerToastColor}; --buttonDangerToastHoverColor: #{$buttonDangerToastHoverColor}; + --comboBoxBadgeBgColor: #{$comboBoxBadgeBgColor}; + --cliOutputResponseColor: #{$cliOutputResponseColor}; --cliOutputResponseFailColor: #{$cliOutputResponseFailColor}; diff --git a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss index eff4e433a6..e925a43fe6 100644 --- a/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss +++ b/redisinsight/ui/src/styles/themes/light_theme/_theme_color.scss @@ -72,6 +72,8 @@ $buttonWarningHoverColor: #b00c03; $buttonDangerToastColor: #e8524a; $buttonDangerToastHoverColor: #bf3932; +$comboBoxBadgeBgColor: #edf0f5; + $hoverInListColor: #e9edfa; $hoverInListColorLight: #d7e3fa; $textColorShade: #415681; diff --git a/redisinsight/ui/src/telemetry/events.ts b/redisinsight/ui/src/telemetry/events.ts index 96c66005ea..56436755a5 100644 --- a/redisinsight/ui/src/telemetry/events.ts +++ b/redisinsight/ui/src/telemetry/events.ts @@ -187,4 +187,9 @@ export enum TelemetryEvent { DATABASE_ANALYSIS_EXTRAPOLATION_CHANGED = 'DATABASE_ANALYSIS_EXTRAPOLATION_CHANGED', USER_SURVEY_LINK_CLICKED = 'USER_SURVEY_LINK_CLICKED', + + SEARCH_MODE_CHANGED = 'SEARCH_MODE_CHANGED', + SEARCH_INDEX_CHANGED = 'SEARCH_INDEX_CHANGED', + SEARCH_INDEX_ADD_BUTTON_CLICKED = 'SEARCH_INDEX_ADD_BUTTON_CLICKED', + SEARCH_INDEX_ADD_CANCELLED = 'SEARCH_INDEX_ADD_CANCELLED', } diff --git a/redisinsight/ui/src/utils/formatters/bufferFormatters.ts b/redisinsight/ui/src/utils/formatters/bufferFormatters.ts index 3cd745c3ec..218f3eb8ec 100644 --- a/redisinsight/ui/src/utils/formatters/bufferFormatters.ts +++ b/redisinsight/ui/src/utils/formatters/bufferFormatters.ts @@ -82,7 +82,7 @@ const bufferToASCII = (reply: RedisResponseBuffer): string => { } const anyToBuffer = (reply: UintArray): RedisResponseBuffer => - ({ data: reply, type: RedisResponseBufferType.Buffer }) + ({ data: reply, type: RedisResponseBufferType.Buffer }) as RedisResponseBuffer const ASCIIToBuffer = (strInit: string) => { let result = '' @@ -134,7 +134,7 @@ const hexToBuffer = (data: string): RedisResponseBuffer => { result.push(parseInt(string.substring(0, 2), 16)) string = string.substring(2, string.length) } - return { type: RedisResponseBufferType.Buffer, data: result } + return { type: RedisResponseBufferType.Buffer, data: result } as RedisResponseBuffer } const bufferToJava = (reply: RedisResponseBuffer) => { diff --git a/redisinsight/ui/src/utils/formatters/valueFormatters.tsx b/redisinsight/ui/src/utils/formatters/valueFormatters.tsx index 06f7e706f2..08c7562ccd 100644 --- a/redisinsight/ui/src/utils/formatters/valueFormatters.tsx +++ b/redisinsight/ui/src/utils/formatters/valueFormatters.tsx @@ -127,7 +127,11 @@ const formattingBuffer = ( } } -const bufferToSerializedFormat = (format: KeyValueFormat, value: RedisResponseBuffer, space?: number): string => { +const bufferToSerializedFormat = ( + format: KeyValueFormat, + value: RedisResponseBuffer = stringToBuffer(''), + space?: number +): string => { switch (format) { case KeyValueFormat.ASCII: return bufferToASCII(value) case KeyValueFormat.HEX: return bufferToHex(value) diff --git a/redisinsight/ui/src/utils/test-utils.tsx b/redisinsight/ui/src/utils/test-utils.tsx index 7058f42f05..a27a8356ca 100644 --- a/redisinsight/ui/src/utils/test-utils.tsx +++ b/redisinsight/ui/src/utils/test-utils.tsx @@ -42,6 +42,7 @@ import { initialState as initialClusterDetails } from 'uiSrc/slices/analytics/cl import { initialState as initialStateAnalyticsSettings } from 'uiSrc/slices/analytics/settings' import { initialState as initialStateDbAnalysis } from 'uiSrc/slices/analytics/dbAnalysis' import { initialState as initialStatePubSub } from 'uiSrc/slices/pubsub/pubsub' +import { initialState as initialStateRedisearch } from 'uiSrc/slices/browser/redisearch' import { RESOURCES_BASE_URL } from 'uiSrc/services/resourcesService' import { apiService } from 'uiSrc/services' @@ -80,6 +81,7 @@ const initialStateDefault: RootState = { rejson: cloneDeep(initialStateRejson), stream: cloneDeep(initialStateStream), bulkActions: cloneDeep(initialStateBulkActions), + redisearch: cloneDeep(initialStateRedisearch), }, cli: { settings: cloneDeep(initialStateCliSettings), diff --git a/tests/e2e/helpers/keys.ts b/tests/e2e/helpers/keys.ts index f63c05fa72..d277db769e 100644 --- a/tests/e2e/helpers/keys.ts +++ b/tests/e2e/helpers/keys.ts @@ -214,3 +214,13 @@ export async function deleteAllKeysFromDB(host: string, port: string): Promise { + for (const keyName of keyNames) { + await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyName)).ok(`The key ${keyName} not found`); + } +} \ No newline at end of file diff --git a/tests/e2e/pageObjects/browser-page.ts b/tests/e2e/pageObjects/browser-page.ts index 1df0b8e7f7..433e909f74 100644 --- a/tests/e2e/pageObjects/browser-page.ts +++ b/tests/e2e/pageObjects/browser-page.ts @@ -93,6 +93,8 @@ export class BrowserPage { workbenchLinkButton = Selector('[data-test-subj=workbench-page-btn]'); cancelStreamGroupBtn = Selector('[data-testid=cancel-stream-groups-btn]'); submitTooltipBtn = Selector('[data-testid=submit-tooltip-btn]'); + patternModeBtn = Selector('[data-testid=search-mode-pattern-btn]'); + redisearchModeBtn = Selector('[data-testid=search-mode-redisearch-btn]'); //CONTAINERS streamGroupsContainer = Selector('[data-testid=stream-groups-container]'); streamConsumersContainer = Selector('[data-testid=stream-consumers-container]'); @@ -101,9 +103,11 @@ export class BrowserPage { streamEntriesContainer = Selector('[data-testid=stream-entries-container]'); streamMessagesContainer = Selector('[data-testid=stream-messages-container]'); loader = Selector('[data-testid=type-loading]'); + newIndexPanel = Selector('[data-testid=create-index-panel]'); //LINKS internalLinkToWorkbench = Selector('[data-testid=internal-workbench-link]'); userSurveyLink = Selector('[data-testid=user-survey-link]'); + redisearchFreeLink = Selector('[data-testid=redisearch-free-db]'); //OPTION ELEMENTS stringOption = Selector('#string'); jsonOption = Selector('#ReJSON-RL'); @@ -123,6 +127,10 @@ export class BrowserPage { timestampOption = Selector('#time'); formatSwitcher = Selector('[data-testid=select-format-key-value]', { timeout: 2000 }); formatSwitcherIcon = Selector('img[data-testid^=key-value-formatter-option-selected]'); + selectIndexDdn = Selector('[data-testid=select-index-placeholder],[data-testid=select-search-mode]', { timeout: 1000 }); + createIndexBtn = Selector('[data-testid=create-index-btn]'); + cancelIndexCreationBtn = Selector('[data-testid=create-index-cancel-btn]'); + confirmIndexCreationBtn = Selector('[data-testid=create-index-btn]'); //TABS streamTabGroups = Selector('[data-testid=stream-tab-Groups]'); streamTabConsumers = Selector('[data-testid=stream-tab-Consumers]'); @@ -166,6 +174,10 @@ export class BrowserPage { claimRetryCountInput = Selector('[data-testid=retry-count]'); lastIdInput = Selector('[data-testid=last-id-field]'); inlineItemEditor = Selector('[data-testid=inline-item-editor]'); + indexNameInput = Selector('[data-testid=index-name]'); + prefixFieldInput = Selector('[data-test-subj=comboBoxInput]'); + indexIdentifierInput = Selector('[data-testid^=identifier-]'); + indexFieldType = Selector('[data-testid^=field-type-]'); //TEXT ELEMENTS keySizeDetails = Selector('[data-testid=key-size-text]'); keyLengthDetails = Selector('[data-testid=key-length-text]'); @@ -183,6 +195,7 @@ export class BrowserPage { jsonKeyValue = Selector('[data-testid=json-data]'); jsonError = Selector('[data-testid=edit-json-error]'); tooltip = Selector('[role=tooltip]'); + popover = Selector('[role=dialog]'); noResultsFound = Selector('[data-test-subj=no-result-found]'); searchAdvices = Selector('[data-test-subj=search-advices]'); keysNumberOfResults = Selector('[data-testid=keys-number-of-results]'); @@ -248,6 +261,9 @@ export class BrowserPage { stringValueAsJson = Selector(this.cssJsonValue); // POPUPS changeValueWarning = Selector('[data-testid=approve-popover]'); + // TABLE + keyListItem = Selector('[role=rowgroup] [role=row]'); + /** * Common part for Add any new key * @param keyName The name of the key @@ -972,6 +988,17 @@ export class BrowserPage { await t.expect(scannedResults).gt(rememberedScanResults, { timeout: 3000 }); } } + + /** + * Open Select Index droprown and select option + * @param index The name of format + */ + async selectIndexByName(index: string): Promise { + const option = Selector(`[data-test-subj="mode-option-type-${index}"]`); + await t + .click(this.selectIndexDdn) + .click(option); + } } /** diff --git a/tests/e2e/pageObjects/cli-page.ts b/tests/e2e/pageObjects/cli-page.ts index 620b4072fe..c9080e87ca 100644 --- a/tests/e2e/pageObjects/cli-page.ts +++ b/tests/e2e/pageObjects/cli-page.ts @@ -122,6 +122,19 @@ export class CliPage { await t.click(this.cliCollapseButton); } + /** + * Send command in Cli + * @param commands The commands to send + */ + async sendCommandsInCli(commands: string[]): Promise { + await t.click(this.cliExpandButton); + for (const command of commands) { + await t.typeText(this.cliCommandInput, command, { replace: true, paste: true }); + await t.pressKey('enter'); + } + await t.click(this.cliCollapseButton); + } + /** * Get command result execution * @param command The command for send in CLI diff --git a/tests/e2e/tests/critical-path/browser/search-capabilities.e2e.ts b/tests/e2e/tests/critical-path/browser/search-capabilities.e2e.ts new file mode 100644 index 0000000000..011a157982 --- /dev/null +++ b/tests/e2e/tests/critical-path/browser/search-capabilities.e2e.ts @@ -0,0 +1,187 @@ +import { acceptLicenseTermsAndAddDatabaseApi } from '../../../helpers/database'; +import { BrowserPage, CliPage } from '../../../pageObjects'; +import { + commonUrl, + ossStandaloneBigConfig, + ossStandaloneConfig, + ossStandaloneV5Config +} from '../../../helpers/conf'; +import { rte } from '../../../helpers/constants'; +import { deleteStandaloneDatabaseApi } from '../../../helpers/api/api-database'; +import { Common } from '../../../helpers/common'; +import { verifyKeysDisplayedInTheList } from '../../../helpers/keys'; + +const browserPage = new BrowserPage(); +const common = new Common(); +const cliPage = new CliPage(); + +const patternModeTooltipText = 'Filter by Key Name or Pattern'; +const redisearchModeTooltipText = 'Search by Values of Keys'; +const notSelectedIndexText = 'Select an index and enter a query to search per values of keys.'; +let keyName = common.generateWord(10); +let keyNames: string[]; +let indexName = common.generateWord(5); + +fixture `Search capabilities in Browser` + .meta({ type: 'critical_path', rte: rte.standalone }) + .page(commonUrl); +test + .before(async() => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); + keyName = common.generateWord(10); + await browserPage.addHashKey(keyName); + }) + .after(async() => { + // Clear and delete database + await browserPage.deleteKeyByName(keyName); + await cliPage.sendCommandsInCli([`DEL ${keyNames.join(' ')}`, `FT.DROPINDEX ${indexName}`]); + await deleteStandaloneDatabaseApi(ossStandaloneConfig); + })('RediSearch capabilities in Browser view to search per Hashes or JSONs', async t => { + indexName = `idx:${keyName}`; + keyNames = [`${keyName}:1`, `${keyName}:2`, `${keyName}:3`]; + const commands = [ + `HSET ${keyNames[0]} "name" "Hall School" "description" " Spanning 10 states" "class" "independent" "type" "traditional" "address_city" "London" "address_street" "Manor Street" "students" 342 "location" "51.445417, -0.258352"`, + `HSET ${keyNames[1]} "name" "Garden School" "description" "Garden School is a new outdoor" "class" "state" "type" "forest; montessori;" "address_city" "London" "address_street" "Gordon Street" "students" 1452 "location" "51.402926, -0.321523"`, + `HSET ${keyNames[2]} "name" "Gillford School" "description" "Gillford School is a centre" "class" "private" "type" "democratic; waldorf" "address_city" "Goudhurst" "address_street" "Goudhurst" "students" 721 "location" "51.112685, 0.451076"`, + `FT.CREATE ${indexName} ON HASH PREFIX 1 "${keyName}:" SCHEMA name TEXT NOSTEM description TEXT class TAG type TAG SEPARATOR ";" address_city AS city TAG address_street AS address TEXT NOSTEM students NUMERIC SORTABLE location GEO` + ]; + + // Create 3 keys and index + await cliPage.sendCommandsInCli(commands); + // Verify that user see the tooltips for the controls to switch the modes + await t.click(browserPage.patternModeBtn); + await t.hover(browserPage.patternModeBtn); + await t.expect(browserPage.tooltip.textContent).contains(patternModeTooltipText, 'Invalid text in pattern mode tooltip'); + await t.hover(browserPage.redisearchModeBtn); + await t.expect(browserPage.tooltip.textContent).contains(redisearchModeTooltipText, 'Invalid text in redisearch mode tooltip'); + + // Verify that user see the "Select an index" message when he switch to Search + await t.click(browserPage.redisearchModeBtn); + await t.expect(browserPage.keyListTable.textContent).contains(notSelectedIndexText, 'Select an index message not displayed'); + + // Verify that user can search by index in Browser view + await browserPage.selectIndexByName(indexName); + await verifyKeysDisplayedInTheList(keyNames); + await t.expect((await browserPage.getKeySelectorByName(keyName)).exists).notOk('Key without index displayed after search'); + // Verify that user can search by index plus key value + await browserPage.searchByKeyName('Hall School'); + await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyNames[0])).ok(`The key ${keyNames[0]} not found`); + await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyNames[1])).notOk(`Invalid key ${keyNames[1]} is displayed after search`); + // Verify that user can search by index plus multiple key values + await browserPage.searchByKeyName('(@name:"Hall School") | (@students:[500, 1000])'); + await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyNames[0])).ok(`The first valid key ${keyNames[0]} not found`); + await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyNames[2])).ok(`The second valid key ${keyNames[2]} not found`); + await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyNames[1])).notOk(`Invalid key ${keyNames[1]} is displayed after search`); + + // Verify that user can clear the search + await t.click(browserPage.clearFilterButton); + await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyNames[1])).ok(`The key ${keyNames[1]} not found`); + await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyName)).notOk('Search not cleared'); + + // Verify that user can search by index in Tree view + await t.click(browserPage.treeViewButton); + // Change delimiter + await browserPage.changeDelimiterInTreeView('-'); + await browserPage.selectIndexByName(indexName); + await verifyKeysDisplayedInTheList(keyNames); + await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyName)).notOk('Key without index displayed after search'); + + // Verify that user see the database scanned when he switch to Pattern search mode + await t.click(browserPage.patternModeBtn); + await t.click(browserPage.browserViewButton); + await verifyKeysDisplayedInTheList(keyNames); + await t.expect(await browserPage.isKeyIsDisplayedInTheList(keyName)).ok('Database not scanned after returning to Pattern search mode'); + }); +test + .before(async() => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneBigConfig, ossStandaloneBigConfig.databaseName); + }) + .after(async() => { + // Clear and delete database + await cliPage.sendCommandInCli(`FT.DROPINDEX ${indexName}`); + await deleteStandaloneDatabaseApi(ossStandaloneBigConfig); + })('Search by index keys scanned for JSON', async t => { + keyName = common.generateWord(10); + indexName = `idx:${keyName}`; + const command = `FT.CREATE ${indexName} ON JSON PREFIX 1 "device:" SCHEMA id numeric`; + + // Create index for JSON keys + await cliPage.sendCommandInCli(command); + // Verify that user can can get 500 keys (limit 0 500) in Browser view + await t.click(browserPage.redisearchModeBtn); + await browserPage.selectIndexByName(indexName); + // Verify that all keys are displayed according to selected index + for (let i = 0; i < 15; i++) { + await t.expect(browserPage.keyListItem.textContent).contains('device:', 'Keys out of index displayed'); + } + // Verify that user can can get 10 000 keys in Tree view + await t.click(browserPage.treeViewButton); + const keysNumberOfResults = browserPage.keysNumberOfResults.textContent; + await t.expect(keysNumberOfResults).contains('10 000', 'Number of results is not 10 000'); + }); +test + .before(async() => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config, ossStandaloneV5Config.databaseName); + }) + .after(async() => { + await deleteStandaloneDatabaseApi(ossStandaloneV5Config); + })('No RediSearch module message', async t => { + const noRedisearchMessage = 'RediSearch module is not loaded. Create a free Redis database(opens in a new tab or window) with module support on Redis Cloud.'; + const externalPageLink = 'https://redis.com/try-free/?utm_source=redis&utm_medium=app&utm_campaign=redisinsight_browser_search'; + + await t.click(browserPage.redisearchModeBtn); + // Verify that user can see message in popover when he not have RediSearch module + await t.expect(browserPage.popover.textContent).contains(noRedisearchMessage, 'Invalid text in no redisearch popover'); + // Verify that user can navigate by link to create a Redis db + await t.click(browserPage.redisearchFreeLink); + await common.checkURL(externalPageLink); + await t.switchToParentWindow(); + }); +test + .before(async() => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneBigConfig, ossStandaloneBigConfig.databaseName); + }) + .after(async() => { + await cliPage.sendCommandInCli(`FT.DROPINDEX ${indexName}`); + await deleteStandaloneDatabaseApi(ossStandaloneBigConfig); + })('Index creation', async t => { + const createIndexLink = 'https://redis.io/commands/ft.create/'; + + // Verify that user can cancel index creation + await t.click(browserPage.redisearchModeBtn); + await t.click(browserPage.selectIndexDdn); + await t.click(browserPage.createIndexBtn); + await t.expect(browserPage.newIndexPanel.exists).ok('New Index panel is not displayed'); + await t.click(browserPage.cancelIndexCreationBtn); + await t.expect(browserPage.newIndexPanel.exists).notOk('New Index panel is displayed'); + + // Verify that user can create an index with all mandatory parameters + await t.click(browserPage.redisearchModeBtn); + await t.click(browserPage.selectIndexDdn); + await t.click(browserPage.createIndexBtn); + await t.expect(browserPage.newIndexPanel.exists).ok('New Index panel is not displayed'); + // Verify that user can see a link to create a profound index and navigate + await t.click(browserPage.newIndexPanel.find('a')); + await common.checkURL(createIndexLink); + await t.switchToParentWindow(); + + // Verify that user can create an index with multiple prefixes + await t.click(browserPage.indexNameInput); + await t.typeText(browserPage.indexNameInput, indexName); + await t.click(browserPage.prefixFieldInput); + await t.typeText(browserPage.prefixFieldInput, 'device:'); + await t.pressKey('enter'); + await t.typeText(browserPage.prefixFieldInput, 'mobile_'); + await t.pressKey('enter'); + await t.typeText(browserPage.prefixFieldInput, 'user_'); + await t.pressKey('enter'); + await t.expect(browserPage.prefixFieldInput.find('button').count).eql(3, '3 prefixes are not displayed'); + + // Verify that user can create an index with multiple fields (up to 20) + await t.click(browserPage.indexIdentifierInput); + await t.typeText(browserPage.indexIdentifierInput, 'k0'); + await t.click(browserPage.confirmIndexCreationBtn); + await t.expect(browserPage.newIndexPanel.exists).notOk('New Index panel is displayed'); + await t.click(browserPage.selectIndexDdn); + await browserPage.selectIndexByName(indexName); + }); diff --git a/tests/e2e/tests/regression/browser/full-screen.e2e.ts b/tests/e2e/tests/regression/browser/full-screen.e2e.ts index 55d814fed9..19bb4bdeac 100644 --- a/tests/e2e/tests/regression/browser/full-screen.e2e.ts +++ b/tests/e2e/tests/regression/browser/full-screen.e2e.ts @@ -101,5 +101,5 @@ test const widthTableAfterFullScreen = await browserPage.keyListTable.clientWidth; await t.expect(widthTableAfterFullScreen).gt(widthKeysBeforeFullScreen, 'Width after switching to full screen not greater then before'); // Verify that user can not see key details - await t.expect(browserPage.keyNameFormDetails.withExactText(keyName).exists).notOk('Key details not opened'); + await t.expect(browserPage.keyNameFormDetails.withExactText(keyName).visible).notOk('Key details not opened'); });