diff --git a/redisinsight/__mocks__/monacoMock.js b/redisinsight/__mocks__/monacoMock.js index d26e492ffe..63b6eae71b 100644 --- a/redisinsight/__mocks__/monacoMock.js +++ b/redisinsight/__mocks__/monacoMock.js @@ -15,10 +15,14 @@ const editor = { executeEdits: jest.fn(), updateOptions: jest.fn(), setSelection: jest.fn(), + setPosition: jest.fn(), createDecorationsCollection: jest.fn(), getValue: jest.fn().mockReturnValue(''), - getModel: jest.fn().mockReturnValue({}), - getPosition: jest.fn(), + getModel: jest.fn().mockReturnValue({ + getOffsetAt: jest.fn().mockReturnValue(0), + getWordUntilPosition: jest.fn().mockReturnValue(''), + }), + getPosition: jest.fn().mockReturnValue({}), trigger: jest.fn(), } @@ -70,16 +74,18 @@ export const languages = { }, CompletionItemInsertTextRule: { InsertAsSnippet: 4 - } + }, + ...monacoEditor.languages } export const monaco = { languages, Selection: jest.fn().mockImplementation(() => ({})), editor: { + ...editor, colorize: jest.fn().mockImplementation((data) => Promise.resolve(data)), defineTheme: jest.fn(), - setTheme: jest.fn() + setTheme: jest.fn(), }, Range: monacoEditor.Range } diff --git a/redisinsight/api/migration/1726058563737-command-execution.ts b/redisinsight/api/migration/1726058563737-command-execution.ts new file mode 100644 index 0000000000..e8df57332b --- /dev/null +++ b/redisinsight/api/migration/1726058563737-command-execution.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CommandExecution1726058563737 implements MigrationInterface { + name = 'CommandExecution1726058563737' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_5cd90dd6def1fd7c521e53fb2c"`); + await queryRunner.query(`CREATE TABLE "temporary_command_execution" ("id" varchar PRIMARY KEY NOT NULL, "databaseId" varchar NOT NULL, "command" text NOT NULL, "result" text NOT NULL, "role" varchar, "nodeOptions" varchar, "encryption" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "mode" varchar, "resultsMode" varchar, "summary" varchar, "executionTime" integer, "db" integer, "type" varchar NOT NULL DEFAULT ('WORKBENCH'), CONSTRAINT "FK_ea8adfe9aceceb79212142206b8" FOREIGN KEY ("databaseId") REFERENCES "database_instance" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_command_execution"("id", "databaseId", "command", "result", "role", "nodeOptions", "encryption", "createdAt", "mode", "resultsMode", "summary", "executionTime", "db") SELECT "id", "databaseId", "command", "result", "role", "nodeOptions", "encryption", "createdAt", "mode", "resultsMode", "summary", "executionTime", "db" FROM "command_execution"`); + await queryRunner.query(`DROP TABLE "command_execution"`); + await queryRunner.query(`ALTER TABLE "temporary_command_execution" RENAME TO "command_execution"`); + await queryRunner.query(`CREATE INDEX "IDX_5cd90dd6def1fd7c521e53fb2c" ON "command_execution" ("createdAt") `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_5cd90dd6def1fd7c521e53fb2c"`); + await queryRunner.query(`ALTER TABLE "command_execution" RENAME TO "temporary_command_execution"`); + await queryRunner.query(`CREATE TABLE "command_execution" ("id" varchar PRIMARY KEY NOT NULL, "databaseId" varchar NOT NULL, "command" text NOT NULL, "result" text NOT NULL, "role" varchar, "nodeOptions" varchar, "encryption" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "mode" varchar, "resultsMode" varchar, "summary" varchar, "executionTime" integer, "db" integer, CONSTRAINT "FK_ea8adfe9aceceb79212142206b8" FOREIGN KEY ("databaseId") REFERENCES "database_instance" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "command_execution"("id", "databaseId", "command", "result", "role", "nodeOptions", "encryption", "createdAt", "mode", "resultsMode", "summary", "executionTime", "db") SELECT "id", "databaseId", "command", "result", "role", "nodeOptions", "encryption", "createdAt", "mode", "resultsMode", "summary", "executionTime", "db" FROM "temporary_command_execution"`); + await queryRunner.query(`DROP TABLE "temporary_command_execution"`); + await queryRunner.query(`CREATE INDEX "IDX_5cd90dd6def1fd7c521e53fb2c" ON "command_execution" ("createdAt") `); + } + +} diff --git a/redisinsight/api/migration/index.ts b/redisinsight/api/migration/index.ts index 621b74750d..60f4bda910 100644 --- a/redisinsight/api/migration/index.ts +++ b/redisinsight/api/migration/index.ts @@ -42,6 +42,7 @@ import { AiHistory1713515657364 } from './1713515657364-ai-history'; import { AiHistorySteps1714501203616 } from './1714501203616-ai-history-steps'; import { Rdi1716370509836 } from './1716370509836-rdi'; import { AiHistory1718260230164 } from './1718260230164-ai-history'; +import { CommandExecution1726058563737 } from './1726058563737-command-execution'; export default [ initialMigration1614164490968, @@ -88,4 +89,5 @@ export default [ AiHistorySteps1714501203616, Rdi1716370509836, AiHistory1718260230164, + CommandExecution1726058563737, ]; diff --git a/redisinsight/api/src/__mocks__/common.ts b/redisinsight/api/src/__mocks__/common.ts index 85eee7e4ca..9cc11473cd 100644 --- a/redisinsight/api/src/__mocks__/common.ts +++ b/redisinsight/api/src/__mocks__/common.ts @@ -8,6 +8,7 @@ export type MockType = { }; export const mockQueryBuilderWhere = jest.fn().mockReturnThis(); +export const mockQueryBuilderWhereInIds = jest.fn().mockReturnThis(); export const mockQueryBuilderSelect = jest.fn().mockReturnThis(); export const mockQueryBuilderLeftJoinAndSelect = jest.fn().mockReturnThis(); export const mockQueryBuilderGetOne = jest.fn(); @@ -18,6 +19,7 @@ export const mockQueryBuilderExecute = jest.fn(); export const mockCreateQueryBuilder = jest.fn(() => ({ // where: jest.fn().mockReturnThis(), where: mockQueryBuilderWhere, + whereInIds: mockQueryBuilderWhereInIds, orWhere: mockQueryBuilderWhere, update: jest.fn().mockReturnThis(), select: mockQueryBuilderSelect, @@ -30,7 +32,6 @@ export const mockCreateQueryBuilder = jest.fn(() => ({ leftJoinAndSelect: mockQueryBuilderLeftJoinAndSelect, offset: jest.fn().mockReturnThis(), delete: jest.fn().mockReturnThis(), - whereInIds: jest.fn().mockReturnThis(), execute: mockQueryBuilderExecute, getCount: mockQueryBuilderGetCount, getRawMany: mockQueryBuilderGetManyRaw, diff --git a/redisinsight/api/src/__mocks__/index.ts b/redisinsight/api/src/__mocks__/index.ts index 8999864e19..19a2c1aad5 100644 --- a/redisinsight/api/src/__mocks__/index.ts +++ b/redisinsight/api/src/__mocks__/index.ts @@ -41,3 +41,4 @@ export * from './cloud-session'; export * from './database-info'; export * from './cloud-job'; export * from './rdi'; +export * from './workbench'; diff --git a/redisinsight/api/src/__mocks__/redisearch.ts b/redisinsight/api/src/__mocks__/redisearch.ts new file mode 100644 index 0000000000..a23dc8c735 --- /dev/null +++ b/redisinsight/api/src/__mocks__/redisearch.ts @@ -0,0 +1,216 @@ +import { IndexInfoDto } from 'src/modules/browser/redisearch/dto'; + +export const mockIndexInfoRaw = [ + 'index_name', + 'idx:movie', + 'index_options', + [], + 'index_definition', + ['key_type', 'HASH', 'prefixes', ['movie:'], 'default_score', '1'], + 'attributes', + [ + ['identifier', 'title', 'attribute', 'title', 'type', 'TEXT', 'WEIGHT', '1', 'SORTABLE'], + ['identifier', 'release_year', 'attribute', 'release_year', 'type', 'NUMERIC', 'SORTABLE', 'UNF'], + ['identifier', 'rating', 'attribute', 'rating', 'type', 'NUMERIC', 'SORTABLE', 'UNF'], + ['identifier', 'genre', 'attribute', 'genre', 'type', 'TAG', 'SEPARATOR', ',', 'SORTABLE'], + ], + 'num_docs', + '2', + 'max_doc_id', + '2', + 'num_terms', + '13', + 'num_records', + '19', + 'inverted_sz_mb', + '0.0016384124755859375', + 'vector_index_sz_mb', + '0', + 'total_inverted_index_blocks', + '17', + 'offset_vectors_sz_mb', + '1.239776611328125e-5', + 'doc_table_size_mb', + '1.468658447265625e-4', + 'sortable_values_size_mb', + '2.498626708984375e-4', + 'key_table_size_mb', + '8.296966552734375e-5', + 'tag_overhead_sz_mb', + '5.53131103515625e-5', + 'text_overhead_sz_mb', + '4.3392181396484375e-4', + 'total_index_memory_sz_mb', + '0.0026903152465820313', + 'geoshapes_sz_mb', '0', + 'records_per_doc_avg', + '9.5', + 'bytes_per_record_avg', + '90.42105102539063', + 'offsets_per_term_avg', + '0.6842105388641357', + 'offset_bits_per_record_avg', + '8', 'hash_indexing_failures', + '0', 'total_indexing_time', '0.890999972820282', 'indexing', '0', + 'percent_indexed', '1', 'number_of_uses', 17, 'cleaning', 0, 'gc_stats', + ['bytes_collected', '0', 'total_ms_run', '0', 'total_cycles', '0', + 'average_cycle_time_ms', 'nan', 'last_run_time_ms', '0', + 'gc_numeric_trees_missed', '0', 'gc_blocks_denied', '0', + ], + 'cursor_stats', + ['global_idle', 0, 'global_total', 0, 'index_capacity', 128, 'index_total', 0], + 'dialect_stats', + ['dialect_1', 1, 'dialect_2', 0, 'dialect_3', 0, 'dialect_4', 0], + 'Index Errors', + ['indexing failures', 0, 'last indexing error', 'N/A', 'last indexing error key', 'N/A'], + 'field statistics', + [ + ['identifier', 'title', 'attribute', 'title', 'Index Errors', + ['indexing failures', 0, 'last indexing error', 'N/A', 'last indexing error key', 'N/A'], + ], + ['identifier', 'release_year', 'attribute', 'release_year', 'Index Errors', + ['indexing failures', 0, 'last indexing error', 'N/A', 'last indexing error key', 'N/A'], + ], + ['identifier', 'rating', 'attribute', 'rating', 'Index Errors', + ['indexing failures', 0, 'last indexing error', 'N/A', 'last indexing error key', 'N/A'], + ], + ['identifier', 'genre', 'attribute', 'genre', 'Index Errors', + ['indexing failures', 0, 'last indexing error', 'N/A', 'last indexing error key', 'N/A'], + ]]]; + +export const mockIndexInfoDto: IndexInfoDto = { + index_name: 'idx:movie', + index_options: {}, + index_definition: { key_type: 'HASH', prefixes: ['movie:'], default_score: '1' }, + attributes: [ + { + identifier: 'title', + attribute: 'title', + type: 'TEXT', + WEIGHT: '1', + SORTABLE: true, + NOINDEX: undefined, + CASESENSITIVE: undefined, + UNF: undefined, + NOSTEM: undefined, + }, + { + identifier: 'release_year', + attribute: 'release_year', + type: 'NUMERIC', + SORTABLE: true, + NOINDEX: undefined, + CASESENSITIVE: undefined, + UNF: true, + NOSTEM: undefined, + }, + { + identifier: 'rating', + attribute: 'rating', + type: 'NUMERIC', + SORTABLE: true, + NOINDEX: undefined, + CASESENSITIVE: undefined, + UNF: true, + NOSTEM: undefined, + }, + { + identifier: 'genre', + attribute: 'genre', + type: 'TAG', + SEPARATOR: ',', + SORTABLE: true, + NOINDEX: undefined, + CASESENSITIVE: undefined, + UNF: undefined, + NOSTEM: undefined, + }, + ], + num_docs: '2', + max_doc_id: '2', + num_terms: '13', + num_records: '19', + inverted_sz_mb: '0.0016384124755859375', + vector_index_sz_mb: '0', + total_inverted_index_blocks: '17', + offset_vectors_sz_mb: '1.239776611328125e-5', + doc_table_size_mb: '1.468658447265625e-4', + sortable_values_size_mb: '2.498626708984375e-4', + tag_overhead_sz_mb: '5.53131103515625e-5', + text_overhead_sz_mb: '4.3392181396484375e-4', + total_index_memory_sz_mb: '0.0026903152465820313', + + key_table_size_mb: '8.296966552734375e-5', + geoshapes_sz_mb: '0', + records_per_doc_avg: '9.5', + bytes_per_record_avg: '90.42105102539063', + offsets_per_term_avg: '0.6842105388641357', + offset_bits_per_record_avg: '8', + hash_indexing_failures: '0', + total_indexing_time: '0.890999972820282', + indexing: '0', + percent_indexed: '1', + number_of_uses: 17, + cleaning: 0, + gc_stats: { + bytes_collected: '0', + total_ms_run: '0', + total_cycles: '0', + average_cycle_time_ms: 'nan', + last_run_time_ms: '0', + gc_numeric_trees_missed: '0', + gc_blocks_denied: '0', + }, + cursor_stats: { + global_idle: 0, + global_total: 0, + index_capacity: 128, + index_total: 0, + }, + dialect_stats: { + dialect_1: 1, dialect_2: 0, dialect_3: 0, dialect_4: 0, + }, + 'Index Errors': { + 'indexing failures': 0, + 'last indexing error': 'N/A', + 'last indexing error key': 'N/A', + }, + 'field statistics': [ + { + identifier: 'title', + attribute: 'title', + 'Index Errors': { + 'indexing failures': 0, + 'last indexing error': 'N/A', + 'last indexing error key': 'N/A', + }, + }, + { + identifier: 'release_year', + attribute: 'release_year', + 'Index Errors': { + 'indexing failures': 0, + 'last indexing error': 'N/A', + 'last indexing error key': 'N/A', + }, + }, + { + identifier: 'rating', + attribute: 'rating', + 'Index Errors': { + 'indexing failures': 0, + 'last indexing error': 'N/A', + 'last indexing error key': 'N/A', + }, + }, + { + identifier: 'genre', + attribute: 'genre', + 'Index Errors': { + 'indexing failures': 0, + 'last indexing error': 'N/A', + 'last indexing error key': 'N/A', + }, + }, + ], +}; diff --git a/redisinsight/api/src/__mocks__/workbench.ts b/redisinsight/api/src/__mocks__/workbench.ts new file mode 100644 index 0000000000..c5b94301e5 --- /dev/null +++ b/redisinsight/api/src/__mocks__/workbench.ts @@ -0,0 +1,100 @@ +import { v4 as uuidv4 } from 'uuid'; +import { + CommandExecution, + CommandExecutionType, + ResultsMode, + RunQueryMode, +} from 'src/modules/workbench/models/command-execution'; +import { CommandExecutionEntity } from 'src/modules/workbench/entities/command-execution.entity'; +import { mockDatabase } from 'src/__mocks__/databases'; +import { CommandExecutionResult } from 'src/modules/workbench/models/command-execution-result'; +import { CommandExecutionStatus } from 'src/modules/cli/dto/cli.dto'; +import { CreateCommandExecutionDto } from 'src/modules/workbench/dto/create-command-execution.dto'; +import { ShortCommandExecution } from 'src/modules/workbench/models/short-command-execution'; +import { CommandExecutionFilter } from 'src/modules/workbench/models/command-executions.filter'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { PluginCommandExecution } from 'src/modules/workbench/models/plugin-command-execution'; + +export const mockCommandExecutionUnsupportedCommandResult = Object.assign(new CommandExecutionResult(), { + response: ERROR_MESSAGES.PLUGIN_COMMAND_NOT_SUPPORTED('subscribe'.toUpperCase()), + status: CommandExecutionStatus.Fail, +}); + +export const mockCommandExecutionSuccessResult = Object.assign(new CommandExecutionResult(), { + status: CommandExecutionStatus.Success, + response: 'bar', +}); + +export const mockCommendExecutionHugeResultPlaceholder = Object.assign(new CommandExecutionResult(), { + status: CommandExecutionStatus.Success, + response: 'Results have been deleted since they exceed 1 MB. Re-run the command to see new results.', +}); + +export const mockCommendExecutionHugeResultPlaceholderEncrypted = 'huge_result_placeholder_encrypted'; + +export const mockCommandExecution = Object.assign(new CommandExecution(), { + id: uuidv4(), + databaseId: mockDatabase.id, + command: 'get foo', + mode: RunQueryMode.ASCII, + resultsMode: ResultsMode.Default, + type: CommandExecutionType.Workbench, + result: [mockCommandExecutionSuccessResult], + createdAt: new Date(), + db: 0, +}); + +export const mockCommandExecutionEntity = Object.assign(new CommandExecutionEntity(), { + ...mockCommandExecution, + command: 'encrypted_command', + result: `${JSON.stringify([mockCommandExecutionSuccessResult])}_encrypted`, + encryption: 'KEYTAR', +}); + +export const mockShortCommandExecution = Object.assign(new ShortCommandExecution(), { + id: mockCommandExecution.id, + databaseId: mockCommandExecution.id, + command: mockCommandExecution.command, + createdAt: mockCommandExecution.createdAt, + mode: mockCommandExecution.mode, + summary: mockCommandExecution.summary, + resultsMode: mockCommandExecution.resultsMode, + executionTime: mockCommandExecution.executionTime, + db: mockCommandExecution.db, + type: mockCommandExecution.type, +}); + +export const mockShortCommandExecutionEntity = Object.assign(new CommandExecutionEntity(), { + ...mockShortCommandExecution, + command: mockCommandExecutionEntity.command, + encryption: mockCommandExecutionEntity.encryption, +}); + +export const mockCreateCommandExecutionDto = Object.assign(new CreateCommandExecutionDto(), { + command: mockCommandExecution.command, + mode: mockCommandExecution.mode, + resultsMode: mockCommandExecution.resultsMode, + type: mockCommandExecution.type, +}); + +export const mockCommandExecutionFilter = Object.assign(new CommandExecutionFilter(), { + type: mockCommandExecution.type, +}); + +export const mockPluginCommandExecution = Object.assign(new PluginCommandExecution(), { + ...mockCreateCommandExecutionDto, + databaseId: mockDatabase.id, + result: [mockCommandExecutionSuccessResult], +}); + +export const mockWorkbenchCommandsExecutor = () => ({ + sendCommand: jest.fn().mockResolvedValue([mockCommandExecutionSuccessResult]), +}); + +export const mockCommandExecutionRepository = () => ({ + createMany: jest.fn().mockResolvedValue([mockCommandExecution]), + getList: jest.fn().mockResolvedValue([mockCommandExecution]), + getOne: jest.fn().mockResolvedValue(mockCommandExecution), + delete: jest.fn(), + deleteAll: jest.fn(), +}); diff --git a/redisinsight/api/src/modules/browser/redisearch/dto/index.info.dto.ts b/redisinsight/api/src/modules/browser/redisearch/dto/index.info.dto.ts new file mode 100644 index 0000000000..2700275fec --- /dev/null +++ b/redisinsight/api/src/modules/browser/redisearch/dto/index.info.dto.ts @@ -0,0 +1,412 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsDefined, +} from 'class-validator'; +import { IsRedisString, RedisStringType } from 'src/common/decorators'; +import { RedisString } from 'src/common/constants'; +import { Expose } from 'class-transformer'; + +export class IndexInfoRequestBodyDto { + @ApiProperty({ + description: 'Index name', + type: String, + }) + @IsDefined() + @RedisStringType() + @IsRedisString() + index: RedisString; +} + +export class IndexOptionsDto { + @ApiProperty({ + description: 'is a filter expression with the full RediSearch aggregation expression language.', + type: String, + }) + @Expose() + filter?: string; + + @ApiProperty({ + description: 'if set, indicates the default language for documents in the index. Default is English.', + type: String, + }) + @Expose() + default_lang?: string; +} + +export class IndexDefinitionDto { + @ApiProperty({ + description: 'key_type, hash or JSON', + type: String, + }) + @Expose() + key_type: string; + + @ApiProperty({ + description: 'Index prefixes given during create', + type: String, + isArray: true, + }) + @Expose() + prefixes: Array; + + @ApiProperty({ + description: 'Index default_score', + type: String, + }) + @Expose() + default_score: string; +} + +export class IndexAttibuteDto { + @ApiProperty({ + description: 'Field identifier', + type: String, + }) + @Expose() + identifier: string; + + @ApiProperty({ + description: 'Field attribute', + type: String, + }) + @Expose() + attribute: string; + + @ApiProperty({ + description: 'Field type', + type: String, + }) + @Expose() + type: string; + + @ApiProperty({ + description: 'Field weight', + type: String, + }) + @Expose() + WEIGHT?: string; + + @ApiProperty({ + description: 'Field can be sorted', + type: Boolean, + }) + @Expose() + SORTABLE?: boolean; + + @ApiProperty({ + description: 'Attributes can have the NOINDEX option, which means they will not be indexed. ', + type: Boolean, + }) + @Expose() + NOINDEX?: boolean; + + @ApiProperty({ + description: 'Attribute is case sensitive', + type: Boolean, + }) + @Expose() + CASESENSITIVE?: boolean; + + @ApiProperty({ + description: `By default, for hashes (not with JSON) SORTABLE applies a normalization to the indexed value + (characters set to lowercase, removal of diacritics).`, + type: Boolean, + }) + @Expose() + UNF?: boolean; + + @ApiProperty({ + description: `Text attributes can have the NOSTEM argument that disables stemming when indexing its values. + This may be ideal for things like proper names.`, + type: Boolean, + }) + @Expose() + NOSTEM?: boolean; + + @ApiProperty({ + description: `Indicates how the text contained in the attribute is to be split into individual tags. + The default is ,. The value must be a single character.`, + type: String, + }) + @Expose() + SEPARATOR?: string; +} + +export class FieldStatisticsDto { + @ApiProperty({ + description: 'Field identifier', + type: String, + }) + @Expose() + identifier: string; + + @ApiProperty({ + description: 'Field attribute', + type: String, + }) + @Expose() + attribute: string; + + @ApiProperty({ + description: 'Field errors', + type: Object, + }) + @Expose() + [ 'Index Errors']: object; +} + +// The list of return fields from redis: https://redis.io/docs/latest/commands/ft.info/ + +export class IndexInfoDto { + // General + @ApiProperty({ + description: 'The index name that was defined when index was created', + type: String, + }) + @Expose() + @IsDefined() + index_name: string; + + @ApiProperty({ + description: 'The index options selected during FT.CREATE such as FILTER {filter}, LANGUAGE {default_lang}, etc.', + type: IndexOptionsDto, + }) + @Expose() + index_options: IndexOptionsDto; + + @ApiProperty({ + description: 'Includes key_type, hash or JSON; prefixes, if any; and default_score.', + type: IndexDefinitionDto, + }) + @Expose() + index_definition: IndexDefinitionDto; + + @ApiProperty({ + description: 'The index schema field names, types, and attributes.', + type: IndexAttibuteDto, + isArray: true, + }) + @Expose() + attributes: IndexAttibuteDto[]; + + @ApiProperty({ + description: 'The number of documents.', + type: String, + }) + @Expose() + num_docs: string; + + @ApiProperty({ + description: 'The maximum document ID.', + type: String, + }) + @Expose() + max_doc_id?: string; + + @ApiProperty({ + description: 'The number of distinct terms.', + type: String, + }) + @Expose() + num_terms?: string; + + @ApiProperty({ + description: 'The total number of records.', + type: String, + }) + @Expose() + num_records?: string; + + // Various size statistics + @ApiProperty({ + description: `The memory used by the inverted index, which is the core data structure + used for searching in RediSearch. The size is given in megabytes.`, + type: String, + }) + @Expose() + inverted_sz_mb?: string; + + @ApiProperty({ + description: `The memory used by the vector index, + which stores any vectors associated with each document.`, + type: String, + }) + @Expose() + vector_index_sz_mb?: string; + + @ApiProperty({ + description: 'The total number of blocks in the inverted index.', + type: String, + }) + @Expose() + total_inverted_index_blocks?: string; + + @ApiProperty({ + description: `The memory used by the offset vectors, + which store positional information for terms in documents.`, + type: String, + }) + @Expose() + offset_vectors_sz_mb?: string; + + @ApiProperty({ + description: `The memory used by the document table, + which contains metadata about each document in the index.`, + type: String, + }) + @Expose() + doc_table_size_mb?: string; + + @ApiProperty({ + description: `The memory used by sortable values, + which are values associated with documents and used for sorting purposes.`, + type: String, + }) + @Expose() + sortable_values_size_mb?: string; + + @ApiProperty({ + description: 'Tag overhead memory usage in mb', + type: String, + }) + @Expose() + tag_overhead_sz_mb?: string; + + @ApiProperty({ + description: 'Text overhead memory usage in mb', + type: String, + }) + @Expose() + text_overhead_sz_mb?: string; + + @ApiProperty({ + description: 'Total index memory size in mb', + type: String, + }) + @Expose() + total_index_memory_sz_mb?: string; + + @ApiProperty({ + description: `The memory used by the key table, + which stores the mapping between document IDs and Redis keys`, + type: String, + }) + @Expose() + key_table_size_mb?: string; + + @ApiProperty({ + description: 'The memory used by GEO-related fields.', + type: String, + }) + @Expose() + geoshapes_sz_mb?: string; + + @ApiProperty({ + description: 'The average number of records (including deletions) per document.', + type: String, + }) + @Expose() + records_per_doc_avg?: string; + + @ApiProperty({ + description: 'The average size of each record in bytes.', + type: String, + }) + @Expose() + bytes_per_record_avg?: string; + + @ApiProperty({ + description: 'The average number of offsets (position information) per term.', + type: String, + }) + @Expose() + offsets_per_term_avg?: string; + + @ApiProperty({ + description: 'The average number of bits used for offsets per record.', + type: String, + }) + @Expose() + offset_bits_per_record_avg?: string; + + // Indexing-related statistics + @ApiProperty({ + description: 'The number of failures encountered during indexing.', + type: String, + }) + @Expose() + hash_indexing_failures?: string; + + @ApiProperty({ + description: 'The total time taken for indexing in seconds.', + type: String, + }) + @Expose() + total_indexing_time?: string; + + @ApiProperty({ + description: 'Indicates whether the index is currently being generated.', + type: String, + }) + @Expose() + indexing?: string; + + @ApiProperty({ + description: 'The percentage of the index that has been successfully generated.', + type: String, + }) + @Expose() + percent_indexed?: string; + + @ApiProperty({ + description: 'The number of times the index has been used.', + type: Number, + }) + @Expose() + number_of_uses?: number; + + @ApiProperty({ + description: 'The index deletion flag. A value of 1 indicates index deletion is in progress.', + type: Number, + }) + @Expose() + cleaning?: number; + + // Other + @ApiProperty({ + description: 'Garbage collection statistics', + type: Object, + }) + @Expose() + gc_stats?: object; + + @ApiProperty({ + description: 'Cursor statistics', + type: Object, + }) + @Expose() + cursor_stats?: object; + + @ApiProperty({ + description: 'Dialect statistics: the number of times the index was searched using each DIALECT, 1 - 4.', + type: Object, + }) + @Expose() + dialect_stats?: object; + + @ApiProperty({ + description: `Index error statistics, including indexing failures, last indexing error, + and last indexing error key.`, + type: Object, + }) + @Expose() + ['Index Errors']?: object; + + @ApiProperty({ + description: 'Dialect statistics: the number of times the index was searched using each DIALECT, 1 - 4.', + type: FieldStatisticsDto, + isArray: true, + }) + @Expose() + ['field statistics']?: Array; +} diff --git a/redisinsight/api/src/modules/browser/redisearch/dto/index.ts b/redisinsight/api/src/modules/browser/redisearch/dto/index.ts index 2390227ecd..56e0b39d48 100644 --- a/redisinsight/api/src/modules/browser/redisearch/dto/index.ts +++ b/redisinsight/api/src/modules/browser/redisearch/dto/index.ts @@ -1,3 +1,4 @@ export * from './create.redisearch-index.dto'; export * from './search.redisearch.dto'; export * from './list.redisearch-indexes.response'; +export * from './index.info.dto'; diff --git a/redisinsight/api/src/modules/browser/redisearch/redisearch.controller.ts b/redisinsight/api/src/modules/browser/redisearch/redisearch.controller.ts index 7c3a9cdb55..7956f7445c 100644 --- a/redisinsight/api/src/modules/browser/redisearch/redisearch.controller.ts +++ b/redisinsight/api/src/modules/browser/redisearch/redisearch.controller.ts @@ -19,6 +19,8 @@ import { BrowserClientMetadata } from 'src/modules/browser/decorators/browser-cl import { ApiQueryRedisStringEncoding } from 'src/common/decorators'; import { CreateRedisearchIndexDto, + IndexInfoDto, + IndexInfoRequestBodyDto, ListRedisearchIndexesResponse, SearchRedisearchDto, } from 'src/modules/browser/redisearch/dto'; @@ -72,4 +74,15 @@ export class RedisearchController extends BrowserBaseController { ): Promise { return await this.service.search(clientMetadata, dto); } + + @Post('info') + @HttpCode(200) + @ApiOperation({ description: 'Get index info' }) + @ApiOkResponse({ type: IndexInfoDto }) + async info( + @BrowserClientMetadata() clientMetadata: ClientMetadata, + @Body() dto: IndexInfoRequestBodyDto, + ): Promise { + return await this.service.getInfo(clientMetadata, dto); + } } diff --git a/redisinsight/api/src/modules/browser/redisearch/redisearch.service.spec.ts b/redisinsight/api/src/modules/browser/redisearch/redisearch.service.spec.ts index 0a0910e660..5ce2d03d16 100644 --- a/redisinsight/api/src/modules/browser/redisearch/redisearch.service.spec.ts +++ b/redisinsight/api/src/modules/browser/redisearch/redisearch.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConflictException, ForbiddenException, + InternalServerErrorException, } from '@nestjs/common'; import { when } from 'jest-when'; import { @@ -20,6 +21,7 @@ import { } from 'src/modules/browser/redisearch/dto'; import { BrowserHistoryService } from 'src/modules/browser/browser-history/browser-history.service'; import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory'; +import { mockIndexInfoDto, mockIndexInfoRaw } from 'src/__mocks__/redisearch'; const keyName1 = Buffer.from('keyName1'); const keyName2 = Buffer.from('keyName2'); @@ -368,4 +370,32 @@ describe('RedisearchService', () => { expect(browserHistory.create).toHaveBeenCalled(); }); }); + + describe('getInfo', () => { + it('should get indexInfo', async () => { + const mockIndexName = 'idx:movie'; + when(standaloneClient.sendCommand) + .calledWith(['FT.INFO', mockIndexName], { replyEncoding: 'utf8' }) + .mockResolvedValue(mockIndexInfoRaw); + + const res = await service.getInfo(mockBrowserClientMetadata, { index: mockIndexName }); + expect(standaloneClient.sendCommand).toHaveBeenCalledWith([ + 'FT.INFO', + mockIndexName, + ], { replyEncoding: 'utf8' }); + expect(res).toEqual(mockIndexInfoDto); + }); + + it('should throw error if index name was not provided', async () => { + await expect(service.getInfo(mockBrowserClientMetadata, { index: '' })).rejects.toThrow('Index was not provided'); + }); + + it('should throw error if client was not created', async () => { + const error = new Error('Client was not created'); + databaseClientFactory.getOrCreateClient = jest.fn().mockRejectedValue(error); + + await expect(service.getInfo(mockBrowserClientMetadata, + { index: 'indexName' })).rejects.toThrow(InternalServerErrorException); + }); + }); }); diff --git a/redisinsight/api/src/modules/browser/redisearch/redisearch.service.ts b/redisinsight/api/src/modules/browser/redisearch/redisearch.service.ts index 325b41b555..39f2d4a856 100644 --- a/redisinsight/api/src/modules/browser/redisearch/redisearch.service.ts +++ b/redisinsight/api/src/modules/browser/redisearch/redisearch.service.ts @@ -11,6 +11,8 @@ import { catchAclError } from 'src/utils'; import { ClientMetadata } from 'src/common/models'; import { CreateRedisearchIndexDto, + IndexInfoDto, + IndexInfoRequestBodyDto, ListRedisearchIndexesResponse, SearchRedisearchDto, } from 'src/modules/browser/redisearch/dto'; @@ -28,6 +30,7 @@ import { RedisClientConnectionType, RedisClientNodeRole, } from 'src/modules/redis/client'; +import { convertIndexInfoReply } from '../utils/redisIndexInfo'; @Injectable() export class RedisearchService { @@ -138,6 +141,38 @@ export class RedisearchService { } } + /** + * Gets the info of a given index + * @param clientMetadata + * @param dto + */ + public async getInfo( + clientMetadata: ClientMetadata, + dto: IndexInfoRequestBodyDto, + ): Promise { + this.logger.log('Getting index info'); + + try { + const { index } = dto; + + if (!index) { + throw new Error('Index was not provided'); + } + + const client: RedisClient = await this.databaseClientFactory.getOrCreateClient(clientMetadata); + + const infoReply = await client.sendCommand( + ['FT.INFO', index], + { replyEncoding: 'utf8' }, + ) as string[][]; + + return plainToClass(IndexInfoDto, convertIndexInfoReply(infoReply)); + } catch (e) { + this.logger.error('Failed to get index info', 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 diff --git a/redisinsight/api/src/modules/browser/utils/redisIndexInfo.ts b/redisinsight/api/src/modules/browser/utils/redisIndexInfo.ts new file mode 100644 index 0000000000..edf1440d5b --- /dev/null +++ b/redisinsight/api/src/modules/browser/utils/redisIndexInfo.ts @@ -0,0 +1,56 @@ +import { chunk, isArray } from 'lodash'; + +type ArrayReplyEntry = string | string[]; +const errorField = 'Index Errors'; + +const infoFieldsToConvert = [ + 'index_options', + 'index_definition', + 'gc_stats', + 'cursor_stats', + 'dialect_stats', + errorField, +]; + +export const convertArrayReplyToObject = ( + input: ArrayReplyEntry[], +): { [key: string]: any } => { + const obj = {}; + + chunk(input, 2).forEach(([key, value]) => { + obj[key as string] = value; + }); + + return obj; +}; + +export const convertIndexInfoAttributeReply = (input: string[]): object => { + const attribute = convertArrayReplyToObject(input); + + if (isArray(input)) { + attribute['SORTABLE'] = input.includes('SORTABLE') || undefined; + attribute['NOINDEX'] = input.includes('NOINDEX') || undefined; + attribute['CASESENSITIVE'] = input.includes('CASESENSITIVE') || undefined; + attribute['UNF'] = input.includes('UNF') || undefined; + attribute['NOSTEM'] = input.includes('NOSTEM') || undefined; + } + + return attribute; +}; + +export const convertIndexInfoReply = (input: ArrayReplyEntry[]): object => { + const infoReply = convertArrayReplyToObject(input); + infoFieldsToConvert.forEach((field) => { + infoReply[field] = convertArrayReplyToObject(infoReply[field]); + }); + + infoReply['attributes'] = infoReply['attributes']?.map?.(convertIndexInfoAttributeReply); + infoReply['field statistics'] = infoReply['field statistics']?.map?.((sField) => { + const convertedField = convertArrayReplyToObject(sField); + if (convertedField[errorField] && Array.isArray(convertedField[errorField])) { + convertedField[errorField] = convertArrayReplyToObject(convertedField[errorField]); + } + return convertedField; + }); + return infoReply; +}; diff --git a/redisinsight/api/src/modules/workbench/dto/create-command-execution.dto.ts b/redisinsight/api/src/modules/workbench/dto/create-command-execution.dto.ts index 50e7388163..9664a64e97 100644 --- a/redisinsight/api/src/modules/workbench/dto/create-command-execution.dto.ts +++ b/redisinsight/api/src/modules/workbench/dto/create-command-execution.dto.ts @@ -1,51 +1,7 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - IsEnum, IsNotEmpty, IsOptional, IsString, -} from 'class-validator'; +import { PickType } from '@nestjs/swagger'; +import { CommandExecution } from 'src/modules/workbench/models/command-execution'; -export enum RunQueryMode { - Raw = 'RAW', - ASCII = 'ASCII', -} - -export enum ResultsMode { - Default = 'DEFAULT', - GroupMode = 'GROUP_MODE', - Silent = 'SILENT', -} - -export class CreateCommandExecutionDto { - @ApiProperty({ - type: String, - description: 'Redis command', - }) - @IsString() - @IsNotEmpty() - command: string; - - @ApiPropertyOptional({ - description: 'Workbench mode', - default: RunQueryMode.ASCII, - enum: RunQueryMode, - }) - @IsOptional() - @IsEnum(RunQueryMode, { - message: `mode must be a valid enum value. Valid values: ${Object.values( - RunQueryMode, - )}.`, - }) - mode?: RunQueryMode = RunQueryMode.ASCII; - - @ApiPropertyOptional({ - description: 'Workbench group mode', - default: ResultsMode.Default, - enum: ResultsMode, - }) - @IsOptional() - @IsEnum(ResultsMode, { - message: `resultsMode must be a valid enum value. Valid values: ${Object.values( - ResultsMode, - )}.`, - }) - resultsMode?: ResultsMode; -} +export class CreateCommandExecutionDto extends PickType( + CommandExecution, + ['command', 'mode', 'resultsMode', 'type'] as const, +) {} diff --git a/redisinsight/api/src/modules/workbench/dto/create-command-executions.dto.ts b/redisinsight/api/src/modules/workbench/dto/create-command-executions.dto.ts index f8749e20cd..9ca21174ba 100644 --- a/redisinsight/api/src/modules/workbench/dto/create-command-executions.dto.ts +++ b/redisinsight/api/src/modules/workbench/dto/create-command-executions.dto.ts @@ -1,39 +1,23 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty, PickType } from '@nestjs/swagger'; import { - IsEnum, IsArray, IsDefined, IsOptional, IsString, ArrayNotEmpty, + IsArray, IsDefined, IsString, ArrayNotEmpty, } from 'class-validator'; -import { RunQueryMode, ResultsMode } from './create-command-execution.dto'; +import { CommandExecution } from 'src/modules/workbench/models/command-execution'; +import { Expose } from 'class-transformer'; -export class CreateCommandExecutionsDto { +export class CreateCommandExecutionsDto extends PickType( + CommandExecution, + ['mode', 'resultsMode', 'type'] as const, +) { @ApiProperty({ isArray: true, type: String, description: 'Redis commands', }) + @Expose() @IsArray() @ArrayNotEmpty() @IsDefined() @IsString({ each: true }) commands: string[]; - - @ApiPropertyOptional({ - description: 'Workbench mode', - default: RunQueryMode.ASCII, - enum: RunQueryMode, - }) - @IsOptional() - @IsEnum(RunQueryMode, { - message: `mode must be a valid enum value. Valid values: ${Object.values( - RunQueryMode, - )}.`, - }) - mode?: RunQueryMode = RunQueryMode.ASCII; - - @IsOptional() - @IsEnum(ResultsMode, { - message: `resultsMode must be a valid enum value. Valid values: ${Object.values( - ResultsMode, - )}.`, - }) - resultsMode?: ResultsMode; } diff --git a/redisinsight/api/src/modules/workbench/entities/command-execution.entity.ts b/redisinsight/api/src/modules/workbench/entities/command-execution.entity.ts index 3d725b6151..09e56ba76f 100644 --- a/redisinsight/api/src/modules/workbench/entities/command-execution.entity.ts +++ b/redisinsight/api/src/modules/workbench/entities/command-execution.entity.ts @@ -2,9 +2,9 @@ import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, JoinColumn, Index, } from 'typeorm'; import { DatabaseEntity } from 'src/modules/database/entities/database.entity'; -import { RunQueryMode, ResultsMode } from 'src/modules/workbench/dto/create-command-execution.dto'; import { Expose } from 'class-transformer'; import { IsInt, Min } from 'class-validator'; +import { CommandExecutionType, ResultsMode, RunQueryMode } from 'src/modules/workbench/models/command-execution'; import { DataAsJsonString } from 'src/common/decorators'; @Entity('command_execution') @@ -25,6 +25,7 @@ export class CommandExecutionEntity { }, ) @JoinColumn({ name: 'databaseId' }) + @Expose() database: DatabaseEntity; @Column({ nullable: false, type: 'text' }) @@ -42,7 +43,7 @@ export class CommandExecutionEntity { @Column({ nullable: true }) @Expose() - role?: string = null; + role?: string; @Column({ nullable: true }) @Expose() @@ -56,7 +57,7 @@ export class CommandExecutionEntity { @Column({ nullable: true }) @DataAsJsonString() @Expose() - nodeOptions?: string = null; + nodeOptions?: string; @Column({ nullable: true }) encryption: string; @@ -71,12 +72,12 @@ export class CommandExecutionEntity { @Min(0) db?: number; + @Column({ nullable: false, default: CommandExecutionType.Workbench }) + @Expose() + type?: string = CommandExecutionType.Workbench; + @CreateDateColumn() @Index() @Expose() createdAt: Date; - - constructor(entity: Partial) { - Object.assign(this, entity); - } } diff --git a/redisinsight/api/src/modules/workbench/models/command-execution-result.ts b/redisinsight/api/src/modules/workbench/models/command-execution-result.ts index 2ba5d706a8..51852ef731 100644 --- a/redisinsight/api/src/modules/workbench/models/command-execution-result.ts +++ b/redisinsight/api/src/modules/workbench/models/command-execution-result.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { CommandExecutionStatus } from 'src/modules/cli/dto/cli.dto'; +import { Expose } from 'class-transformer'; export class CommandExecutionResult { @ApiProperty({ @@ -7,15 +8,13 @@ export class CommandExecutionResult { default: CommandExecutionStatus.Success, enum: CommandExecutionStatus, }) + @Expose() status: CommandExecutionStatus; @ApiProperty({ type: String, description: 'Redis response', }) + @Expose() response: any; - - constructor(partial: Partial = {}) { - Object.assign(this, partial); - } } diff --git a/redisinsight/api/src/modules/workbench/models/command-execution.ts b/redisinsight/api/src/modules/workbench/models/command-execution.ts index d7c0da3041..f22b9beb3f 100644 --- a/redisinsight/api/src/modules/workbench/models/command-execution.ts +++ b/redisinsight/api/src/modules/workbench/models/command-execution.ts @@ -1,10 +1,32 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { - IsDefined, IsInt, IsOptional, Min, + IsDefined, + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Min, } from 'class-validator'; import { CommandExecutionResult } from 'src/modules/workbench/models/command-execution-result'; -import { RunQueryMode, ResultsMode } from 'src/modules/workbench/dto/create-command-execution.dto'; -import { Expose } from 'class-transformer'; +import { Expose, Type } from 'class-transformer'; +import { Default } from 'src/common/decorators'; + +export enum RunQueryMode { + Raw = 'RAW', + ASCII = 'ASCII', +} + +export enum ResultsMode { + Default = 'DEFAULT', + GroupMode = 'GROUP_MODE', + Silent = 'SILENT', +} + +export enum CommandExecutionType { + Workbench = 'WORKBENCH', + Search = 'SEARCH', +} export class ResultsSummary { @ApiProperty({ @@ -45,9 +67,11 @@ export class CommandExecution { databaseId: string; @ApiProperty({ - description: 'Redis command executed', + description: 'Redis command', type: String, }) + @IsString() + @IsNotEmpty() @Expose() command: string; @@ -57,7 +81,14 @@ export class CommandExecution { enum: RunQueryMode, }) @Expose() - mode?: RunQueryMode = RunQueryMode.ASCII; + @IsOptional() + @IsEnum(RunQueryMode, { + message: `mode must be a valid enum value. Valid values: ${Object.values( + RunQueryMode, + )}.`, + }) + @Default(RunQueryMode.ASCII) + mode?: RunQueryMode; @ApiPropertyOptional({ description: 'Workbench result mode', @@ -65,7 +96,14 @@ export class CommandExecution { enum: ResultsMode, }) @Expose() - resultsMode?: ResultsMode = ResultsMode.Default; + @IsOptional() + @IsEnum(ResultsMode, { + message: `resultsMode must be a valid enum value. Valid values: ${Object.values( + ResultsMode, + )}.`, + }) + @Default(ResultsMode.Default) + resultsMode?: ResultsMode; @ApiPropertyOptional({ description: 'Workbench executions summary', @@ -79,6 +117,7 @@ export class CommandExecution { type: () => CommandExecutionResult, isArray: true, }) + @Type(() => CommandExecutionResult) @Expose() result: CommandExecutionResult[]; @@ -113,7 +152,18 @@ export class CommandExecution { @IsOptional() db?: number; - constructor(partial: Partial = {}) { - Object.assign(this, partial); - } + @ApiPropertyOptional({ + description: 'Command execution type. Used to distinguish between search and workbench', + default: CommandExecutionType.Workbench, + enum: CommandExecutionType, + }) + @Expose() + @IsOptional() + @IsEnum(CommandExecutionType, { + message: `type must be a valid enum value. Valid values: ${Object.values( + CommandExecutionType, + )}.`, + }) + @Default(CommandExecutionType.Workbench) + type?: CommandExecutionType; } diff --git a/redisinsight/api/src/modules/workbench/models/command-executions.filter.ts b/redisinsight/api/src/modules/workbench/models/command-executions.filter.ts new file mode 100644 index 0000000000..0cc3dccbe9 --- /dev/null +++ b/redisinsight/api/src/modules/workbench/models/command-executions.filter.ts @@ -0,0 +1,4 @@ +import { PickType } from '@nestjs/swagger'; +import { CommandExecution } from 'src/modules/workbench/models/command-execution'; + +export class CommandExecutionFilter extends PickType(CommandExecution, ['type'] as const) {} diff --git a/redisinsight/api/src/modules/workbench/models/plugin-command-execution.ts b/redisinsight/api/src/modules/workbench/models/plugin-command-execution.ts index 8dc38bb30f..94add4f14d 100644 --- a/redisinsight/api/src/modules/workbench/models/plugin-command-execution.ts +++ b/redisinsight/api/src/modules/workbench/models/plugin-command-execution.ts @@ -3,9 +3,4 @@ import { OmitType, PartialType } from '@nestjs/swagger'; export class PluginCommandExecution extends PartialType( OmitType(CommandExecution, ['createdAt', 'id'] as const), -) { - constructor(partial: Partial) { - super(); - Object.assign(this, partial); - } -} +) {} diff --git a/redisinsight/api/src/modules/workbench/plugins.service.spec.ts b/redisinsight/api/src/modules/workbench/plugins.service.spec.ts index ea1580cf60..89358ff46f 100644 --- a/redisinsight/api/src/modules/workbench/plugins.service.spec.ts +++ b/redisinsight/api/src/modules/workbench/plugins.service.spec.ts @@ -1,20 +1,16 @@ import { Test, TestingModule } from '@nestjs/testing'; import { mockClientMetadata, - mockDatabase, + mockCommandExecutionUnsupportedCommandResult, + mockCreateCommandExecutionDto, mockDatabaseClientFactory, + mockPluginCommandExecution, mockWhitelistCommandsResponse, mockWorkbenchClientMetadata, + mockWorkbenchCommandsExecutor, } from 'src/__mocks__'; import { v4 as uuidv4 } from 'uuid'; import { WorkbenchCommandsExecutor } from 'src/modules/workbench/providers/workbench-commands.executor'; -import { - CreateCommandExecutionDto, - ResultsMode, - RunQueryMode, -} from 'src/modules/workbench/dto/create-command-execution.dto'; -import { CommandExecutionResult } from 'src/modules/workbench/models/command-execution-result'; -import { CommandExecutionStatus } from 'src/modules/cli/dto/cli.dto'; import { BadRequestException } from '@nestjs/common'; import ERROR_MESSAGES from 'src/constants/error-messages'; import { PluginsService } from 'src/modules/workbench/plugins.service'; @@ -24,27 +20,10 @@ import { PluginStateRepository } from 'src/modules/workbench/repositories/plugin import { PluginState } from 'src/modules/workbench/models/plugin-state'; import config from 'src/utils/config'; import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory'; +import {CommandExecutionType, ResultsMode, RunQueryMode} from 'src/modules/workbench/models/command-execution' const PLUGINS_CONFIG = config.get('plugins'); -const mockCreateCommandExecutionDto: CreateCommandExecutionDto = { - command: 'get foo', - mode: RunQueryMode.ASCII, - resultsMode: ResultsMode.Default, -}; - -const mockCommandExecutionResults: CommandExecutionResult[] = [ - new CommandExecutionResult({ - status: CommandExecutionStatus.Success, - response: 'OK', - }), -]; -const mockPluginCommandExecution = new PluginCommandExecution({ - ...mockCreateCommandExecutionDto, - databaseId: mockDatabase.id, - result: mockCommandExecutionResults, -}); - const mockVisualizationId = 'pluginName_visualizationName'; const mockCommandExecutionId = uuidv4(); const mockState = { @@ -80,9 +59,7 @@ describe('PluginsService', () => { PluginsService, { provide: WorkbenchCommandsExecutor, - useFactory: () => ({ - sendCommand: jest.fn(), - }), + useFactory: mockWorkbenchCommandsExecutor, }, { provide: PluginCommandsWhitelistProvider, @@ -107,7 +84,6 @@ describe('PluginsService', () => { describe('sendCommand', () => { it('should successfully execute command', async () => { - workbenchCommandsExecutor.sendCommand.mockResolvedValueOnce(mockCommandExecutionResults); pluginsCommandsWhitelistProvider.getWhitelistCommands.mockResolvedValueOnce(mockWhitelistCommandsResponse); const result = await service.sendCommand(mockWorkbenchClientMetadata, mockCreateCommandExecutionDto); @@ -125,14 +101,13 @@ describe('PluginsService', () => { const result = await service.sendCommand(mockWorkbenchClientMetadata, dto); - expect(result).toEqual(new PluginCommandExecution({ + expect(result).toEqual({ ...dto, databaseId: mockWorkbenchClientMetadata.databaseId, - result: [new CommandExecutionResult({ - response: ERROR_MESSAGES.PLUGIN_COMMAND_NOT_SUPPORTED('subscribe'.toUpperCase()), - status: CommandExecutionStatus.Fail, - })], - })); + result: [mockCommandExecutionUnsupportedCommandResult], + resultsMode: ResultsMode.Default, + type: CommandExecutionType.Workbench, + }); expect(workbenchCommandsExecutor.sendCommand).not.toHaveBeenCalled(); }); it('should throw an error when command execution failed', async () => { @@ -140,7 +115,6 @@ describe('PluginsService', () => { workbenchCommandsExecutor.sendCommand.mockRejectedValueOnce(new BadRequestException('error')); const dto = { - ...mockCommandExecutionResults, command: 'get foo', mode: RunQueryMode.ASCII, }; @@ -155,7 +129,6 @@ describe('PluginsService', () => { }); describe('getWhitelistCommands', () => { it('should successfully return whitelisted commands', async () => { - workbenchCommandsExecutor.sendCommand.mockResolvedValueOnce(mockCommandExecutionResults); pluginsCommandsWhitelistProvider.getWhitelistCommands.mockResolvedValueOnce(mockWhitelistCommandsResponse); const result = await service.getWhitelistCommands(mockWorkbenchClientMetadata); diff --git a/redisinsight/api/src/modules/workbench/plugins.service.ts b/redisinsight/api/src/modules/workbench/plugins.service.ts index dda9d1b02b..8ca00e88ba 100644 --- a/redisinsight/api/src/modules/workbench/plugins.service.ts +++ b/redisinsight/api/src/modules/workbench/plugins.service.ts @@ -49,13 +49,13 @@ export class PluginsService { }); } catch (error) { if (error instanceof CommandNotSupportedError) { - return new PluginCommandExecution({ + return plainToClass(PluginCommandExecution, { ...dto, databaseId: clientMetadata.databaseId, - result: [new CommandExecutionResult({ + result: [{ response: error.message, status: CommandExecutionStatus.Fail, - })], + }], }); } diff --git a/redisinsight/api/src/modules/workbench/providers/workbench-commands.executor.spec.ts b/redisinsight/api/src/modules/workbench/providers/workbench-commands.executor.spec.ts index 741b61d50b..4db9dc9cd6 100644 --- a/redisinsight/api/src/modules/workbench/providers/workbench-commands.executor.spec.ts +++ b/redisinsight/api/src/modules/workbench/providers/workbench-commands.executor.spec.ts @@ -11,7 +11,6 @@ import { unknownCommand } from 'src/constants'; import { WorkbenchCommandsExecutor } from 'src/modules/workbench/providers/workbench-commands.executor'; import { CreateCommandExecutionDto, - RunQueryMode, } from 'src/modules/workbench/dto/create-command-execution.dto'; import { CommandExecutionResult } from 'src/modules/workbench/models/command-execution-result'; import { CommandExecutionStatus } from 'src/modules/cli/dto/cli.dto'; @@ -19,6 +18,7 @@ import { ServiceUnavailableException } from '@nestjs/common'; import { CommandNotSupportedError, CommandParsingError } from 'src/modules/cli/constants/errors'; import { FormatterManager, IFormatterStrategy, FormatterTypes } from 'src/common/transformers'; import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory'; +import { RunQueryMode } from 'src/modules/workbench/models/command-execution'; import { WorkbenchAnalyticsService } from '../services/workbench-analytics/workbench-analytics.service'; const MOCK_ERROR_MESSAGE = 'Some error'; diff --git a/redisinsight/api/src/modules/workbench/providers/workbench-commands.executor.ts b/redisinsight/api/src/modules/workbench/providers/workbench-commands.executor.ts index 1228edbbf9..223da5efcf 100644 --- a/redisinsight/api/src/modules/workbench/providers/workbench-commands.executor.ts +++ b/redisinsight/api/src/modules/workbench/providers/workbench-commands.executor.ts @@ -11,7 +11,7 @@ import { } from 'src/modules/cli/constants/errors'; import { unknownCommand } from 'src/constants'; import { CommandExecutionResult } from 'src/modules/workbench/models/command-execution-result'; -import { CreateCommandExecutionDto, RunQueryMode } from 'src/modules/workbench/dto/create-command-execution.dto'; +import { CreateCommandExecutionDto } from 'src/modules/workbench/dto/create-command-execution.dto'; import { FormatterManager, FormatterTypes, @@ -20,6 +20,7 @@ import { } from 'src/common/transformers'; import { RedisClient } from 'src/modules/redis/client'; import { getAnalyticsDataFromIndexInfo } from 'src/utils'; +import { RunQueryMode } from 'src/modules/workbench/models/command-execution'; import { WorkbenchAnalyticsService } from '../services/workbench-analytics/workbench-analytics.service'; @Injectable() @@ -44,7 +45,7 @@ export class WorkbenchCommandsExecutor { /** * Entrypoint for any CommandExecution - * Will determine type of a command (standalone, per node(s)) and format, and execute it + * Will determine type of command (standalone, per node(s)) and format, and execute it * Also sis a single place of analytics events invocation * @param client * @param dto diff --git a/redisinsight/api/src/modules/workbench/repositories/command-execution.repository.ts b/redisinsight/api/src/modules/workbench/repositories/command-execution.repository.ts index ab13569de4..0125933cab 100644 --- a/redisinsight/api/src/modules/workbench/repositories/command-execution.repository.ts +++ b/redisinsight/api/src/modules/workbench/repositories/command-execution.repository.ts @@ -1,14 +1,61 @@ import { CommandExecution } from 'src/modules/workbench/models/command-execution'; import { ShortCommandExecution } from 'src/modules/workbench/models/short-command-execution'; import { SessionMetadata } from 'src/common/models'; +import { CommandExecutionFilter } from 'src/modules/workbench/models/command-executions.filter'; export abstract class CommandExecutionRepository { + /** + * Create multiple entities + * + * @param sessionMetadata + * @param commandExecutions + */ abstract createMany( sessionMetadata: SessionMetadata, commandExecutions: Partial[], ): Promise; - abstract getList(sessionMetadata: SessionMetadata, databaseId: string): Promise; + + /** + * Fetch only needed fields to show in list to avoid huge decryption work + * + * @param sessionMetadata + * @param databaseId + * @param filter + */ + abstract getList( + sessionMetadata: SessionMetadata, + databaseId: string, + filter: CommandExecutionFilter, + ): Promise; + + /** + * Get single command execution entity, decrypt and convert to model + * + * @param sessionMetadata + * @param databaseId + * @param id + */ abstract getOne(sessionMetadata: SessionMetadata, databaseId: string, id: string): Promise; + + /** + * Delete single item + * + * @param sessionMetadata + * @param databaseId + * @param id + */ abstract delete(sessionMetadata: SessionMetadata, databaseId: string, id: string): Promise; - abstract deleteAll(sessionMetadata: SessionMetadata, databaseId: string): Promise; + + /** + * Delete all items + * + * @param sessionMetadata + * @param databaseId + * @param filter + */ + abstract deleteAll( + sessionMetadata: SessionMetadata, + databaseId: string, + filter: CommandExecutionFilter, + ): Promise; } diff --git a/redisinsight/api/src/modules/workbench/repositories/local-command-execution.repository.spec.ts b/redisinsight/api/src/modules/workbench/repositories/local-command-execution.repository.spec.ts index 10703459f9..e5143d7f67 100644 --- a/redisinsight/api/src/modules/workbench/repositories/local-command-execution.repository.spec.ts +++ b/redisinsight/api/src/modules/workbench/repositories/local-command-execution.repository.spec.ts @@ -1,23 +1,19 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { v4 as uuidv4 } from 'uuid'; +import { when } from 'jest-when'; import { mockEncryptionService, - mockEncryptResult, - mockQueryBuilderGetMany, - mockQueryBuilderGetManyRaw, mockRepository, - mockDatabase, MockType, mockSessionMetadata, + mockCommandExecutionEntity, + mockCommandExecution, + mockCommendExecutionHugeResultPlaceholder, + mockCommendExecutionHugeResultPlaceholderEncrypted, + mockShortCommandExecutionEntity, + mockShortCommandExecution, + mockCommandExecutionFilter, } from 'src/__mocks__'; -import { omit } from 'lodash'; -import { - CreateCommandExecutionDto, - RunQueryMode, -} from 'src/modules/workbench/dto/create-command-execution.dto'; -import { CommandExecution } from 'src/modules/workbench/models/command-execution'; -import { CommandExecutionResult } from 'src/modules/workbench/models/command-execution-result'; -import { CommandExecutionStatus, ICliExecResultFromNode } from 'src/modules/cli/dto/cli.dto'; +import { CommandExecutionStatus } from 'src/modules/cli/dto/cli.dto'; import { NotFoundException } from '@nestjs/common'; import { EncryptionService } from 'src/modules/encryption/encryption.service'; import { Repository } from 'typeorm'; @@ -30,49 +26,14 @@ import { LocalCommandExecutionRepository } from 'src/modules/workbench/repositor const WORKBENCH_CONFIG = config.get('workbench'); -const mockNodeEndpoint = { - host: '127.0.0.1', - port: 6379, -}; - -const mockCliNodeResponse: ICliExecResultFromNode = { - ...mockNodeEndpoint, - response: 'OK', - status: CommandExecutionStatus.Success, -}; - -const mockCreateCommandExecutionDto: CreateCommandExecutionDto = { - command: 'set foo bar', - mode: RunQueryMode.ASCII, -}; - -const mockCommandExecutionEntity = new CommandExecutionEntity({ - id: uuidv4(), - databaseId: mockDatabase.id, - command: mockEncryptResult.data, - result: mockEncryptResult.data, - mode: mockCreateCommandExecutionDto.mode, - encryption: 'KEYTAR', - createdAt: new Date(), -}); - -const mockCommandExecutionResult: CommandExecutionResult = { - status: mockCliNodeResponse.status, - response: mockCliNodeResponse.response, -}; - -const mockCommandExecutionPartial: Partial = new CommandExecution({ - ...mockCreateCommandExecutionDto, - databaseId: mockDatabase.id, - result: [mockCommandExecutionResult], -}); - describe('LocalCommandExecutionRepository', () => { let service: LocalCommandExecutionRepository; let repository: MockType>; - let encryptionService; + let encryptionService: MockType; beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ providers: [ LocalCommandExecutionRepository, @@ -89,177 +50,178 @@ describe('LocalCommandExecutionRepository', () => { service = module.get(LocalCommandExecutionRepository); repository = module.get(getRepositoryToken(CommandExecutionEntity)); - encryptionService = module.get(EncryptionService); + encryptionService = module.get(EncryptionService); + + when(encryptionService.encrypt) + .calledWith(mockCommandExecution.command) + .mockResolvedValue({ + data: mockCommandExecutionEntity.command, + encryption: mockCommandExecutionEntity.encryption, + }) + .calledWith(JSON.stringify(mockCommandExecution.result)) + .mockResolvedValue({ + data: mockCommandExecutionEntity.result, + encryption: mockCommandExecutionEntity.encryption, + }) + .calledWith(JSON.stringify([mockCommendExecutionHugeResultPlaceholder])) + .mockResolvedValue({ + data: mockCommendExecutionHugeResultPlaceholderEncrypted, + encryption: mockCommandExecutionEntity.encryption, + }); + + when(encryptionService.decrypt) + .calledWith(mockCommandExecutionEntity.command, expect.anything()) + .mockResolvedValue(mockCommandExecution.command) + .calledWith(mockCommandExecutionEntity.result, expect.anything()) + .mockResolvedValue(JSON.stringify(mockCommandExecution.result)); + + repository.save.mockReturnValue(mockCommandExecutionEntity); + repository.findOneBy.mockReturnValue(mockCommandExecutionEntity); }); describe('create', () => { - it('should process new entity', async () => { - repository.save.mockReturnValueOnce([mockCommandExecutionEntity]); - encryptionService.encrypt.mockReturnValue(mockEncryptResult); + let cleanupSpy: jest.SpyInstance; - expect(await service.createMany(mockSessionMetadata, [mockCommandExecutionPartial])).toEqual([{ - ...mockCommandExecutionPartial, - id: mockCommandExecutionEntity.id, - createdAt: mockCommandExecutionEntity.createdAt, - }]); + beforeEach(() => { + cleanupSpy = jest.spyOn(service as any, 'cleanupDatabaseHistory'); }); - it('should return full result even if size limit exceeded', async () => { - repository.save.mockReturnValueOnce([mockCommandExecutionEntity]); - encryptionService.encrypt.mockReturnValue(mockEncryptResult); - - const executionResult = [{ - status: CommandExecutionStatus.Success, - response: `${Buffer.alloc(WORKBENCH_CONFIG.maxResultSize, 'a').toString()}`, - }]; + it('should process new entity', async () => { expect(await service.createMany(mockSessionMetadata, [{ - ...mockCommandExecutionPartial, - result: executionResult, - }])).toEqual([{ - ...mockCommandExecutionPartial, - id: mockCommandExecutionEntity.id, - createdAt: mockCommandExecutionEntity.createdAt, - result: executionResult, - }]); - - expect(encryptionService.encrypt).toHaveBeenLastCalledWith(JSON.stringify([{ - status: CommandExecutionStatus.Success, - response: 'Results have been deleted since they exceed 1 MB. Re-run the command to see new results.', - }])); + ...mockCommandExecution, + id: undefined, + createdAt: undefined, + }])).toEqual([mockCommandExecution]); + expect(repository.save).toHaveBeenCalledWith({ + ...mockCommandExecutionEntity, + id: undefined, + createdAt: undefined, + }); + expect(cleanupSpy).toBeCalledTimes(1); + expect(cleanupSpy).toHaveBeenCalledWith(mockCommandExecution.databaseId, { type: mockCommandExecution.type }); }); - it('should return with flag isNotStored="true" even if size limit exceeded', async () => { - repository.save.mockReturnValueOnce([{ ...mockCommandExecutionEntity, isNotStored: true }]); - encryptionService.encrypt.mockReturnValue(mockEncryptResult); - + it('should return full result even if size limit exceeded', async () => { const executionResult = [{ status: CommandExecutionStatus.Success, response: `${Buffer.alloc(WORKBENCH_CONFIG.maxResultSize, 'a').toString()}`, }]; expect(await service.createMany(mockSessionMetadata, [{ - ...mockCommandExecutionPartial, + ...mockCommandExecution, result: executionResult, }])).toEqual([{ - ...mockCommandExecutionPartial, - id: mockCommandExecutionEntity.id, - createdAt: mockCommandExecutionEntity.createdAt, + ...mockCommandExecution, result: executionResult, - isNotStored: true, + isNotStored: true, // double check that for such cases special flag returned }]); - - expect(encryptionService.encrypt).toHaveBeenLastCalledWith(JSON.stringify([ - new CommandExecutionResult({ - status: CommandExecutionStatus.Success, - response: 'Results have been deleted since they exceed 1 MB. Re-run the command to see new results.', - }), - ])); + expect(repository.save).toHaveBeenCalledWith({ + ...mockCommandExecutionEntity, + command: mockCommandExecutionEntity.command, + result: mockCommendExecutionHugeResultPlaceholderEncrypted, + }); }); }); describe('getList', () => { it('should return list (2) of command execution', async () => { - const entityResponse = new CommandExecutionEntity({ ...omit(mockCommandExecutionEntity, 'result') }); - mockQueryBuilderGetMany.mockReturnValueOnce([entityResponse, entityResponse]); - encryptionService.decrypt.mockReturnValueOnce(mockCreateCommandExecutionDto.command); - encryptionService.decrypt.mockReturnValueOnce(mockCreateCommandExecutionDto.command); - - const commandExecution = new CommandExecution({ - ...omit(mockCommandExecutionPartial, ['result']), - id: mockCommandExecutionEntity.id, - createdAt: mockCommandExecutionEntity.createdAt, - }); - - expect(await service.getList(mockSessionMetadata, mockCommandExecutionEntity.databaseId)).toEqual([ - commandExecution, - commandExecution, + repository.createQueryBuilder() + .getMany.mockReturnValueOnce([mockShortCommandExecutionEntity, mockShortCommandExecutionEntity]); + + expect(await service.getList( + mockSessionMetadata, + mockCommandExecutionEntity.databaseId, + mockCommandExecutionFilter, + )).toEqual([ + mockShortCommandExecution, + mockShortCommandExecution, ]); + expect(repository.createQueryBuilder().where).toHaveBeenCalledWith({ + databaseId: mockCommandExecution.databaseId, + type: mockCommandExecutionFilter.type, + }); }); it('should return list (1) of command execution without failed decrypted item', async () => { - const entityResponse = new CommandExecutionEntity({ ...omit(mockCommandExecutionEntity, 'result') }); - mockQueryBuilderGetMany.mockReturnValueOnce([entityResponse, entityResponse]); - encryptionService.decrypt.mockReturnValueOnce(mockCreateCommandExecutionDto.command); + repository.createQueryBuilder().getMany.mockResolvedValueOnce([ + mockShortCommandExecutionEntity, + { + ...mockShortCommandExecutionEntity, + command: 'something that can not be decrypted', + }, + ]); + encryptionService.decrypt.mockResolvedValueOnce(mockShortCommandExecution.command); encryptionService.decrypt.mockRejectedValueOnce(new KeytarDecryptionErrorException()); - const commandExecution = new CommandExecution({ - ...omit(mockCommandExecutionPartial, ['result']), - id: mockCommandExecutionEntity.id, - createdAt: mockCommandExecutionEntity.createdAt, - }); - - expect(await service.getList(mockSessionMetadata, mockCommandExecutionEntity.databaseId)).toEqual([ - commandExecution, + expect(await service.getList( + mockSessionMetadata, + mockCommandExecution.databaseId, + mockCommandExecutionFilter, + )).toEqual([ + mockShortCommandExecution, ]); }); }); describe('getOne', () => { it('should return decrypted and transformed command execution', async () => { - repository.findOneBy.mockResolvedValueOnce(mockCommandExecutionEntity); - encryptionService.decrypt.mockReturnValueOnce(mockCreateCommandExecutionDto.command); - encryptionService.decrypt.mockReturnValueOnce(JSON.stringify([mockCommandExecutionResult])); - - const commandExecution = new CommandExecution({ - ...mockCommandExecutionPartial, - id: mockCommandExecutionEntity.id, - createdAt: mockCommandExecutionEntity.createdAt, + expect(await service.getOne(mockSessionMetadata, mockCommandExecution.databaseId, mockCommandExecution.id)) + .toEqual(mockCommandExecution); + expect(repository.findOneBy).toHaveBeenCalledWith({ + id: mockCommandExecution.id, + databaseId: mockCommandExecution.databaseId, }); - - expect(await service.getOne(mockSessionMetadata, mockDatabase.id, mockCommandExecutionEntity.id)).toEqual( - commandExecution, - ); }); it('should return null fields in case of decryption errors', async () => { - repository.findOneBy.mockResolvedValueOnce(mockCommandExecutionEntity); - encryptionService.decrypt.mockReturnValueOnce(mockCreateCommandExecutionDto.command); + encryptionService.decrypt.mockReturnValueOnce(mockCommandExecution.command); encryptionService.decrypt.mockRejectedValueOnce(new KeytarDecryptionErrorException()); - const commandExecution = new CommandExecution({ - ...mockCommandExecutionPartial, - id: mockCommandExecutionEntity.id, - createdAt: mockCommandExecutionEntity.createdAt, - result: null, - }); - - expect(await service.getOne(mockSessionMetadata, mockDatabase.id, mockCommandExecutionEntity.id)).toEqual( - commandExecution, - ); + expect(await service.getOne(mockSessionMetadata, mockCommandExecution.databaseId, mockCommandExecution.id)) + .toEqual({ + ...mockCommandExecution, + result: null, + }); }); it('should return not found exception', async () => { repository.findOneBy.mockResolvedValueOnce(null); - try { - await service.getOne(mockSessionMetadata, mockDatabase.id, mockCommandExecutionEntity.id); - fail(); - } catch (e) { - expect(e).toBeInstanceOf(NotFoundException); - expect(e.message).toEqual(ERROR_MESSAGES.COMMAND_EXECUTION_NOT_FOUND); - } + await expect(service.getOne(mockSessionMetadata, mockCommandExecution.databaseId, mockCommandExecution.id)) + .rejects.toEqual(new NotFoundException(ERROR_MESSAGES.COMMAND_EXECUTION_NOT_FOUND)); }); }); describe('delete', () => { it('Should not return anything on delete', async () => { repository.delete.mockResolvedValueOnce(1); - expect(await service.delete(mockSessionMetadata, mockDatabase.id, mockCommandExecutionEntity.id)).toEqual( - undefined, - ); + expect(await service.delete(mockSessionMetadata, mockCommandExecution.databaseId, mockCommandExecution.id)) + .toEqual(undefined); + expect(repository.delete).toHaveBeenCalledWith({ + id: mockCommandExecution.id, + databaseId: mockCommandExecution.databaseId, + }); }); }); describe('deleteAll', () => { it('Should not return anything on delete', async () => { repository.delete.mockResolvedValueOnce(1); - expect(await service.deleteAll(mockSessionMetadata, mockDatabase.id)).toEqual( - undefined, - ); + expect(await service.deleteAll(mockSessionMetadata, mockCommandExecution.databaseId, mockCommandExecutionFilter)) + .toEqual(undefined); + expect(repository.delete).toHaveBeenCalledWith({ + databaseId: mockCommandExecution.databaseId, + type: mockCommandExecutionFilter.type, + }); }); }); describe('cleanupDatabaseHistory', () => { it('Should should not return anything on cleanup', async () => { - mockQueryBuilderGetManyRaw.mockReturnValueOnce([ + repository.createQueryBuilder().getRawMany.mockReturnValueOnce([ { id: mockCommandExecutionEntity.id }, { id: mockCommandExecutionEntity.id }, ]); - expect(await service['cleanupDatabaseHistory'](mockDatabase.id)).toEqual( - undefined, - ); + expect(await service['cleanupDatabaseHistory'](mockCommandExecution.databaseId, mockCommandExecutionFilter)) + .toEqual(undefined); + expect(repository.createQueryBuilder().where).toHaveBeenCalledWith({ + databaseId: mockCommandExecution.databaseId, + type: mockCommandExecutionFilter.type, + }); + expect(repository.createQueryBuilder().whereInIds) + .toHaveBeenCalledWith([mockCommandExecutionEntity.id, mockCommandExecutionEntity.id]); }); }); }); diff --git a/redisinsight/api/src/modules/workbench/repositories/local-command-execution.repository.ts b/redisinsight/api/src/modules/workbench/repositories/local-command-execution.repository.ts index be0f4c6e05..5207b41cd3 100644 --- a/redisinsight/api/src/modules/workbench/repositories/local-command-execution.repository.ts +++ b/redisinsight/api/src/modules/workbench/repositories/local-command-execution.repository.ts @@ -14,6 +14,7 @@ import { CommandExecutionStatus } from 'src/modules/cli/dto/cli.dto'; import { CommandExecutionRepository } from 'src/modules/workbench/repositories/command-execution.repository'; import config from 'src/utils/config'; import { SessionMetadata } from 'src/common/models'; +import { CommandExecutionFilter } from 'src/modules/workbench/models/command-executions.filter'; const WORKBENCH_CONFIG = config.get('workbench'); @@ -29,19 +30,20 @@ export class LocalCommandExecutionRepository extends CommandExecutionRepository private readonly encryptionService: EncryptionService, ) { super(); - this.modelEncryptor = new ModelEncryptor(encryptionService, ['command', 'result']); + this.modelEncryptor = new ModelEncryptor(this.encryptionService, ['command', 'result']); } /** - * Encrypt command executions and save entire entities + * @inheritDoc + * ___ + * Should encrypt command executions * Should always throw and error in case when unable to encrypt for some reason - * @param _ - * @param commandExecutions */ async createMany(_: SessionMetadata, commandExecutions: Partial[]): Promise { // todo: limit by 30 max to insert - let entities = await Promise.all(commandExecutions.map(async (commandExecution) => { + const response = await Promise.all(commandExecutions.map(async (commandExecution, idx) => { const entity = plainToClass(CommandExecutionEntity, commandExecution); + let isNotStored: undefined | boolean; // Do not store command execution result that exceeded limitation if (JSON.stringify(entity.result).length > WORKBENCH_CONFIG.maxResultSize) { @@ -52,31 +54,23 @@ export class LocalCommandExecutionRepository extends CommandExecutionRepository }, ]); // Hack, do not store isNotStored. Send once to show warning - entity['isNotStored'] = true; + isNotStored = true; } - return this.modelEncryptor.encryptEntity(entity); + return classToClass(CommandExecution, { + ...(await this.commandExecutionRepository.save(await this.modelEncryptor.encryptEntity(entity))), + command: commandExecutions[idx].command, // avoid decryption + mode: commandExecutions[idx].mode, + result: commandExecutions[idx].result, // avoid decryption + show original response when it was huge + summary: commandExecutions[idx].summary, + executionTime: commandExecutions[idx].executionTime, + isNotStored, + }); })); - entities = await this.commandExecutionRepository.save(entities); - - const response = await Promise.all( - entities.map((entity, idx) => classToClass( - CommandExecution, - { - ...entity, - command: commandExecutions[idx].command, - mode: commandExecutions[idx].mode, - result: commandExecutions[idx].result, - summary: commandExecutions[idx].summary, - executionTime: commandExecutions[idx].executionTime, - }, - )), - ); - // cleanup history and ignore error if any try { - await this.cleanupDatabaseHistory(entities[0].databaseId); + await this.cleanupDatabaseHistory(response[0].databaseId, { type: commandExecutions[0].type }); } catch (e) { this.logger.error('Error when trying to cleanup history after insert', e); } @@ -85,15 +79,17 @@ export class LocalCommandExecutionRepository extends CommandExecutionRepository } /** - * Fetch only needed fields to show in list to avoid huge decryption work - * @param _ - * @param databaseId + * @inheritDoc */ - async getList(_: SessionMetadata, databaseId: string): Promise { + async getList( + _: SessionMetadata, + databaseId: string, + queryFilter: CommandExecutionFilter, + ): Promise { this.logger.log('Getting command executions'); const entities = await this.commandExecutionRepository .createQueryBuilder('e') - .where({ databaseId }) + .where({ databaseId, type: queryFilter.type }) .select([ 'e.id', 'e.command', @@ -105,6 +101,7 @@ export class LocalCommandExecutionRepository extends CommandExecutionRepository 'e.resultsMode', 'e.executionTime', 'e.db', + 'e.type', ]) .orderBy('e.createdAt', 'DESC') .limit(WORKBENCH_CONFIG.maxItemsPerDb) @@ -127,11 +124,7 @@ export class LocalCommandExecutionRepository extends CommandExecutionRepository } /** - * Get single command execution entity, decrypt and convert to model - * - * @param _ - * @param databaseId - * @param id + * @inheritDoc */ async getOne(_: SessionMetadata, databaseId: string, id: string): Promise { this.logger.log('Getting command executions'); @@ -151,11 +144,7 @@ export class LocalCommandExecutionRepository extends CommandExecutionRepository } /** - * Delete single item - * - * @param _ - * @param databaseId - * @param id + * @inheritDoc */ async delete(_: SessionMetadata, databaseId: string, id: string): Promise { this.logger.log('Delete command execution'); @@ -166,28 +155,26 @@ export class LocalCommandExecutionRepository extends CommandExecutionRepository } /** - * Delete all items - * - * @param _ - * @param databaseId + * @inheritDoc */ - async deleteAll(_: SessionMetadata, databaseId: string): Promise { + async deleteAll(_: SessionMetadata, databaseId: string, queryFilter: CommandExecutionFilter): Promise { this.logger.log('Delete all command executions'); - await this.commandExecutionRepository.delete({ databaseId }); + await this.commandExecutionRepository.delete({ databaseId, type: queryFilter.type }); this.logger.log('Command executions deleted'); } /** - * Clean history for particular database to fit 30 items limitation + * Clean history for particular database to fit N items limitation * @param databaseId + * @param queryFilter */ - private async cleanupDatabaseHistory(databaseId: string): Promise { + private async cleanupDatabaseHistory(databaseId: string, queryFilter: CommandExecutionFilter): Promise { // todo: investigate why delete with sub-query doesn't works const idsToDelete = (await this.commandExecutionRepository .createQueryBuilder() - .where({ databaseId }) + .where({ databaseId, type: queryFilter.type }) .select('id') .orderBy('createdAt', 'DESC') .offset(WORKBENCH_CONFIG.maxItemsPerDb) diff --git a/redisinsight/api/src/modules/workbench/workbench.controller.ts b/redisinsight/api/src/modules/workbench/workbench.controller.ts index 1ba53775df..5803864756 100644 --- a/redisinsight/api/src/modules/workbench/workbench.controller.ts +++ b/redisinsight/api/src/modules/workbench/workbench.controller.ts @@ -6,6 +6,7 @@ import { Get, Param, Post, + Query, UseInterceptors, UsePipes, ValidationPipe, @@ -19,6 +20,7 @@ import { CreateCommandExecutionsDto } from 'src/modules/workbench/dto/create-com import { ShortCommandExecution } from 'src/modules/workbench/models/short-command-execution'; import { ClientMetadata } from 'src/common/models'; import { WorkbenchClientMetadata } from 'src/modules/workbench/decorators/workbench-client-metadata.decorator'; +import { CommandExecutionFilter } from 'src/modules/workbench/models/command-executions.filter'; @ApiTags('Workbench') @UsePipes(new ValidationPipe({ transform: true })) @@ -62,8 +64,9 @@ export class WorkbenchController { @ApiRedisParams() async listCommandExecutions( @WorkbenchClientMetadata() clientMetadata: ClientMetadata, + @Query() filter: CommandExecutionFilter, ): Promise { - return this.service.listCommandExecutions(clientMetadata); + return this.service.listCommandExecutions(clientMetadata, filter); } @ApiEndpoint({ @@ -107,7 +110,8 @@ export class WorkbenchController { @ApiRedisParams() async deleteCommandExecutions( @WorkbenchClientMetadata() clientMetadata: ClientMetadata, + @Body() filter: CommandExecutionFilter, ): Promise { - return this.service.deleteCommandExecutions(clientMetadata); + return this.service.deleteCommandExecutions(clientMetadata, filter); } } diff --git a/redisinsight/api/src/modules/workbench/workbench.service.spec.ts b/redisinsight/api/src/modules/workbench/workbench.service.spec.ts index b4a056b520..aabd4d6611 100644 --- a/redisinsight/api/src/modules/workbench/workbench.service.spec.ts +++ b/redisinsight/api/src/modules/workbench/workbench.service.spec.ts @@ -1,22 +1,18 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { v4 as uuidv4 } from 'uuid'; import { when } from 'jest-when'; import { - mockDatabase, + mockCommandExecution, mockCommandExecutionFilter, mockCommandExecutionRepository, + mockCommandExecutionSuccessResult, + mockCreateCommandExecutionDto, mockDatabaseClientFactory, - mockStandaloneRedisClient, + mockStandaloneRedisClient, MockType, mockWorkbenchAnalyticsService, - mockWorkbenchClientMetadata, + mockWorkbenchClientMetadata, mockWorkbenchCommandsExecutor, } from 'src/__mocks__'; import { WorkbenchService } from 'src/modules/workbench/workbench.service'; import { WorkbenchCommandsExecutor } from 'src/modules/workbench/providers/workbench-commands.executor'; import { CommandExecutionRepository } from 'src/modules/workbench/repositories/command-execution.repository'; -import { - CreateCommandExecutionDto, - RunQueryMode, - ResultsMode, -} from 'src/modules/workbench/dto/create-command-execution.dto'; -import { CommandExecution } from 'src/modules/workbench/models/command-execution'; +import { ResultsMode, RunQueryMode } from 'src/modules/workbench/models/command-execution'; import { CommandExecutionResult } from 'src/modules/workbench/models/command-execution-result'; import { CommandExecutionStatus } from 'src/modules/cli/dto/cli.dto'; import { BadRequestException, InternalServerErrorException } from '@nestjs/common'; @@ -25,12 +21,6 @@ import { CreateCommandExecutionsDto } from 'src/modules/workbench/dto/create-com import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory'; import { WorkbenchAnalyticsService } from './services/workbench-analytics/workbench-analytics.service'; -const mockCreateCommandExecutionDto: CreateCommandExecutionDto = { - command: 'set foo bar', - mode: RunQueryMode.ASCII, - resultsMode: ResultsMode.Default, -}; - const mockCommands = ['set 1 1', 'get 1']; const mockCreateCommandExecutionDtoWithGroupMode: CreateCommandExecutionsDto = { @@ -54,23 +44,8 @@ const mockCreateCommandExecutionsDto: CreateCommandExecutionsDto = { }; const mockCommandExecutionResults: CommandExecutionResult[] = [ - new CommandExecutionResult({ - status: CommandExecutionStatus.Success, - response: 'OK', - }), + mockCommandExecutionSuccessResult, ]; -const mockCommandExecutionToRun: CommandExecution = new CommandExecution({ - ...mockCreateCommandExecutionDto, - databaseId: mockDatabase.id, - db: 0, -}); - -const mockCommandExecution: CommandExecution = new CommandExecution({ - ...mockCommandExecutionToRun, - id: uuidv4(), - createdAt: new Date(), - result: mockCommandExecutionResults, -}); const mockSendCommandResultSuccess = { response: '1', status: 'success' }; const mockSendCommandResultFail = { response: 'error', status: 'fail' }; @@ -106,19 +81,10 @@ const mockCommandExecutionWithSilentMode = { }], }; -const mockCommandExecutionRepository = () => ({ - createMany: jest.fn(), - getList: jest.fn(), - getOne: jest.fn(), - delete: jest.fn(), - deleteAll: jest.fn(), -}); - describe('WorkbenchService', () => { - const client = mockStandaloneRedisClient; let service: WorkbenchService; - let workbenchCommandsExecutor; - let commandExecutionProvider; + let workbenchCommandsExecutor: MockType; + let commandExecutionRepository: MockType; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -130,9 +96,7 @@ describe('WorkbenchService', () => { }, { provide: WorkbenchCommandsExecutor, - useFactory: () => ({ - sendCommand: jest.fn(), - }), + useFactory: mockWorkbenchCommandsExecutor, }, { provide: CommandExecutionRepository, @@ -145,30 +109,38 @@ describe('WorkbenchService', () => { ], }).compile(); - service = module.get(WorkbenchService); - workbenchCommandsExecutor = module.get(WorkbenchCommandsExecutor); - commandExecutionProvider = module.get(CommandExecutionRepository); + service = module.get(WorkbenchService); + workbenchCommandsExecutor = module.get(WorkbenchCommandsExecutor); + commandExecutionRepository = module.get(CommandExecutionRepository); }); describe('createCommandExecution', () => { it('should successfully execute command and save it', async () => { - const result = await service.createCommandExecution(client, mockCreateCommandExecutionDto); - // can't predict execution time - expect(result).toMatchObject(mockCommandExecutionToRun); - expect(result.executionTime).toBeGreaterThan(0); + const result = await service.createCommandExecution(mockStandaloneRedisClient, mockCreateCommandExecutionDto); + expect(result).toEqual({ + ...mockCommandExecution, + executionTime: result.executionTime, + id: undefined, // result was not saved yet + createdAt: undefined, // result was not saved yet + }); }); it('should save db index', async () => { const db = 2; - client.getCurrentDbIndex = jest.fn().mockResolvedValueOnce(db); + mockStandaloneRedisClient.getCurrentDbIndex.mockResolvedValueOnce(db); const result = await service.createCommandExecution( - client, + mockStandaloneRedisClient, mockCreateCommandExecutionDto, ); - expect(result).toMatchObject({ ...mockCommandExecutionToRun, db }); - expect(result.db).toBe(db); + expect(result).toEqual({ + ...mockCommandExecution, + executionTime: result.executionTime, + id: undefined, // result was not saved yet + createdAt: undefined, // result was not saved yet + db, + }); }); it('should save result as unsupported command message', async () => { - client.getCurrentDbIndex = jest.fn().mockResolvedValueOnce(0); + mockStandaloneRedisClient.getCurrentDbIndex = jest.fn().mockResolvedValueOnce(0); workbenchCommandsExecutor.sendCommand.mockResolvedValueOnce(mockCommandExecutionResults); const dto = { @@ -177,7 +149,7 @@ describe('WorkbenchService', () => { mode: RunQueryMode.ASCII, }; - expect(await service.createCommandExecution(client, dto)).toEqual({ + expect(await service.createCommandExecution(mockStandaloneRedisClient, dto)).toEqual({ ...dto, db: 0, databaseId: mockWorkbenchClientMetadata.databaseId, @@ -199,7 +171,7 @@ describe('WorkbenchService', () => { }; try { - await service.createCommandExecution(client, dto); + await service.createCommandExecution(mockStandaloneRedisClient, dto); fail(); } catch (e) { expect(e).toBeInstanceOf(BadRequestException); @@ -211,19 +183,18 @@ describe('WorkbenchService', () => { workbenchCommandsExecutor.sendCommand.mockResolvedValueOnce( [mockCommandExecutionResults, mockCommandExecutionResults], ); - commandExecutionProvider.createMany.mockResolvedValueOnce([mockCommandExecution, mockCommandExecution]); + commandExecutionRepository.createMany.mockResolvedValueOnce([mockCommandExecution, mockCommandExecution]); const result = await service.createCommandExecutions(mockWorkbenchClientMetadata, mockCreateCommandExecutionsDto); expect(result).toEqual([mockCommandExecution, mockCommandExecution]); }); - it('should successfully execute commands and save in group mode view', async () => { when(workbenchCommandsExecutor.sendCommand) - .calledWith(client, expect.anything()) + .calledWith(mockStandaloneRedisClient, expect.anything()) .mockResolvedValue([mockSendCommandResultSuccess]); - commandExecutionProvider.createMany.mockResolvedValueOnce([mockCommandExecutionWithGroupMode]); + commandExecutionRepository.createMany.mockResolvedValueOnce([mockCommandExecutionWithGroupMode]); const result = await service.createCommandExecutions( mockWorkbenchClientMetadata, @@ -232,13 +203,12 @@ describe('WorkbenchService', () => { expect(result).toEqual([mockCommandExecutionWithGroupMode]); }); - it('should successfully execute commands and save in silent mode view', async () => { when(workbenchCommandsExecutor.sendCommand) - .calledWith(client, expect.anything()) + .calledWith(mockStandaloneRedisClient, expect.anything()) .mockResolvedValue([mockSendCommandResultSuccess]); - commandExecutionProvider.createMany.mockResolvedValueOnce([mockCommandExecutionWithSilentMode]); + commandExecutionRepository.createMany.mockResolvedValueOnce([mockCommandExecutionWithSilentMode]); const result = await service.createCommandExecutions( mockWorkbenchClientMetadata, @@ -250,20 +220,20 @@ describe('WorkbenchService', () => { it('should successfully execute commands with error and save summary', async () => { when(workbenchCommandsExecutor.sendCommand) - .calledWith(client, { + .calledWith(mockStandaloneRedisClient, { ...mockCreateCommandExecutionDtoWithGroupMode, command: mockCommands[0], }) .mockResolvedValue([mockSendCommandResultSuccess]); when(workbenchCommandsExecutor.sendCommand) - .calledWith(client, { + .calledWith(mockStandaloneRedisClient, { ...mockCreateCommandExecutionDtoWithGroupMode, command: mockCommands[1], }) .mockResolvedValue([mockSendCommandResultFail]); - commandExecutionProvider.createMany.mockResolvedValueOnce([mockCommandExecutionWithGroupMode]); + commandExecutionRepository.createMany.mockResolvedValueOnce([mockCommandExecutionWithGroupMode]); const result = await service.createCommandExecutions( mockWorkbenchClientMetadata, @@ -275,20 +245,20 @@ describe('WorkbenchService', () => { it('should successfully execute commands with error and save summary in silent mode view', async () => { when(workbenchCommandsExecutor.sendCommand) - .calledWith(client, { + .calledWith(mockStandaloneRedisClient, { ...mockCreateCommandExecutionDtoWithSilentMode, command: mockCommands[0], }) .mockResolvedValue([mockSendCommandResultSuccess]); when(workbenchCommandsExecutor.sendCommand) - .calledWith(client, { + .calledWith(mockStandaloneRedisClient, { ...mockCreateCommandExecutionDtoWithSilentMode, command: mockCommands[1], }) .mockResolvedValue([mockSendCommandResultFail]); - commandExecutionProvider.createMany.mockResolvedValueOnce([mockCommandExecutionWithSilentMode]); + commandExecutionRepository.createMany.mockResolvedValueOnce([mockCommandExecutionWithSilentMode]); const result = await service.createCommandExecutions( mockWorkbenchClientMetadata, @@ -310,7 +280,7 @@ describe('WorkbenchService', () => { }); it('should throw an error from command execution provider (create)', async () => { workbenchCommandsExecutor.sendCommand.mockResolvedValueOnce([mockCommandExecutionResults]); - commandExecutionProvider.createMany.mockRejectedValueOnce(new InternalServerErrorException('db error')); + commandExecutionRepository.createMany.mockRejectedValueOnce(new InternalServerErrorException('db error')); try { await service.createCommandExecutions(mockWorkbenchClientMetadata, mockCreateCommandExecutionsDto); @@ -322,17 +292,17 @@ describe('WorkbenchService', () => { }); describe('listCommandExecutions', () => { it('should return list of command executions', async () => { - commandExecutionProvider.getList.mockResolvedValueOnce([mockCommandExecution, mockCommandExecution]); + commandExecutionRepository.getList.mockResolvedValueOnce([mockCommandExecution, mockCommandExecution]); - const result = await service.listCommandExecutions(mockWorkbenchClientMetadata); + const result = await service.listCommandExecutions(mockWorkbenchClientMetadata, mockCommandExecutionFilter); expect(result).toEqual([mockCommandExecution, mockCommandExecution]); }); it('should throw an error from command execution provider (getList)', async () => { - commandExecutionProvider.getList.mockRejectedValueOnce(new InternalServerErrorException()); + commandExecutionRepository.getList.mockRejectedValueOnce(new InternalServerErrorException()); try { - await service.listCommandExecutions(mockWorkbenchClientMetadata); + await service.listCommandExecutions(mockWorkbenchClientMetadata, mockCommandExecutionFilter); fail(); } catch (e) { expect(e).toBeInstanceOf(InternalServerErrorException); @@ -341,14 +311,14 @@ describe('WorkbenchService', () => { }); describe('getCommandExecution', () => { it('should return full command executions', async () => { - commandExecutionProvider.getOne.mockResolvedValueOnce(mockCommandExecution); + commandExecutionRepository.getOne.mockResolvedValueOnce(mockCommandExecution); const result = await service.getCommandExecution(mockWorkbenchClientMetadata, mockCommandExecution.id); expect(result).toEqual(mockCommandExecution); }); it('should throw an error from command execution provider (getOne)', async () => { - commandExecutionProvider.getOne.mockRejectedValueOnce(new InternalServerErrorException()); + commandExecutionRepository.getOne.mockRejectedValueOnce(new InternalServerErrorException()); try { await service.getCommandExecution(mockWorkbenchClientMetadata, mockCommandExecution.id); @@ -360,7 +330,7 @@ describe('WorkbenchService', () => { }); describe('deleteCommandExecution', () => { it('should not return anything on delete', async () => { - commandExecutionProvider.delete.mockResolvedValueOnce('some response'); + commandExecutionRepository.delete.mockResolvedValueOnce('some response'); const result = await service.deleteCommandExecution( mockWorkbenchClientMetadata, @@ -372,10 +342,11 @@ describe('WorkbenchService', () => { }); describe('deleteCommandExecutions', () => { it('should not return anything on delete', async () => { - commandExecutionProvider.deleteAll.mockResolvedValueOnce('some response'); + commandExecutionRepository.deleteAll.mockResolvedValueOnce('some response'); const result = await service.deleteCommandExecutions( mockWorkbenchClientMetadata, + mockCommandExecutionFilter, ); expect(result).toEqual(undefined); diff --git a/redisinsight/api/src/modules/workbench/workbench.service.ts b/redisinsight/api/src/modules/workbench/workbench.service.ts index 2a4a972995..30142a1c8d 100644 --- a/redisinsight/api/src/modules/workbench/workbench.service.ts +++ b/redisinsight/api/src/modules/workbench/workbench.service.ts @@ -2,8 +2,8 @@ import { Injectable } from '@nestjs/common'; import { omit } from 'lodash'; import { ClientMetadata } from 'src/common/models'; import { WorkbenchCommandsExecutor } from 'src/modules/workbench/providers/workbench-commands.executor'; -import { CommandExecution } from 'src/modules/workbench/models/command-execution'; -import { CreateCommandExecutionDto, ResultsMode } from 'src/modules/workbench/dto/create-command-execution.dto'; +import { CommandExecution, ResultsMode } from 'src/modules/workbench/models/command-execution'; +import { CreateCommandExecutionDto } from 'src/modules/workbench/dto/create-command-execution.dto'; import { CreateCommandExecutionsDto } from 'src/modules/workbench/dto/create-command-executions.dto'; import { getBlockingCommands, multilineCommandToOneLine } from 'src/utils/cli-helper'; import ERROR_MESSAGES from 'src/constants/error-messages'; @@ -12,6 +12,7 @@ import { CommandExecutionStatus } from 'src/modules/cli/dto/cli.dto'; import { CommandExecutionRepository } from 'src/modules/workbench/repositories/command-execution.repository'; import { DatabaseClientFactory } from 'src/modules/database/providers/database.client.factory'; import { RedisClient } from 'src/modules/redis/client'; +import { CommandExecutionFilter } from 'src/modules/workbench/models/command-executions.filter'; import { getUnsupportedCommands } from './utils/getUnsupportedCommands'; import { WorkbenchAnalyticsService } from './services/workbench-analytics/workbench-analytics.service'; @@ -160,9 +161,13 @@ export class WorkbenchService { * Get list command execution history per instance (last 30 items) * * @param clientMetadata + * @param filter */ - async listCommandExecutions(clientMetadata: ClientMetadata): Promise { - return this.commandExecutionRepository.getList(clientMetadata.sessionMetadata, clientMetadata.databaseId); + async listCommandExecutions( + clientMetadata: ClientMetadata, + filter: CommandExecutionFilter, + ): Promise { + return this.commandExecutionRepository.getList(clientMetadata.sessionMetadata, clientMetadata.databaseId, filter); } /** @@ -190,9 +195,10 @@ export class WorkbenchService { * Delete command executions by databaseId * * @param clientMetadata + * @param filter */ - async deleteCommandExecutions(clientMetadata: ClientMetadata): Promise { - await this.commandExecutionRepository.deleteAll(clientMetadata.sessionMetadata, clientMetadata.databaseId); + async deleteCommandExecutions(clientMetadata: ClientMetadata, filter: CommandExecutionFilter): Promise { + await this.commandExecutionRepository.deleteAll(clientMetadata.sessionMetadata, clientMetadata.databaseId, filter); } /** diff --git a/redisinsight/api/test/api/plugins/POST-databases-id-plugins-command_executions.test.ts b/redisinsight/api/test/api/plugins/POST-databases-id-plugins-command_executions.test.ts index 0526aae7b4..2a8e9a6e20 100644 --- a/redisinsight/api/test/api/plugins/POST-databases-id-plugins-command_executions.test.ts +++ b/redisinsight/api/test/api/plugins/POST-databases-id-plugins-command_executions.test.ts @@ -8,8 +8,8 @@ import { generateInvalidDataTestCases, validateInvalidDataTestCase, validateApiCall, - requirements, -} from '../deps'; + requirements, getMainCheckFn, +} from '../deps' const { server, request, constants, rte, localDb } = deps; // endpoint to test @@ -40,26 +40,10 @@ const responseSchema = Joi.object().keys({ })), mode: Joi.string().required(), resultsMode: Joi.string().required(), + type: Joi.string().valid('WORKBENCH', 'SEARCH').required(), }).required(); -const mainCheckFn = async (testCase) => { - it(testCase.name, async () => { - // additional checks before test run - if (testCase.before) { - await testCase.before(); - } - - await validateApiCall({ - endpoint, - ...testCase, - }); - - // additional checks after test pass - if (testCase.after) { - await testCase.after(); - } - }); -}; +const mainCheckFn = getMainCheckFn(endpoint); describe('POST /databases/:instanceId/plugins/command-executions', () => { before(rte.data.truncate); diff --git a/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-info.test.ts b/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-info.test.ts new file mode 100644 index 0000000000..e963782cce --- /dev/null +++ b/redisinsight/api/test/api/redisearch/POST-databases-id-redisearch-info.test.ts @@ -0,0 +1,125 @@ +import { + expect, + describe, + before, + Joi, + deps, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + getMainCheckFn, +} from '../deps'; + +const { + server, request, constants, rte, localDb, +} = deps; + +// endpoint to test +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => request(server) + .post(`/${constants.API.DATABASES}/${instanceId}/redisearch/info`); + +// input data schema +const dataSchema = Joi.object({ + index: Joi.string().required(), +}).strict(); + +const validInputData = { + index: constants.TEST_SEARCH_HASH_INDEX_1, +}; + +const responseSchema = Joi.object({ + index_name: Joi.string().required(), + index_options: Joi.object({}), + index_definition: Joi.object({ + key_type: Joi.string(), + prefixes: Joi.array(), + default_score: Joi.string(), + }), + attributes: Joi.array().items({ + identifier: Joi.string(), + attribute: Joi.string(), + type: Joi.string(), + WEIGHT: Joi.string(), + SORTABLE: Joi.string(), + NOINDEX: Joi.string(), + CASESENSITIVE: Joi.string(), + UNF: Joi.string(), + NOSTEM: Joi.string(), + SEPARATOR: Joi.string(), + }), + num_docs: Joi.string(), + max_doc_id: Joi.string(), + num_terms: Joi.string(), + num_records: Joi.string(), + inverted_sz_mb: Joi.string(), + vector_index_sz_mb: Joi.string(), + total_inverted_index_blocks: Joi.string(), + offset_vectors_sz_mb: Joi.string(), + doc_table_size_mb: Joi.string(), + sortable_values_size_mb: Joi.string(), + tag_overhead_sz_mb: Joi.string(), + text_overhead_sz_mb: Joi.string(), + total_index_memory_sz_mb: Joi.string(), + key_table_size_mb: Joi.string(), + geoshapes_sz_mb: Joi.string(), + records_per_doc_avg: Joi.string(), + bytes_per_record_avg: Joi.string(), + offsets_per_term_avg: Joi.string(), + offset_bits_per_record_avg: Joi.string(), + hash_indexing_failures: Joi.string(), + total_indexing_time: Joi.string(), + indexing: Joi.string(), + percent_indexed: Joi.string(), + number_of_uses: Joi.number(), + cleaning: Joi.number(), + gc_stats: Joi.object(), + cursor_stats: Joi.object(), + dialect_stats: Joi.object(), + 'Index Errors': Joi.object(), + 'field statistics': Joi.array().items({ + identifier: Joi.string(), + attribute: Joi.string(), + 'Index Errors': Joi.object(), + }), +}).required().strict(); +const mainCheckFn = getMainCheckFn(endpoint); + +describe('POST /databases/:id/redisearch/info', () => { + requirements('!rte.bigData', 'rte.modules.search'); + before(async () => { + await rte.data.generateRedisearchIndexes(true); + await localDb.createTestDbInstance(rte, {}, { id: constants.TEST_INSTANCE_ID_2 }); + }); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).forEach( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Common', () => { + [ + { + name: 'Should get info index', + data: validInputData, + responseSchema, + checkFn: async ({ body }) => { + expect(body.index_name).to.eq(constants.TEST_SEARCH_HASH_INDEX_1); + expect(body.index_definition?.key_type).to.eq('HASH'); + }, + }, + { + name: 'Should throw error if non-existent index provided', + data: { + index: 'Invalid index', + }, + statusCode: 500, + responseBody: { + message: 'Unknown Index name', + error: 'Internal Server Error', + statusCode: 500, + }, + }, + ].forEach(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/workbench/DELETE-databases-id-workbench-command_executions-id.test.ts b/redisinsight/api/test/api/workbench/DELETE-databases-id-workbench-command_executions-id.test.ts index 1aac29ec4f..193fa3aa5b 100644 --- a/redisinsight/api/test/api/workbench/DELETE-databases-id-workbench-command_executions-id.test.ts +++ b/redisinsight/api/test/api/workbench/DELETE-databases-id-workbench-command_executions-id.test.ts @@ -1,10 +1,9 @@ import { expect, describe, - it, deps, - validateApiCall, -} from '../deps'; + getMainCheckFn, +} from '../deps' const { server, request, constants, rte, localDb } = deps; // endpoint to test @@ -14,24 +13,7 @@ const endpoint = ( ) => request(server).delete(`/${constants.API.DATABASES}/${instanceId}/workbench/command-executions/${id}`); -const mainCheckFn = async (testCase) => { - it(testCase.name, async () => { - // additional checks before test run - if (testCase.before) { - await testCase.before(); - } - - await validateApiCall({ - endpoint, - ...testCase, - }); - - // additional checks after test pass - if (testCase.after) { - await testCase.after(); - } - }); -}; +const mainCheckFn = getMainCheckFn(endpoint); describe('DELETE /databases/:instanceId/workbench/command-executions/:commandExecutionId', () => { describe('Common', () => { diff --git a/redisinsight/api/test/api/workbench/DELETE-databases-id-workbench-command_executions.test.ts b/redisinsight/api/test/api/workbench/DELETE-databases-id-workbench-command_executions.test.ts index 671a25b733..81a9fb05fa 100644 --- a/redisinsight/api/test/api/workbench/DELETE-databases-id-workbench-command_executions.test.ts +++ b/redisinsight/api/test/api/workbench/DELETE-databases-id-workbench-command_executions.test.ts @@ -1,11 +1,11 @@ import { expect, describe, - it, deps, - validateApiCall, -} from '../deps'; -const { server, request, constants, rte, localDb } = deps; + getMainCheckFn, + Joi, generateInvalidDataTestCases, validateInvalidDataTestCase, +} from '../deps' +const { server, request, constants, localDb } = deps; // endpoint to test const endpoint = ( @@ -13,26 +13,26 @@ const endpoint = ( ) => request(server).delete(`/${constants.API.DATABASES}/${instanceId}/workbench/command-executions`); -const mainCheckFn = async (testCase) => { - it(testCase.name, async () => { - // additional checks before test run - if (testCase.before) { - await testCase.before(); - } +// input data schema +const dataSchema = Joi.object({ + type: Joi.string().valid('WORKBENCH', 'SEARCH').allow(null), +}).messages({ + 'any.required': '{#label} should not be empty', +}).strict(); - await validateApiCall({ - endpoint, - ...testCase, - }); - - // additional checks after test pass - if (testCase.after) { - await testCase.after(); - } - }); +const validInputData = { + type: 'WORKBENCH', }; +const mainCheckFn = getMainCheckFn(endpoint); + describe('DELETE /databases/:instanceId/workbench/command-executions', () => { + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + describe('Common', () => { [ { @@ -61,4 +61,55 @@ describe('DELETE /databases/:instanceId/workbench/command-executions', () => { }, ].map(mainCheckFn); }); + describe('Filter', () => { + beforeEach(async () => { + await localDb.generateNCommandExecutions({ + databaseId: constants.TEST_INSTANCE_ID, + type: 'WORKBENCH', + }, 20, true); + await localDb.generateNCommandExecutions({ + databaseId: constants.TEST_INSTANCE_ID, + type: 'SEARCH', + }, 10, false); + }); + + [ + { + name: 'Should return 404 not found when incorrect instance', + endpoint: () => endpoint( + constants.TEST_NOT_EXISTED_INSTANCE_ID, + ), + statusCode: 404, + responseBody: { + statusCode: 404, + message: 'Invalid database instance id.', + error: 'Not Found' + }, + }, + { + name: 'Should return remove only WORKBENCH items (by default)', + after: async () => { + expect(await (await localDb.getRepository(localDb.repositories.COMMAND_EXECUTION)).count({})).to.eq(10) + }, + }, + { + name: 'Should return remove only WORKBENCH items', + data: { + type: 'WORKBENCH', + }, + after: async () => { + expect(await (await localDb.getRepository(localDb.repositories.COMMAND_EXECUTION)).count({})).to.eq(10) + }, + }, + { + name: 'Should return remove only SEARCH items', + data: { + type: 'SEARCH', + }, + after: async () => { + expect(await (await localDb.getRepository(localDb.repositories.COMMAND_EXECUTION)).count({})).to.eq(20) + }, + }, + ].map(mainCheckFn); + }); }); diff --git a/redisinsight/api/test/api/workbench/GET-databases-id-workbench-command_executions-id.test.ts b/redisinsight/api/test/api/workbench/GET-databases-id-workbench-command_executions-id.test.ts index c50e1324b0..62878b5b99 100644 --- a/redisinsight/api/test/api/workbench/GET-databases-id-workbench-command_executions-id.test.ts +++ b/redisinsight/api/test/api/workbench/GET-databases-id-workbench-command_executions-id.test.ts @@ -6,7 +6,7 @@ import { deps, validateApiCall, } from '../deps'; -const { server, request, constants, rte, localDb } = deps; +const { server, request, constants, localDb } = deps; // endpoint to test const endpoint = ( @@ -29,6 +29,7 @@ const responseSchema = Joi.object().keys({ executionTime: Joi.number().required(), db: Joi.number().integer().allow(null), createdAt: Joi.date().required(), + type: Joi.string().valid('WORKBENCH', 'SEARCH').required(), }).required(); const mainCheckFn = async (testCase) => { diff --git a/redisinsight/api/test/api/workbench/GET-databases-id-workbench-command_executions.test.ts b/redisinsight/api/test/api/workbench/GET-databases-id-workbench-command_executions.test.ts index f705326087..32fc90b682 100644 --- a/redisinsight/api/test/api/workbench/GET-databases-id-workbench-command_executions.test.ts +++ b/redisinsight/api/test/api/workbench/GET-databases-id-workbench-command_executions.test.ts @@ -1,11 +1,11 @@ import { expect, describe, - it, + before, Joi, deps, - validateApiCall, -} from '../deps'; + getMainCheckFn, +} from '../deps' const { server, request, constants, localDb } = deps; // endpoint to test @@ -28,26 +28,10 @@ const responseSchema = Joi.array().items(Joi.object().keys({ }).allow(null), db: Joi.number().integer().allow(null), createdAt: Joi.date().required(), + type: Joi.string().valid('WORKBENCH', 'SEARCH').required(), })).required().max(30); -const mainCheckFn = async (testCase) => { - it(testCase.name, async () => { - // additional checks before test run - if (testCase.before) { - await testCase.before(); - } - - await validateApiCall({ - endpoint, - ...testCase, - }); - - // additional checks after test pass - if (testCase.after) { - await testCase.after(); - } - }); -}; +const mainCheckFn = getMainCheckFn(endpoint); describe('GET /databases/:instanceId/workbench/command-executions', () => { describe('Common', () => { @@ -110,4 +94,58 @@ describe('GET /databases/:instanceId/workbench/command-executions', () => { }, ].map(mainCheckFn); }); + describe('Filter', () => { + before(async () => { + await localDb.generateNCommandExecutions({ + databaseId: constants.TEST_INSTANCE_ID, + type: 'WORKBENCH', + }, 20, true); + await localDb.generateNCommandExecutions({ + databaseId: constants.TEST_INSTANCE_ID, + type: 'SEARCH', + }, 10, false); + }); + + [ + { + name: 'Should get only 20 items (workbench by default)', + responseSchema, + checkFn: async ({ body }) => { + expect(body.length).to.eql(20); + for (let i = 0; i < 20; i ++) { + expect(body[i].command).to.eql('set foo bar'); + expect(body[i].type).to.eql('WORKBENCH'); + } + }, + }, + { + name: 'Should get only 20 items filtered by type (WORKBENCH)', + query: { + type: 'WORKBENCH', + }, + responseSchema, + checkFn: async ({ body }) => { + expect(body.length).to.eql(20); + for (let i = 0; i < 20; i ++) { + expect(body[i].command).to.eql('set foo bar'); + expect(body[i].type).to.eql('WORKBENCH'); + } + }, + }, + { + name: 'Should get only 10 items filtered by type (SEARCH)', + responseSchema, + query: { + type: 'SEARCH', + }, + checkFn: async ({ body }) => { + expect(body.length).to.eql(10); + for (let i = 0; i < 10; i ++) { + expect(body[i].command).to.eql('set foo bar'); + expect(body[i].type).to.eql('SEARCH'); + } + }, + }, + ].map(mainCheckFn); + }); }); diff --git a/redisinsight/api/test/api/workbench/POST-databases-id-workbench-command_executions.test.ts b/redisinsight/api/test/api/workbench/POST-databases-id-workbench-command_executions.test.ts index a98a551fc5..e337f46ca1 100644 --- a/redisinsight/api/test/api/workbench/POST-databases-id-workbench-command_executions.test.ts +++ b/redisinsight/api/test/api/workbench/POST-databases-id-workbench-command_executions.test.ts @@ -9,8 +9,8 @@ import { generateInvalidDataTestCases, validateInvalidDataTestCase, validateApiCall, - requirements, -} from '../deps'; + requirements, getMainCheckFn, +} from '../deps' import { convertArrayReplyToObject } from 'src/modules/redis/utils'; const { server, request, constants, rte, localDb } = deps; @@ -25,6 +25,7 @@ const dataSchema = Joi.object({ }), mode: Joi.string().valid('RAW', 'ASCII').allow(null), resultsMode: Joi.string().valid('DEFAULT', 'GROUP_MODE').allow(null), + type: Joi.string().valid('WORKBENCH', 'SEARCH').allow(null), }).messages({ 'any.required': '{#label} should not be empty', }).strict(); @@ -54,26 +55,10 @@ const responseSchema = Joi.array().items(Joi.object().keys({ success: Joi.number(), fail: Joi.number(), }), + type: Joi.string().valid('WORKBENCH', 'SEARCH').required(), })).required(); -const mainCheckFn = async (testCase) => { - it(testCase.name, async () => { - // additional checks before test run - if (testCase.before) { - await testCase.before(); - } - - await validateApiCall({ - endpoint, - ...testCase, - }); - - // additional checks after test pass - if (testCase.after) { - await testCase.after(); - } - }); -}; +const mainCheckFn = getMainCheckFn(endpoint); describe('POST /databases/:instanceId/workbench/command-executions', () => { before(rte.data.truncate); @@ -86,7 +71,7 @@ describe('POST /databases/:instanceId/workbench/command-executions', () => { describe('Common', () => { describe('String', () => { - const bigStringValue = Buffer.alloc(1023 * 1024, 'a').toString(); + const bigStringValue = Buffer.alloc(10, 'a').toString(); [ { @@ -118,8 +103,8 @@ describe('POST /databases/:instanceId/workbench/command-executions', () => { }); expect(entity.encryption).to.eql(constants.TEST_ENCRYPTION_STRATEGY); - expect(localDb.encryptData(body[0].command)).to.eql(entity.command); - expect(localDb.encryptData(JSON.stringify(body[0].result))).to.eql(entity.result); + expect(body[0].command).to.eql(localDb.decryptData(entity.command)); + expect(body[0].result).to.eql(JSON.parse(localDb.decryptData(entity.result))); }, before: async () => { expect(await rte.client.set(constants.TEST_STRING_KEY_1, bigStringValue)); @@ -230,8 +215,8 @@ describe('POST /databases/:instanceId/workbench/command-executions', () => { }); expect(entity.encryption).to.eql(constants.TEST_ENCRYPTION_STRATEGY); - expect(localDb.encryptData(body[0].command)).to.eql(entity.command); - expect(localDb.encryptData(JSON.stringify(body[0].result))).to.eql(entity.result); + expect(body[0].command).to.eql(localDb.decryptData(entity.command)); + expect(body[0].result).to.eql(JSON.parse(localDb.decryptData(entity.result))); } }, { diff --git a/redisinsight/api/test/api/ws/bulk-actions/bulk-actions-create.test.ts b/redisinsight/api/test/api/ws/bulk-actions/bulk-actions-create.test.ts index e709c362b8..ab964dd605 100644 --- a/redisinsight/api/test/api/ws/bulk-actions/bulk-actions-create.test.ts +++ b/redisinsight/api/test/api/ws/bulk-actions/bulk-actions-create.test.ts @@ -23,7 +23,7 @@ const createDto = { let client; describe('bulk-actions', function () { - this.timeout(10000); + this.timeout(20000); beforeEach(async () => { client = await getClient(); await rte.data.generateKeys(true); diff --git a/redisinsight/ui/src/assets/img/sidebar/search.svg b/redisinsight/ui/src/assets/img/sidebar/search.svg new file mode 100644 index 0000000000..5fe15b2504 --- /dev/null +++ b/redisinsight/ui/src/assets/img/sidebar/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/redisinsight/ui/src/assets/img/sidebar/search_active.svg b/redisinsight/ui/src/assets/img/sidebar/search_active.svg new file mode 100644 index 0000000000..fd5f5821e4 --- /dev/null +++ b/redisinsight/ui/src/assets/img/sidebar/search_active.svg @@ -0,0 +1,3 @@ + + + diff --git a/redisinsight/ui/src/components/monaco-laguages/MonacoLanguages.tsx b/redisinsight/ui/src/components/monaco-laguages/MonacoLanguages.tsx index e87c71dba4..f5cc4c1003 100644 --- a/redisinsight/ui/src/components/monaco-laguages/MonacoLanguages.tsx +++ b/redisinsight/ui/src/components/monaco-laguages/MonacoLanguages.tsx @@ -3,14 +3,19 @@ import { useSelector } from 'react-redux' import { monaco } from 'react-monaco-editor' import { findIndex } from 'lodash' import { appRedisCommandsSelector } from 'uiSrc/slices/app/redis-commands' -import { MonacoLanguage, redisLanguageConfig, Theme } from 'uiSrc/constants' +import { MonacoLanguage, redisLanguageConfig, Theme, IRedisCommandTree } from 'uiSrc/constants' import { getRedisMonarchTokensProvider } from 'uiSrc/utils' import { darkTheme, lightTheme, MonacoThemes } from 'uiSrc/constants/monaco' import { ThemeContext } from 'uiSrc/contexts/themeContext' +import { getRediSearchSubRedisMonarchTokensProvider } from 'uiSrc/utils/monaco/monarchTokens/redisearchTokensSubRedis' +import SEARCH_COMMANDS_SPEC from 'uiSrc/pages/workbench/data/supported_commands.json' +import { mergeRedisCommandsSpecs } from 'uiSrc/utils/transformers/redisCommands' +import { ModuleCommandPrefix } from 'uiSrc/pages/workbench/constants' + const MonacoLanguages = () => { const { theme } = useContext(ThemeContext) - const { commandsArray: REDIS_COMMANDS_ARRAY } = useSelector(appRedisCommandsSelector) + const { commandsArray: REDIS_COMMANDS_ARRAY, spec: COMMANDS_SPEC } = useSelector(appRedisCommandsSelector) useEffect(() => { if (monaco?.editor) { @@ -34,12 +39,20 @@ const MonacoLanguages = () => { const isRedisLangRegistered = findIndex(languages, { id: MonacoLanguage.Redis }) > -1 if (!isRedisLangRegistered) { monaco.languages.register({ id: MonacoLanguage.Redis }) + monaco.languages.register({ id: MonacoLanguage.RediSearch }) } monaco.languages.setLanguageConfiguration(MonacoLanguage.Redis, redisLanguageConfig) + const REDIS_COMMANDS = mergeRedisCommandsSpecs(COMMANDS_SPEC, SEARCH_COMMANDS_SPEC) as IRedisCommandTree[] + const REDIS_SEARCH_COMMANDS = REDIS_COMMANDS.filter(({ name }) => name?.startsWith(ModuleCommandPrefix.RediSearch)) + + monaco.languages.setMonarchTokensProvider( + MonacoLanguage.RediSearch, + getRediSearchSubRedisMonarchTokensProvider(REDIS_SEARCH_COMMANDS) + ) monaco.languages.setMonarchTokensProvider( MonacoLanguage.Redis, - getRedisMonarchTokensProvider(REDIS_COMMANDS_ARRAY) + getRedisMonarchTokensProvider(REDIS_COMMANDS) ) } diff --git a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx index b57eb92c38..3e964542a5 100644 --- a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx +++ b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.spec.tsx @@ -5,6 +5,7 @@ import { EXTERNAL_LINKS } from 'uiSrc/constants/links' import { appInfoSelector } from 'uiSrc/slices/app/info' import { cleanup, mockedStore, render, screen, fireEvent } from 'uiSrc/utils/test-utils' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' import NavigationMenu from './NavigationMenu' let store: typeof mockedStore @@ -23,6 +24,13 @@ jest.mock('uiSrc/slices/app/info', () => ({ }) })) +jest.mock('uiSrc/slices/instances/instances', () => ({ + ...jest.requireActual('uiSrc/slices/instances/instances'), + connectedInstanceSelector: jest.fn().mockReturnValue({ + id: '' + }), +})) + jest.mock('uiSrc/slices/app/features', () => ({ ...jest.requireActual('uiSrc/slices/app/features'), appFeatureFlagsFeaturesSelector: jest.fn().mockReturnValue({ @@ -54,7 +62,6 @@ describe('NavigationMenu', () => { render() expect(screen.queryByTestId('browser-page-btn"')).not.toBeInTheDocument() - expect(screen.queryByTestId('workbench-page-btn')).not.toBeInTheDocument() }) it('should render help menu', () => { @@ -93,15 +100,12 @@ describe('NavigationMenu', () => { }) describe('with connectedInstance', () => { - beforeAll(() => { - jest.mock('uiSrc/slices/instances/instances', () => ({ - ...jest.requireActual('uiSrc/slices/instances/instances'), - connectedInstanceSelector: jest.fn().mockReturnValue({ - id: '123', - connectionType: 'STANDALONE', - db: 0, - }) - })) + beforeEach(() => { + (connectedInstanceSelector as jest.Mock).mockReturnValue({ + id: '123', + connectionType: 'STANDALONE', + db: 0, + }) }) it('should render', () => { @@ -123,8 +127,8 @@ describe('NavigationMenu', () => { })) render() - expect(screen.findByTestId('browser-page-btn')).toBeTruthy() - expect(screen.findByTestId('workbench-page-btn')).toBeTruthy() + expect(screen.getByTestId('browser-page-btn')).toBeTruthy() + expect(screen.getByTestId('workbench-page-btn')).toBeTruthy() }) it('should render public routes', () => { diff --git a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx index 8979e13310..9711375054 100644 --- a/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx +++ b/redisinsight/ui/src/components/navigation-menu/NavigationMenu.tsx @@ -49,8 +49,6 @@ import NotificationMenu from './components/notifications-center' import { RedisLogo } from './components/redis-logo/RedisLogo' import styles from './styles.module.scss' -const workbenchPath = `/${PageNames.workbench}` -const browserPath = `/${PageNames.browser}` const pubSubPath = `/${PageNames.pubSub}` interface INavigations { @@ -111,7 +109,7 @@ const NavigationMenu = () => { { tooltipText: 'Browser', pageName: PageNames.browser, - isActivePage: activePage === browserPath, + isActivePage: activePage === `/${PageNames.browser}`, ariaLabel: 'Browser page button', onClick: () => handleGoPage(Pages.browser(connectedInstanceId)), dataTestId: 'browser-page-btn', @@ -131,7 +129,7 @@ const NavigationMenu = () => { onClick: () => handleGoPage(Pages.workbench(connectedInstanceId)), dataTestId: 'workbench-page-btn', connectedInstanceId, - isActivePage: activePage === workbenchPath, + isActivePage: activePage === `/${PageNames.workbench}`, getClassName() { return cx(styles.navigationButton, { [styles.active]: this.isActivePage }) }, diff --git a/redisinsight/ui/src/components/query/Query/styles.module.scss b/redisinsight/ui/src/components/query/Query/styles.module.scss deleted file mode 100644 index bb80636b81..0000000000 --- a/redisinsight/ui/src/components/query/Query/styles.module.scss +++ /dev/null @@ -1,140 +0,0 @@ -.wrapper { - position: relative; - height: 100%; - - :global(.editorBounder) { - bottom: 6px; - left: 18px; - right: 46px; - } -} -.container { - display: flex; - padding: 8px 0 8px 16px; - width: 100%; - height: 100%; - word-break: break-word; - text-align: left; - letter-spacing: 0; - background-color: var(--rsInputWrapperColor); - color: var(--euiTextSubduedColor) !important; - border: 1px solid var(--euiColorLightShade); -} - -.disabled { - opacity: 0.8; -} - -.disabledActions { - pointer-events: none; - user-select: none; -} - -.containerPlaceholder { - display: flex; - padding: 8px 16px 8px 16px; - width: 100%; - height: 100%; - background-color: var(--rsInputWrapperColor); - color: var(--euiTextSubduedColor) !important; - border: 1px solid var(--euiColorLightShade); - > div { - border: 1px solid var(--euiColorLightShade); - background-color: var(--euiColorEmptyShade); - padding: 8px 20px; - width: 100%; - } -} - -.input { - height: 100%; - width: calc(100% - 44px); - border: 1px solid var(--euiColorLightShade); - background-color: var(--rsInputColor); -} - -#script { - font: normal normal bold 14px/17px Inconsolata !important; - color: var(--textColorShade); - caret-color: var(--euiColorFullShade); - min-width: 5px; - display: inline; -} - -.actions { - width: 44px; - position: relative; - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: center; -} - -.textBtn.textBtn { - border: none !important; - width: 24px; - height: 24px !important; - min-width: auto !important; - min-height: auto !important; - border-radius: 4px !important; - background: transparent !important; - box-shadow: none !important; - - :global(.euiButton__content) { - padding: 0 !important; - } - - &:hover, - &:focus { - border: 1px solid var(--buttonSecondaryHoverColor) !important; - } - - svg path { - fill: var(--euiTextSubduedColor) !important; - } - - &:hover svg path, - &:focus svg path { - fill: var(--wbHoverIconColor) !important; - } - - &.activeBtn { - background: var(--browserComponentActive) !important; - border: 1px solid var(--euiColorSecondary); - - svg path { - fill: var(--wbActiveIconColor) !important; - } - - &:hover, - &:focus { - border: 1px solid var(--buttonSecondaryHoverColor) !important; - } - } -} - -.submitButton { - color: var(--rsSubmitBtn) !important; - width: 44px !important; - height: 44px !important; - - &Loading { - position: absolute; - left: 0; - opacity: 0.5; - margin-top: -10px; - svg { - width: 17px !important; - height: 17px !important; - } - } - - svg { - width: 24px; - height: 24px; - } -} - -.tooltipText { - font-size: 12px !important; -} diff --git a/redisinsight/ui/src/components/query/index.ts b/redisinsight/ui/src/components/query/index.ts index cf4185d43b..00c92dffa3 100644 --- a/redisinsight/ui/src/components/query/index.ts +++ b/redisinsight/ui/src/components/query/index.ts @@ -1,3 +1,9 @@ -import QueryWrapper from './QueryWrapper' +import QueryCard from './query-card' +import QueryActions from './query-actions' +import QueryTutorials from './query-tutorials' -export default QueryWrapper +export { + QueryCard, + QueryActions, + QueryTutorials +} diff --git a/redisinsight/ui/src/components/query/query-actions/QueryActions.spec.tsx b/redisinsight/ui/src/components/query/query-actions/QueryActions.spec.tsx new file mode 100644 index 0000000000..a913298d14 --- /dev/null +++ b/redisinsight/ui/src/components/query/query-actions/QueryActions.spec.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { mock } from 'ts-mockito' +import { render, screen, fireEvent } from 'uiSrc/utils/test-utils' + +import QueryActions, { Props } from './QueryActions' + +const mockedProps = mock() + +describe('QueryActions', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call props on click buttons', () => { + const onChangeMode = jest.fn() + const onChangeGroupMode = jest.fn() + const onSubmit = jest.fn() + + render( + + ) + + fireEvent.click(screen.getByTestId('btn-change-mode')) + expect(onChangeMode).toBeCalled() + + fireEvent.click(screen.getByTestId('btn-change-group-mode')) + expect(onChangeGroupMode).toBeCalled() + + fireEvent.click(screen.getByTestId('btn-submit')) + expect(onSubmit).toBeCalled() + }) +}) diff --git a/redisinsight/ui/src/components/query/query-actions/QueryActions.tsx b/redisinsight/ui/src/components/query/query-actions/QueryActions.tsx new file mode 100644 index 0000000000..b4764713a3 --- /dev/null +++ b/redisinsight/ui/src/components/query/query-actions/QueryActions.tsx @@ -0,0 +1,129 @@ +import React, { useRef } from 'react' + +import cx from 'classnames' +import { EuiButton, EuiSpacer, EuiText, EuiToolTip } from '@elastic/eui' +import { ResultsMode, RunQueryMode } from 'uiSrc/slices/interfaces' +import { KEYBOARD_SHORTCUTS } from 'uiSrc/constants' +import { KeyboardShortcut } from 'uiSrc/components' +import { isGroupMode } from 'uiSrc/utils' + +import GroupModeIcon from 'uiSrc/assets/img/icons/group_mode.svg?react' +import RawModeIcon from 'uiSrc/assets/img/icons/raw_mode.svg?react' + +import Divider from 'uiSrc/components/divider/Divider' +import styles from './styles.module.scss' + +export interface Props { + onChangeMode?: () => void + onChangeGroupMode?: () => void + onSubmit: () => void + activeMode: RunQueryMode + resultsMode?: ResultsMode + isLoading?: boolean + isDisabled?: boolean +} + +const QueryActions = (props: Props) => { + const { + isLoading, + isDisabled, + activeMode, + resultsMode, + onChangeMode, + onChangeGroupMode, + onSubmit, + } = props + const runTooltipRef = useRef(null) + + const KeyBoardTooltipContent = KEYBOARD_SHORTCUTS?.workbench?.runQuery && ( + <> + + {KEYBOARD_SHORTCUTS.workbench.runQuery?.label}: + + + + + ) + + return ( +
+ {onChangeMode && ( + + onChangeMode()} + iconType={RawModeIcon} + disabled={isLoading} + className={cx(styles.btn, styles.textBtn, { [styles.activeBtn]: activeMode === RunQueryMode.Raw })} + data-testid="btn-change-mode" + > + Raw mode + + + )} + {onChangeGroupMode && ( + + Groups the command results into a single window. +
+ When grouped, the results can be visualized only in the text format. + + )} + data-testid="group-results-tooltip" + > + onChangeGroupMode()} + disabled={isLoading} + iconType={GroupModeIcon} + className={cx(styles.btn, styles.textBtn, { [styles.activeBtn]: isGroupMode(resultsMode) })} + data-testid="btn-change-group-mode" + > + Group results + +
+ )} + + + { + onSubmit() + setTimeout(() => runTooltipRef?.current?.hideToolTip?.(), 0) + }} + isLoading={isLoading} + disabled={isLoading} + iconType="playFilled" + className={cx(styles.btn, styles.submitButton)} + aria-label="submit" + data-testid="btn-submit" + > + Run + + +
+ ) +} + +export default QueryActions diff --git a/redisinsight/ui/src/components/query/query-actions/index.ts b/redisinsight/ui/src/components/query/query-actions/index.ts new file mode 100644 index 0000000000..474915f557 --- /dev/null +++ b/redisinsight/ui/src/components/query/query-actions/index.ts @@ -0,0 +1,3 @@ +import QueryActions from './QueryActions' + +export default QueryActions diff --git a/redisinsight/ui/src/components/query/query-actions/styles.module.scss b/redisinsight/ui/src/components/query/query-actions/styles.module.scss new file mode 100644 index 0000000000..4764d8d231 --- /dev/null +++ b/redisinsight/ui/src/components/query/query-actions/styles.module.scss @@ -0,0 +1,82 @@ +.actions { + display: flex; + justify-content: space-between; + align-items: center; + + .btn { + height: 24px !important; + min-width: auto !important; + min-height: auto !important; + border-radius: 4px !important; + background: transparent !important; + box-shadow: none !important; + + color: var(--euiTextColor) !important; + border: 1px solid transparent !important; + + :global(.euiButton__content) { + padding: 0 6px; + } + + :global(.euiButton__text) { + color: var(--euiTextColor) !important; + font: normal normal 400 14px/17px Graphik, sans-serif !important + } + + &:focus, &:active { + outline: 0 !important; + } + + svg { + margin-top: 1px; + width: 14px; + height: 14px; + } + } + + .textBtn { + margin: 0 8px; + + svg path { + fill: var(--euiTextSubduedColor) !important; + } + + &.activeBtn { + background: var(--browserComponentActive) !important; + border: 1px solid var(--euiColorPrimary) !important; + } + } + + .submitButton { + margin-left: 8px; + + svg { + color: var(--rsSubmitBtn) !important; + } + + :global(.euiLoadingSpinner) { + width: 14px; + height: 14px; + color: var(--rsSubmitBtn) !important; + } + } + + .divider { + height: 20px; + margin-left: 8px; + } + + .tooltipText { + font-size: 12px !important; + } +} + +@include global.insights-open(1220px) { + .actions { + .btn { + :global(.euiButton__text) { + display: none; + } + } + } +} diff --git a/redisinsight/ui/src/components/query-card/QueryCard.spec.tsx b/redisinsight/ui/src/components/query/query-card/QueryCard.spec.tsx similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCard.spec.tsx rename to redisinsight/ui/src/components/query/query-card/QueryCard.spec.tsx diff --git a/redisinsight/ui/src/components/query-card/QueryCard.tsx b/redisinsight/ui/src/components/query/query-card/QueryCard.tsx similarity index 96% rename from redisinsight/ui/src/components/query-card/QueryCard.tsx rename to redisinsight/ui/src/components/query/query-card/QueryCard.tsx index d1890eaada..3f778e6859 100644 --- a/redisinsight/ui/src/components/query-card/QueryCard.tsx +++ b/redisinsight/ui/src/components/query/query-card/QueryCard.tsx @@ -5,15 +5,9 @@ import { EuiLoadingContent, keys } from '@elastic/eui' import { useParams } from 'react-router-dom' import { isNull } from 'lodash' -import { WBQueryType, ProfileQueryType, DEFAULT_TEXT_VIEW_TYPE } from 'uiSrc/pages/workbench/constants' -import { RunQueryMode, ResultsMode, ResultsSummary } from 'uiSrc/slices/interfaces/workbench' -import { - getWBQueryType, - getVisualizationsByCommand, - Maybe, - isGroupResults, - isSilentModeWithoutError, -} from 'uiSrc/utils' +import { DEFAULT_TEXT_VIEW_TYPE, ProfileQueryType, WBQueryType } from 'uiSrc/pages/workbench/constants' +import { ResultsMode, ResultsSummary, RunQueryMode } from 'uiSrc/slices/interfaces/workbench' +import { getVisualizationsByCommand, getWBQueryType, isGroupResults, isSilentModeWithoutError, Maybe, } from 'uiSrc/utils' import { appPluginsSelector } from 'uiSrc/slices/app/plugins' import { CommandExecutionResult, IPluginVisualization } from 'uiSrc/slices/interfaces' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliDefaultResult/QueryCardCliDefaultResult.spec.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCliDefaultResult/QueryCardCliDefaultResult.spec.tsx similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCliDefaultResult/QueryCardCliDefaultResult.spec.tsx rename to redisinsight/ui/src/components/query/query-card/QueryCardCliDefaultResult/QueryCardCliDefaultResult.spec.tsx diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliDefaultResult/QueryCardCliDefaultResult.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCliDefaultResult/QueryCardCliDefaultResult.tsx similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCliDefaultResult/QueryCardCliDefaultResult.tsx rename to redisinsight/ui/src/components/query/query-card/QueryCardCliDefaultResult/QueryCardCliDefaultResult.tsx diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliDefaultResult/index.ts b/redisinsight/ui/src/components/query/query-card/QueryCardCliDefaultResult/index.ts similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCliDefaultResult/index.ts rename to redisinsight/ui/src/components/query/query-card/QueryCardCliDefaultResult/index.ts diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliDefaultResult/styles.module.scss b/redisinsight/ui/src/components/query/query-card/QueryCardCliDefaultResult/styles.module.scss similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCliDefaultResult/styles.module.scss rename to redisinsight/ui/src/components/query/query-card/QueryCardCliDefaultResult/styles.module.scss diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx rename to redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.spec.tsx diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.tsx similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.tsx rename to redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/QueryCardCliGroupResult.tsx diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliGroupResult/index.ts b/redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/index.ts similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCliGroupResult/index.ts rename to redisinsight/ui/src/components/query/query-card/QueryCardCliGroupResult/index.ts diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.spec.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCliPlugin/QueryCardCliPlugin.spec.tsx similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.spec.tsx rename to redisinsight/ui/src/components/query/query-card/QueryCardCliPlugin/QueryCardCliPlugin.spec.tsx diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx similarity index 98% rename from redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx rename to redisinsight/ui/src/components/query/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx index 0b07487bca..edb4cdc390 100644 --- a/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx +++ b/redisinsight/ui/src/components/query/query-card/QueryCardCliPlugin/QueryCardCliPlugin.tsx @@ -44,7 +44,14 @@ enum ActionTypes { const baseUrl = getBaseApiUrl() const QueryCardCliPlugin = (props: Props) => { - const { query, id, result, setMessage, commandId, mode = RunQueryMode.Raw } = props + const { + query, + id, + result, + setMessage, + commandId, + mode = RunQueryMode.Raw, + } = props const { visualizations = [], staticPath } = useSelector(appPluginsSelector) const { modules = [] } = useSelector(connectedInstanceSelector) const serverInfo = useSelector(appServerInfoSelector) diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/index.ts b/redisinsight/ui/src/components/query/query-card/QueryCardCliPlugin/index.ts similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCliPlugin/index.ts rename to redisinsight/ui/src/components/query/query-card/QueryCardCliPlugin/index.ts diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliPlugin/styles.module.scss b/redisinsight/ui/src/components/query/query-card/QueryCardCliPlugin/styles.module.scss similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCliPlugin/styles.module.scss rename to redisinsight/ui/src/components/query/query-card/QueryCardCliPlugin/styles.module.scss diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliResultWrapper/QueryCardCliResultWrapper.spec.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCliResultWrapper/QueryCardCliResultWrapper.spec.tsx similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCliResultWrapper/QueryCardCliResultWrapper.spec.tsx rename to redisinsight/ui/src/components/query/query-card/QueryCardCliResultWrapper/QueryCardCliResultWrapper.spec.tsx diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliResultWrapper/QueryCardCliResultWrapper.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCliResultWrapper/QueryCardCliResultWrapper.tsx similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCliResultWrapper/QueryCardCliResultWrapper.tsx rename to redisinsight/ui/src/components/query/query-card/QueryCardCliResultWrapper/QueryCardCliResultWrapper.tsx diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliResultWrapper/index.ts b/redisinsight/ui/src/components/query/query-card/QueryCardCliResultWrapper/index.ts similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCliResultWrapper/index.ts rename to redisinsight/ui/src/components/query/query-card/QueryCardCliResultWrapper/index.ts diff --git a/redisinsight/ui/src/components/query-card/QueryCardCliResultWrapper/styles.module.scss b/redisinsight/ui/src/components/query/query-card/QueryCardCliResultWrapper/styles.module.scss similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCliResultWrapper/styles.module.scss rename to redisinsight/ui/src/components/query/query-card/QueryCardCliResultWrapper/styles.module.scss diff --git a/redisinsight/ui/src/components/query-card/QueryCardCommonResult/QueryCardCommonResult.spec.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/QueryCardCommonResult.spec.tsx similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCommonResult/QueryCardCommonResult.spec.tsx rename to redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/QueryCardCommonResult.spec.tsx diff --git a/redisinsight/ui/src/components/query-card/QueryCardCommonResult/QueryCardCommonResult.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/QueryCardCommonResult.tsx similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCommonResult/QueryCardCommonResult.tsx rename to redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/QueryCardCommonResult.tsx diff --git a/redisinsight/ui/src/components/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx rename to redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/components/CommonErrorResponse/CommonErrorResponse.tsx diff --git a/redisinsight/ui/src/components/query-card/QueryCardCommonResult/components/CommonErrorResponse/index.ts b/redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/components/CommonErrorResponse/index.ts similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCommonResult/components/CommonErrorResponse/index.ts rename to redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/components/CommonErrorResponse/index.ts diff --git a/redisinsight/ui/src/components/query-card/QueryCardCommonResult/index.ts b/redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/index.ts similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCommonResult/index.ts rename to redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/index.ts diff --git a/redisinsight/ui/src/components/query-card/QueryCardCommonResult/styles.module.scss b/redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/styles.module.scss similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardCommonResult/styles.module.scss rename to redisinsight/ui/src/components/query/query-card/QueryCardCommonResult/styles.module.scss diff --git a/redisinsight/ui/src/components/query-card/QueryCardHeader/QueryCardHeader.spec.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.spec.tsx similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardHeader/QueryCardHeader.spec.tsx rename to redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.spec.tsx diff --git a/redisinsight/ui/src/components/query-card/QueryCardHeader/QueryCardHeader.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.tsx similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardHeader/QueryCardHeader.tsx rename to redisinsight/ui/src/components/query/query-card/QueryCardHeader/QueryCardHeader.tsx diff --git a/redisinsight/ui/src/components/query-card/QueryCardHeader/index.ts b/redisinsight/ui/src/components/query/query-card/QueryCardHeader/index.ts similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardHeader/index.ts rename to redisinsight/ui/src/components/query/query-card/QueryCardHeader/index.ts diff --git a/redisinsight/ui/src/components/query-card/QueryCardHeader/styles.module.scss b/redisinsight/ui/src/components/query/query-card/QueryCardHeader/styles.module.scss similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardHeader/styles.module.scss rename to redisinsight/ui/src/components/query/query-card/QueryCardHeader/styles.module.scss diff --git a/redisinsight/ui/src/components/query-card/QueryCardTooltip/QueryCardTooltip.spec.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardTooltip/QueryCardTooltip.spec.tsx similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardTooltip/QueryCardTooltip.spec.tsx rename to redisinsight/ui/src/components/query/query-card/QueryCardTooltip/QueryCardTooltip.spec.tsx diff --git a/redisinsight/ui/src/components/query-card/QueryCardTooltip/QueryCardTooltip.tsx b/redisinsight/ui/src/components/query/query-card/QueryCardTooltip/QueryCardTooltip.tsx similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardTooltip/QueryCardTooltip.tsx rename to redisinsight/ui/src/components/query/query-card/QueryCardTooltip/QueryCardTooltip.tsx diff --git a/redisinsight/ui/src/components/query-card/QueryCardTooltip/index.ts b/redisinsight/ui/src/components/query/query-card/QueryCardTooltip/index.ts similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardTooltip/index.ts rename to redisinsight/ui/src/components/query/query-card/QueryCardTooltip/index.ts diff --git a/redisinsight/ui/src/components/query-card/QueryCardTooltip/styles.module.scss b/redisinsight/ui/src/components/query/query-card/QueryCardTooltip/styles.module.scss similarity index 100% rename from redisinsight/ui/src/components/query-card/QueryCardTooltip/styles.module.scss rename to redisinsight/ui/src/components/query/query-card/QueryCardTooltip/styles.module.scss diff --git a/redisinsight/ui/src/components/query-card/index.ts b/redisinsight/ui/src/components/query/query-card/index.ts similarity index 100% rename from redisinsight/ui/src/components/query-card/index.ts rename to redisinsight/ui/src/components/query/query-card/index.ts diff --git a/redisinsight/ui/src/components/query-card/styles.module.scss b/redisinsight/ui/src/components/query/query-card/styles.module.scss similarity index 100% rename from redisinsight/ui/src/components/query-card/styles.module.scss rename to redisinsight/ui/src/components/query/query-card/styles.module.scss diff --git a/redisinsight/ui/src/components/query/query-tutorials/QueryTutorials.spec.tsx b/redisinsight/ui/src/components/query/query-tutorials/QueryTutorials.spec.tsx new file mode 100644 index 0000000000..6115ca6092 --- /dev/null +++ b/redisinsight/ui/src/components/query/query-tutorials/QueryTutorials.spec.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import reactRouterDom from 'react-router-dom' +import { fireEvent, render, screen } from 'uiSrc/utils/test-utils' + +import { findTutorialPath } from 'uiSrc/utils' + +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +import { TutorialsIds } from 'uiSrc/constants' +import QueryTutorials from './QueryTutorials' + +const mockedTutorials = [ + { + id: TutorialsIds.IntroToSearch, + title: 'Intro to search' + }, + { + id: TutorialsIds.BasicRedisUseCases, + title: 'Basic use cases' + }, + { + id: TutorialsIds.IntroVectorSearch, + title: 'Intro to vector search' + }, +] + +jest.mock('uiSrc/utils', () => ({ + ...jest.requireActual('uiSrc/utils'), + findTutorialPath: jest.fn(), +})) + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +describe('QueryTutorial', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call proper history push after click on guide with tutorial', () => { + const pushMock = jest.fn() + reactRouterDom.useHistory = jest.fn().mockReturnValue({ push: pushMock }); + (findTutorialPath as jest.Mock).mockImplementation(() => 'path') + + render() + + fireEvent.click(screen.getByTestId('query-tutorials-link_sq-intro')) + + expect(pushMock).toHaveBeenCalledWith({ + search: 'path=tutorials/path' + }) + }) + + it('should call proper telemetry event after click on guide', () => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock); + (findTutorialPath as jest.Mock).mockImplementation(() => 'path') + + render() + + fireEvent.click(screen.getByTestId('query-tutorials-link_sq-intro')) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.EXPLORE_PANEL_TUTORIAL_OPENED, + eventData: { + path: 'path', + databaseId: 'instanceId', + source: 'source', + } + }) + }) +}) diff --git a/redisinsight/ui/src/components/query/query-tutorials/QueryTutorials.tsx b/redisinsight/ui/src/components/query/query-tutorials/QueryTutorials.tsx new file mode 100644 index 0000000000..0fed95df93 --- /dev/null +++ b/redisinsight/ui/src/components/query/query-tutorials/QueryTutorials.tsx @@ -0,0 +1,59 @@ +import React from 'react' + +import { EuiLink, EuiText } from '@elastic/eui' +import { useDispatch } from 'react-redux' +import { useHistory, useParams } from 'react-router-dom' +import { findTutorialPath } from 'uiSrc/utils' +import { openTutorialByPath } from 'uiSrc/slices/panels/sidePanels' +import { sendEventTelemetry, TELEMETRY_EMPTY_VALUE, TelemetryEvent } from 'uiSrc/telemetry' + +import styles from './styles.module.scss' + +export interface Props { + tutorials: Array<{ + id: string + title: string + }> + source: string +} + +const QueryTutorials = ({ tutorials, source }: Props) => { + const dispatch = useDispatch() + const history = useHistory() + const { instanceId } = useParams<{ instanceId: string }>() + + const handleClickTutorial = (id: string) => { + const tutorialPath = findTutorialPath({ id }) + dispatch(openTutorialByPath(tutorialPath, history, true)) + + sendEventTelemetry({ + event: TelemetryEvent.EXPLORE_PANEL_TUTORIAL_OPENED, + eventData: { + path: tutorialPath, + databaseId: instanceId || TELEMETRY_EMPTY_VALUE, + source, + } + }) + } + + return ( +
+ + Tutorials: + + {tutorials.map(({ id, title }) => ( + handleClickTutorial(id)} + data-testid={`query-tutorials-link_${id}`} + > + {title} + + ))} +
+ ) +} + +export default QueryTutorials diff --git a/redisinsight/ui/src/components/query/query-tutorials/index.ts b/redisinsight/ui/src/components/query/query-tutorials/index.ts new file mode 100644 index 0000000000..c2c46d3a39 --- /dev/null +++ b/redisinsight/ui/src/components/query/query-tutorials/index.ts @@ -0,0 +1,3 @@ +import QueryTutorials from './QueryTutorials' + +export default QueryTutorials diff --git a/redisinsight/ui/src/components/query/query-tutorials/styles.module.scss b/redisinsight/ui/src/components/query/query-tutorials/styles.module.scss new file mode 100644 index 0000000000..0e1e56dc13 --- /dev/null +++ b/redisinsight/ui/src/components/query/query-tutorials/styles.module.scss @@ -0,0 +1,48 @@ +.container { + display: flex; + align-items: center; + + .title { + margin-right: 8px; + } + + .tutorialLink { + padding: 4px 8px; + + background-color: var(--browserTableRowEven); + + border-radius: 4px; + border: 1px solid var(--separatorColor); + + color: var(--htmlColor) !important; + text-decoration: none !important; + font-size: 12px; + + &:not(:first-of-type) { + margin-left: 8px; + } + + &:global(.euiLink) { + &:hover, &:focus { + color: var(--htmlColor); + text-decoration: underline !important; + outline: none !important; + animation: none !important; + } + } + } +} + +@include global.insights-open(1280px) { + .title { + display: none + } +} + +@include global.insights-open(1024px) { + .tutorialLink:last-of-type { + display: none; + } +} + + diff --git a/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/expert-chat/ExpertChat.tsx b/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/expert-chat/ExpertChat.tsx index ae3f60d3b8..0dd2cf4048 100644 --- a/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/expert-chat/ExpertChat.tsx +++ b/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/expert-chat/ExpertChat.tsx @@ -19,7 +19,7 @@ import { oauthCloudUserSelector } from 'uiSrc/slices/oauth/cloud' import { fetchRedisearchListAction } from 'uiSrc/slices/browser/redisearch' import TelescopeImg from 'uiSrc/assets/img/telescope-dark.svg?react' import { openTutorialByPath } from 'uiSrc/slices/panels/sidePanels' -import { SAMPLE_DATA_TUTORIAL } from 'uiSrc/constants' +import { TutorialsIds } from 'uiSrc/constants' import NoIndexesInitialMessage from './components/no-indexes-initial-message' import ExpertChatHeader from './components/expert-chat-header' @@ -157,7 +157,7 @@ const ExpertChat = () => { }, []) const handleClickTutorial = () => { - const tutorialPath = findTutorialPath({ id: SAMPLE_DATA_TUTORIAL }) + const tutorialPath = findTutorialPath({ id: TutorialsIds.RedisUseCases }) dispatch(openTutorialByPath(tutorialPath, history, true)) sendEventTelemetry({ diff --git a/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/components/code-block/CodeBlock.spec.tsx b/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/components/code-block/CodeBlock.spec.tsx index 7d1502fda6..df43dc81bb 100644 --- a/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/components/code-block/CodeBlock.spec.tsx +++ b/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/components/code-block/CodeBlock.spec.tsx @@ -5,6 +5,7 @@ import { ButtonLang } from 'uiSrc/utils/formatters/markdown/remarkCode' import { sendWBCommand } from 'uiSrc/slices/workbench/wb-results' import { setDbIndexState } from 'uiSrc/slices/app/context' +import { CommandExecutionType } from 'uiSrc/slices/interfaces' import CodeBlock from './CodeBlock' let store: typeof mockedStore @@ -27,7 +28,7 @@ describe('CodeBlock', () => { expect(store.getActions()).toEqual([ sendWBCommand({ commandId: expect.any(String), - commands: ['info'] + commands: ['info'], }), setDbIndexState(true) ]) diff --git a/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/components/code-block/CodeBlock.tsx b/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/components/code-block/CodeBlock.tsx index 30b51199a4..eb319636af 100644 --- a/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/components/code-block/CodeBlock.tsx +++ b/redisinsight/ui/src/components/side-panels/panels/ai-assistant/components/shared/markdown-message/components/code-block/CodeBlock.tsx @@ -20,7 +20,13 @@ const CodeBlock = (props: Props) => { const handleApply = (params?: CodeButtonParams, onFinish?: () => void) => { onRunCommand?.(children) - dispatch(sendWbQueryAction(children, null, params, { afterAll: onFinish }, onFinish)) + dispatch(sendWbQueryAction( + children, + null, + params, + { afterAll: onFinish }, + onFinish + )) } return ( diff --git a/redisinsight/ui/src/components/side-panels/panels/enablement-area/EnablementAreaWrapper.tsx b/redisinsight/ui/src/components/side-panels/panels/enablement-area/EnablementAreaWrapper.tsx index 72acc86174..a4816a17ad 100644 --- a/redisinsight/ui/src/components/side-panels/panels/enablement-area/EnablementAreaWrapper.tsx +++ b/redisinsight/ui/src/components/side-panels/panels/enablement-area/EnablementAreaWrapper.tsx @@ -26,7 +26,13 @@ const EnablementAreaWrapper = () => { params?: CodeButtonParams, onFinish?: () => void ) => { - dispatch(sendWbQueryAction(script, null, params, { afterAll: onFinish }, onFinish)) + dispatch(sendWbQueryAction( + script, + null, + params, + { afterAll: onFinish }, + onFinish + )) } const onOpenInternalPage = ({ path, manifestPath }: IInternalPage) => { diff --git a/redisinsight/ui/src/components/side-panels/panels/live-time-recommendations/components/recommendation/Recommendation.tsx b/redisinsight/ui/src/components/side-panels/panels/live-time-recommendations/components/recommendation/Recommendation.tsx index 5f46da2728..5cacae6882 100644 --- a/redisinsight/ui/src/components/side-panels/panels/live-time-recommendations/components/recommendation/Recommendation.tsx +++ b/redisinsight/ui/src/components/side-panels/panels/live-time-recommendations/components/recommendation/Recommendation.tsx @@ -17,7 +17,7 @@ import { isUndefined } from 'lodash' import cx from 'classnames' import { Nullable, Maybe, findTutorialPath } from 'uiSrc/utils' -import { Theme } from 'uiSrc/constants' +import { Pages, Theme } from 'uiSrc/constants' import { RecommendationVoting, RecommendationCopyComponent, RecommendationBody } from 'uiSrc/components' import { Vote } from 'uiSrc/constants/recommendations' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' @@ -77,7 +77,12 @@ const Recommendation = ({ } }) - const tutorialPath = findTutorialPath({ id: tutorialId ?? '' }) + if (!tutorialId) { + history.push(Pages.workbench(instanceId)) + return + } + + const tutorialPath = findTutorialPath({ id: tutorialId }) dispatch(openTutorialByPath(tutorialPath ?? '', history)) } diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index 027d917a64..41c0c4da2f 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -113,6 +113,7 @@ enum ApiEndpoints { REDISEARCH = 'redisearch', REDISEARCH_SEARCH = 'redisearch/search', + REDISEARCH_INFO = 'redisearch/info', HISTORY = 'history', FEATURES = 'features', diff --git a/redisinsight/ui/src/constants/browser.ts b/redisinsight/ui/src/constants/browser.ts index 49d2912cdb..453f28dd97 100644 --- a/redisinsight/ui/src/constants/browser.ts +++ b/redisinsight/ui/src/constants/browser.ts @@ -1,7 +1,5 @@ import { KeyValueFormat, SortOrder } from './keys' -export const SAMPLE_DATA_TUTORIAL = 'redis_use_cases' - export const DEFAULT_DELIMITER = ':' export const DEFAULT_TREE_SORTING = SortOrder.ASC export const DEFAULT_SHOW_HIDDEN_RECOMMENDATIONS = false diff --git a/redisinsight/ui/src/constants/commands.ts b/redisinsight/ui/src/constants/commands.ts index 28d6ba575b..3130ada6f7 100644 --- a/redisinsight/ui/src/constants/commands.ts +++ b/redisinsight/ui/src/constants/commands.ts @@ -1,15 +1,15 @@ export interface ICommands { - [key: string]: ICommand; + [key: string]: ICommand } export interface ICommand { - name?: string; - summary: string; - complexity?: string; - arguments?: ICommandArg[]; - since: string; - group: CommandGroup | string; - provider?: string; + name?: string + summary: string + complexity?: string + arguments?: ICommandArg[] + since: string + group: CommandGroup | string + provider?: string } export enum CommandProvider { @@ -18,19 +18,49 @@ export enum CommandProvider { } export interface ICommandArg { - name?: string[] | string; - type?: CommandArgsType[] | CommandArgsType | string | string[]; - optional?: boolean; - enum?: string[]; - block?: ICommandArg[]; - command?: string; - multiple?: boolean; - variadic?: boolean; - dsl?: string; + name?: string[] | string + type?: CommandArgsType[] | CommandArgsType | string | string[] + optional?: boolean + enum?: string[] + block?: ICommandArg[] + command?: string + multiple?: boolean + variadic?: boolean + dsl?: string +} + +export enum ICommandTokenType { + PureToken = 'pure-token', + Block = 'block', + OneOf = 'oneof', + String = 'string', + Double = 'double', + Enum = 'enum', + Integer = 'integer', + Key = 'key', + POSIXTime = 'posix time', + Pattern = 'pattern', +} + +export interface IRedisCommand { + name?: string + summary?: string + expression?: boolean + type?: ICommandTokenType + token?: string + optional?: boolean + multiple?: boolean + arguments?: IRedisCommand[] + variadic?: boolean + dsl?: string +} + +export interface IRedisCommandTree extends IRedisCommand { + parent?: IRedisCommandTree } export interface ICommandArgGenerated extends ICommandArg { - generatedName?: string | string[]; + generatedName?: string | string[] } export enum CommandArgsType { diff --git a/redisinsight/ui/src/constants/index.ts b/redisinsight/ui/src/constants/index.ts index 506c1a41d6..75579dc687 100644 --- a/redisinsight/ui/src/constants/index.ts +++ b/redisinsight/ui/src/constants/index.ts @@ -31,5 +31,6 @@ export * from './customErrorCodes' export * from './securityField' export * from './redisearch' export * from './browser/keyDetailsHeader' +export * from './tutorials' export * from './datetime' export { ApiEndpoints, BrowserStorageItem, ApiStatusCode, apiErrors } diff --git a/redisinsight/ui/src/constants/keyboardShortcuts.tsx b/redisinsight/ui/src/constants/keyboardShortcuts.tsx index bf98ebb298..188ad8d421 100644 --- a/redisinsight/ui/src/constants/keyboardShortcuts.tsx +++ b/redisinsight/ui/src/constants/keyboardShortcuts.tsx @@ -107,8 +107,8 @@ const MAC_SHORTCUTS = { }, workbench: { runQuery: { - label: 'Run', - description: 'Run Command', + label: 'Run commands', + description: 'Run Commands', keys: [(), 'Enter'], }, nextLine: { diff --git a/redisinsight/ui/src/constants/monaco/monaco.ts b/redisinsight/ui/src/constants/monaco/monaco.ts index a1ba8b1300..a8eea653d4 100644 --- a/redisinsight/ui/src/constants/monaco/monaco.ts +++ b/redisinsight/ui/src/constants/monaco/monaco.ts @@ -39,6 +39,26 @@ export enum MonacoLanguage { JMESPath = 'jmespathLanguage', SQLiteFunctions = 'sqliteFunctions', Text = 'text', + RediSearch = 'redisearch', +} + +export const defaultMonacoOptions: monacoEditor.editor.IStandaloneEditorConstructionOptions = { + tabCompletion: 'on', + wordWrap: 'on', + padding: { top: 10 }, + automaticLayout: true, + formatOnPaste: false, + glyphMargin: true, + stickyScroll: { + enabled: true, + defaultModel: 'indentationModel' + }, + suggest: { + preview: true, + showStatusBar: true, + showIcons: false, + }, + lineNumbersMinChars: 4 } export const DEFAULT_MONACO_YAML_URI = 'http://example.com/schema-name.json' diff --git a/redisinsight/ui/src/constants/monaco/theme.ts b/redisinsight/ui/src/constants/monaco/theme.ts index bdfcbe0d29..9cb3e860af 100644 --- a/redisinsight/ui/src/constants/monaco/theme.ts +++ b/redisinsight/ui/src/constants/monaco/theme.ts @@ -1,11 +1,53 @@ import { monaco } from 'react-monaco-editor' +export const redisearchDarKThemeRules = [ + { token: 'keyword', foreground: '#C8B5F2' }, + { token: 'argument.block.0', foreground: '#8CD7B9' }, + { token: 'argument.block.1', foreground: '#72B59B' }, + { token: 'argument.block.2', foreground: '#3A8365' }, + { token: 'argument.block.3', foreground: '#244F3E' }, + { token: 'argument.block.withToken.0', foreground: '#8CD7B9' }, + { token: 'argument.block.withToken.1', foreground: '#72B59B' }, + { token: 'argument.block.withToken.2', foreground: '#3A8365' }, + { token: 'argument.block.withToken.3', foreground: '#244F3E' }, + { token: 'index', foreground: '#DE47BB' }, + { token: 'query', foreground: '#7B90E0' }, + { token: 'field', foreground: '#B02C30' }, + { token: 'query.operator', foreground: '#B9F0F3' }, + { token: 'function', foreground: '#9E7EE8' }, +] + +export const redisearchLightThemeRules = [ + { token: 'keyword', foreground: '#7547DE' }, + { token: 'argument.block.0', foreground: '#8CD7B9' }, + { token: 'argument.block.1', foreground: '#72B59B' }, + { token: 'argument.block.2', foreground: '#3A8365' }, + { token: 'argument.block.3', foreground: '#244F3E' }, + { token: 'argument.block.withToken.0', foreground: '#8CD7B9' }, + { token: 'argument.block.withToken.1', foreground: '#72B59B' }, + { token: 'argument.block.withToken.2', foreground: '#3A8365' }, + { token: 'argument.block.withToken.3', foreground: '#244F3E' }, + { token: 'index', foreground: '#DE47BB' }, + { token: 'query', foreground: '#7B90E0' }, + { token: 'field', foreground: '#B02C30' }, + { token: 'query.operator', foreground: '#B9F0F3' }, + { token: 'function', foreground: '#9E7EE8' }, +] + export const darkThemeRules = [ - { token: 'function', foreground: 'BFBC4E' } + { token: 'function', foreground: 'BFBC4E' }, + ...redisearchDarKThemeRules.map((rule) => ({ + ...rule, + token: `${rule.token}` + })) ] export const lightThemeRules = [ - { token: 'function', foreground: '795E26' } + { token: 'function', foreground: '795E26' }, + ...redisearchLightThemeRules.map((rule) => ({ + ...rule, + token: `${rule.token}.redisearch` + })) ] export enum MonacoThemes { diff --git a/redisinsight/ui/src/constants/pages.ts b/redisinsight/ui/src/constants/pages.ts index 29cc5c1f4b..b063b6cc68 100644 --- a/redisinsight/ui/src/constants/pages.ts +++ b/redisinsight/ui/src/constants/pages.ts @@ -1,12 +1,14 @@ import { FeatureFlags } from 'uiSrc/constants' +import { Maybe } from 'uiSrc/utils' export interface IRoute { path: any - component: (routes: any) => JSX.Element | Element | null + component?: (routes: any) => JSX.Element | Element | null pageName?: PageNames exact?: boolean routes?: any protected?: boolean + redirect?: (params: Record>) => string isAvailableWithoutAgreements?: boolean featureFlag?: FeatureFlags } @@ -14,6 +16,7 @@ export interface IRoute { export enum PageNames { workbench = 'workbench', browser = 'browser', + search = 'search', slowLog = 'slowlog', pubSub = 'pub-sub', analytics = 'analytics', @@ -46,6 +49,7 @@ export const Pages = { sentinelDatabasesResult: `${sentinel}/databases-result`, browser: (instanceId: string) => `/${instanceId}/${PageNames.browser}`, workbench: (instanceId: string) => `/${instanceId}/${PageNames.workbench}`, + search: (instanceId: string) => `/${instanceId}/${PageNames.search}`, pubSub: (instanceId: string) => `/${instanceId}/${PageNames.pubSub}`, analytics: (instanceId: string) => `/${instanceId}/${PageNames.analytics}`, slowLog: (instanceId: string) => `/${instanceId}/${PageNames.analytics}/${PageNames.slowLog}`, diff --git a/redisinsight/ui/src/constants/storage.ts b/redisinsight/ui/src/constants/storage.ts index bd92ec4bf4..f0eb778d49 100644 --- a/redisinsight/ui/src/constants/storage.ts +++ b/redisinsight/ui/src/constants/storage.ts @@ -19,6 +19,7 @@ enum BrowserStorageItem { bulkActionDeleteId = 'bulkActionDeleteId', dbConfig = 'dbConfig_', RunQueryMode = 'RunQueryMode', + SQRunQueryMode = 'SQRunQueryMode', wbCleanUp = 'wbCleanUp', viewFormat = 'viewFormat', wbGroupMode = 'wbGroupMode', diff --git a/redisinsight/ui/src/constants/tutorials.ts b/redisinsight/ui/src/constants/tutorials.ts new file mode 100644 index 0000000000..261e38c3f2 --- /dev/null +++ b/redisinsight/ui/src/constants/tutorials.ts @@ -0,0 +1,13 @@ +enum TutorialsIds { + IntroToSearch = 'sq-intro', + IntroToJSON = 'ds-json-intro', + BasicRedisUseCases = 'redis_use_cases_basic', + RedisUseCases = 'redis_use_cases', + IntroVectorSearch = 'vss-intro', + ExactMatch = 'sq-exact-match', + FullTextSearch = 'sq-full-text', +} + +export { + TutorialsIds +} diff --git a/redisinsight/ui/src/mocks/data/mocked_redis_commands.ts b/redisinsight/ui/src/mocks/data/mocked_redis_commands.ts new file mode 100644 index 0000000000..359c3b12ed --- /dev/null +++ b/redisinsight/ui/src/mocks/data/mocked_redis_commands.ts @@ -0,0 +1,1521 @@ +export const MOCKED_REDIS_COMMANDS = { + 'FT.SEARCH': { + summary: 'Searches the index with a textual query, returning either documents or just ids', + complexity: 'O(N)', + history: [ + [ + '2.0.0', + 'Deprecated `WITHPAYLOADS` and `PAYLOAD` arguments' + ] + ], + arguments: [ + { + name: 'index', + type: 'string' + }, + { + name: 'query', + type: 'string' + }, + { + name: 'nocontent', + type: 'pure-token', + token: 'NOCONTENT', + optional: true + }, + { + name: 'verbatim', + type: 'pure-token', + token: 'VERBATIM', + optional: true + }, + { + name: 'nostopwords', + type: 'pure-token', + token: 'NOSTOPWORDS', + optional: true + }, + { + name: 'withscores', + type: 'pure-token', + token: 'WITHSCORES', + optional: true + }, + { + name: 'withpayloads', + type: 'pure-token', + token: 'WITHPAYLOADS', + optional: true + }, + { + name: 'withsortkeys', + type: 'pure-token', + token: 'WITHSORTKEYS', + optional: true + }, + { + name: 'filter', + type: 'block', + optional: true, + multiple: true, + arguments: [ + { + name: 'numeric_field', + type: 'string', + token: 'FILTER' + }, + { + name: 'min', + type: 'double' + }, + { + name: 'max', + type: 'double' + } + ] + }, + { + name: 'geo_filter', + type: 'block', + optional: true, + multiple: true, + arguments: [ + { + name: 'geo_field', + type: 'string', + token: 'GEOFILTER' + }, + { + name: 'lon', + type: 'double' + }, + { + name: 'lat', + type: 'double' + }, + { + name: 'radius', + type: 'double' + }, + { + name: 'radius_type', + type: 'oneof', + arguments: [ + { + name: 'm', + type: 'pure-token', + token: 'm' + }, + { + name: 'km', + type: 'pure-token', + token: 'km' + }, + { + name: 'mi', + type: 'pure-token', + token: 'mi' + }, + { + name: 'ft', + type: 'pure-token', + token: 'ft' + } + ] + } + ] + }, + { + name: 'in_keys', + type: 'block', + optional: true, + arguments: [ + { + name: 'count', + type: 'string', + token: 'INKEYS' + }, + { + name: 'key', + type: 'string', + multiple: true + } + ] + }, + { + name: 'in_fields', + type: 'block', + optional: true, + arguments: [ + { + name: 'count', + type: 'string', + token: 'INFIELDS' + }, + { + name: 'field', + type: 'string', + multiple: true + } + ] + }, + { + name: 'return', + type: 'block', + optional: true, + arguments: [ + { + name: 'count', + type: 'string', + token: 'RETURN' + }, + { + name: 'identifiers', + type: 'block', + multiple: true, + arguments: [ + { + name: 'identifier', + type: 'string' + }, + { + name: 'property', + type: 'string', + token: 'AS', + optional: true + } + ] + } + ] + }, + { + name: 'summarize', + type: 'block', + optional: true, + arguments: [ + { + name: 'summarize', + type: 'pure-token', + token: 'SUMMARIZE' + }, + { + name: 'fields', + type: 'block', + optional: true, + arguments: [ + { + name: 'count', + type: 'string', + token: 'FIELDS' + }, + { + name: 'field', + type: 'string', + multiple: true + } + ] + }, + { + name: 'num', + type: 'integer', + token: 'FRAGS', + optional: true + }, + { + name: 'fragsize', + type: 'integer', + token: 'LEN', + optional: true + }, + { + name: 'separator', + type: 'string', + token: 'SEPARATOR', + optional: true + } + ] + }, + { + name: 'highlight', + type: 'block', + optional: true, + arguments: [ + { + name: 'highlight', + type: 'pure-token', + token: 'HIGHLIGHT' + }, + { + name: 'fields', + type: 'block', + optional: true, + arguments: [ + { + name: 'count', + type: 'string', + token: 'FIELDS' + }, + { + name: 'field', + type: 'string', + multiple: true + } + ] + }, + { + name: 'tags', + type: 'block', + optional: true, + arguments: [ + { + name: 'tags', + type: 'pure-token', + token: 'TAGS' + }, + { + name: 'open', + type: 'string' + }, + { + name: 'close', + type: 'string' + } + ] + } + ] + }, + { + name: 'slop', + type: 'integer', + optional: true, + token: 'SLOP' + }, + { + name: 'timeout', + type: 'integer', + optional: true, + token: 'TIMEOUT' + }, + { + name: 'inorder', + type: 'pure-token', + token: 'INORDER', + optional: true + }, + { + name: 'language', + type: 'string', + optional: true, + token: 'LANGUAGE' + }, + { + name: 'expander', + type: 'string', + optional: true, + token: 'EXPANDER' + }, + { + name: 'scorer', + type: 'string', + optional: true, + token: 'SCORER' + }, + { + name: 'explainscore', + type: 'pure-token', + token: 'EXPLAINSCORE', + optional: true + }, + { + name: 'payload', + type: 'string', + optional: true, + token: 'PAYLOAD' + }, + { + name: 'sortby', + type: 'block', + optional: true, + arguments: [ + { + name: 'sortby', + type: 'string', + token: 'SORTBY' + }, + { + name: 'order', + type: 'oneof', + optional: true, + arguments: [ + { + name: 'asc', + type: 'pure-token', + token: 'ASC' + }, + { + name: 'desc', + type: 'pure-token', + token: 'DESC' + } + ] + } + ] + }, + { + name: 'limit', + type: 'block', + optional: true, + arguments: [ + { + name: 'limit', + type: 'pure-token', + token: 'LIMIT' + }, + { + name: 'offset', + type: 'integer' + }, + { + name: 'num', + type: 'integer' + } + ] + }, + { + name: 'params', + type: 'block', + optional: true, + arguments: [ + { + name: 'params', + type: 'pure-token', + token: 'PARAMS' + }, + { + name: 'nargs', + type: 'integer' + }, + { + name: 'values', + type: 'block', + multiple: true, + arguments: [ + { + name: 'name', + type: 'string' + }, + { + name: 'value', + type: 'string' + } + ] + } + ] + }, + { + name: 'dialect', + type: 'integer', + optional: true, + token: 'DIALECT', + since: '2.4.3' + } + ], + since: '1.0.0', + group: 'search' + }, + 'FT.AGGREGATE': { + summary: 'Run a search query on an index and perform aggregate transformations on the results', + complexity: 'O(1)', + arguments: [ + { + name: 'index', + type: 'string' + }, + { + name: 'query', + type: 'string' + }, + { + name: 'verbatim', + type: 'pure-token', + token: 'VERBATIM', + optional: true + }, + { + name: 'load', + type: 'block', + optional: true, + arguments: [ + { + name: 'count', + type: 'string', + token: 'LOAD' + }, + { + name: 'field', + type: 'string', + multiple: true + } + ] + }, + { + name: 'timeout', + type: 'integer', + optional: true, + token: 'TIMEOUT' + }, + { + name: 'loadall', + type: 'pure-token', + token: 'LOAD *', + optional: true + }, + { + name: 'groupby', + type: 'block', + optional: true, + multiple: true, + arguments: [ + { + name: 'nargs', + type: 'integer', + token: 'GROUPBY' + }, + { + name: 'property', + type: 'string', + multiple: true + }, + { + name: 'reduce', + type: 'block', + optional: true, + multiple: true, + arguments: [ + { + name: 'function', + type: 'string', + token: 'REDUCE' + }, + { + name: 'nargs', + type: 'integer' + }, + { + name: 'arg', + type: 'string', + multiple: true + }, + { + name: 'name', + type: 'string', + token: 'AS', + optional: true + } + ] + } + ] + }, + { + name: 'sortby', + type: 'block', + optional: true, + arguments: [ + { + name: 'nargs', + type: 'integer', + token: 'SORTBY' + }, + { + name: 'fields', + type: 'block', + optional: true, + multiple: true, + arguments: [ + { + name: 'property', + type: 'string' + }, + { + name: 'order', + type: 'oneof', + arguments: [ + { + name: 'asc', + type: 'pure-token', + token: 'ASC' + }, + { + name: 'desc', + type: 'pure-token', + token: 'DESC' + } + ] + } + ] + }, + { + name: 'num', + type: 'integer', + token: 'MAX', + optional: true + } + ] + }, + { + name: 'apply', + type: 'block', + optional: true, + multiple: true, + arguments: [ + { + name: 'expression', + type: 'string', + token: 'APPLY' + }, + { + name: 'name', + type: 'string', + token: 'AS' + } + ] + }, + { + name: 'limit', + type: 'block', + optional: true, + arguments: [ + { + name: 'limit', + type: 'pure-token', + token: 'LIMIT' + }, + { + name: 'offset', + type: 'integer' + }, + { + name: 'num', + type: 'integer' + } + ] + }, + { + name: 'filter', + type: 'string', + optional: true, + token: 'FILTER' + }, + { + name: 'cursor', + type: 'block', + optional: true, + arguments: [ + { + name: 'withcursor', + type: 'pure-token', + token: 'WITHCURSOR' + }, + { + name: 'read_size', + type: 'integer', + optional: true, + token: 'COUNT' + }, + { + name: 'idle_time', + type: 'integer', + optional: true, + token: 'MAXIDLE' + } + ] + }, + { + name: 'params', + type: 'block', + optional: true, + arguments: [ + { + name: 'params', + type: 'pure-token', + token: 'PARAMS' + }, + { + name: 'nargs', + type: 'integer' + }, + { + name: 'values', + type: 'block', + multiple: true, + arguments: [ + { + name: 'name', + type: 'string' + }, + { + name: 'value', + type: 'string' + } + ] + } + ] + }, + { + name: 'dialect', + type: 'integer', + optional: true, + token: 'DIALECT', + since: '2.4.3' + } + ], + since: '1.1.0', + group: 'search' + }, + 'FT.PROFILE': { + summary: 'Performs a `FT.SEARCH` or `FT.AGGREGATE` command and collects performance information', + complexity: 'O(N)', + arguments: [ + { + name: 'index', + type: 'string' + }, + { + name: 'querytype', + type: 'oneof', + arguments: [ + { + name: 'search', + type: 'pure-token', + token: 'SEARCH' + }, + { + name: 'aggregate', + type: 'pure-token', + token: 'AGGREGATE' + } + ] + }, + { + name: 'limited', + type: 'pure-token', + token: 'LIMITED', + optional: true + }, + { + name: 'queryword', + type: 'pure-token', + token: 'QUERY' + }, + { + name: 'query', + type: 'string' + } + ], + since: '2.2.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.ALIASADD': { + summary: 'Adds an alias to the index', + complexity: 'O(1)', + arguments: [ + { + name: 'alias', + type: 'string' + }, + { + name: 'index', + type: 'string' + } + ], + since: '1.0.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.ALIASDEL': { + summary: 'Deletes an alias from the index', + complexity: 'O(1)', + arguments: [ + { + name: 'alias', + type: 'string' + } + ], + since: '1.0.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.ALIASUPDATE': { + summary: 'Adds or updates an alias to the index', + complexity: 'O(1)', + arguments: [ + { + name: 'alias', + type: 'string' + }, + { + name: 'index', + type: 'string' + } + ], + since: '1.0.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.ALTER': { + summary: 'Adds a new field to the index', + complexity: 'O(N) where N is the number of keys in the keyspace', + arguments: [ + { + name: 'index', + type: 'string' + }, + { + name: 'skipinitialscan', + type: 'pure-token', + token: 'SKIPINITIALSCAN', + optional: true + }, + { + name: 'schema', + type: 'pure-token', + token: 'SCHEMA' + }, + { + name: 'add', + type: 'pure-token', + token: 'ADD' + }, + { + name: 'field', + type: 'string' + }, + { + name: 'options', + type: 'string' + } + ], + since: '1.0.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.CONFIG GET': { + summary: 'Retrieves runtime configuration options', + complexity: 'O(1)', + arguments: [ + { + name: 'option', + type: 'string' + } + ], + since: '1.0.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.CONFIG HELP': { + summary: 'Help description of runtime configuration options', + complexity: 'O(1)', + arguments: [ + { + name: 'option', + type: 'string' + } + ], + since: '1.0.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.CONFIG SET': { + summary: 'Sets runtime configuration options', + complexity: 'O(1)', + arguments: [ + { + name: 'option', + type: 'string' + }, + { + name: 'value', + type: 'string' + } + ], + since: '1.0.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.CREATE': { + summary: 'Creates an index with the given spec', + complexity: 'O(K) at creation where K is the number of fields, O(N) if scanning the keyspace is triggered, where N is the number of keys in the keyspace', + history: [ + [ + '2.0.0', + 'Added `PAYLOAD_FIELD` argument for backward support of `FT.SEARCH` deprecated `WITHPAYLOADS` argument' + ], + [ + '2.0.0', + 'Deprecated `PAYLOAD_FIELD` argument' + ] + ], + arguments: [ + { + name: 'index', + type: 'string' + }, + { + name: 'data_type', + token: 'ON', + type: 'oneof', + arguments: [ + { + name: 'hash', + type: 'pure-token', + token: 'HASH' + }, + { + name: 'json', + type: 'pure-token', + token: 'JSON' + } + ], + optional: true + }, + { + name: 'prefix', + type: 'block', + optional: true, + arguments: [ + { + name: 'count', + type: 'integer', + token: 'PREFIX' + }, + { + name: 'prefix', + type: 'string', + multiple: true + } + ] + }, + { + name: 'filter', + type: 'string', + optional: true, + token: 'FILTER' + }, + { + name: 'default_lang', + type: 'string', + token: 'LANGUAGE', + optional: true + }, + { + name: 'lang_attribute', + type: 'string', + token: 'LANGUAGE_FIELD', + optional: true + }, + { + name: 'default_score', + type: 'double', + token: 'SCORE', + optional: true + }, + { + name: 'score_attribute', + type: 'string', + token: 'SCORE_FIELD', + optional: true + }, + { + name: 'payload_attribute', + type: 'string', + token: 'PAYLOAD_FIELD', + optional: true + }, + { + name: 'maxtextfields', + type: 'pure-token', + token: 'MAXTEXTFIELDS', + optional: true + }, + { + name: 'seconds', + type: 'double', + token: 'TEMPORARY', + optional: true + }, + { + name: 'nooffsets', + type: 'pure-token', + token: 'NOOFFSETS', + optional: true + }, + { + name: 'nohl', + type: 'pure-token', + token: 'NOHL', + optional: true + }, + { + name: 'nofields', + type: 'pure-token', + token: 'NOFIELDS', + optional: true + }, + { + name: 'nofreqs', + type: 'pure-token', + token: 'NOFREQS', + optional: true + }, + { + name: 'stopwords', + type: 'block', + optional: true, + token: 'STOPWORDS', + arguments: [ + { + name: 'count', + type: 'integer' + }, + { + name: 'stopword', + type: 'string', + multiple: true, + optional: true + } + ] + }, + { + name: 'skipinitialscan', + type: 'pure-token', + token: 'SKIPINITIALSCAN', + optional: true + }, + { + name: 'schema', + type: 'pure-token', + token: 'SCHEMA' + }, + { + name: 'field', + type: 'block', + multiple: true, + arguments: [ + { + name: 'field_name', + type: 'string' + }, + { + name: 'alias', + type: 'string', + token: 'AS', + optional: true + }, + { + name: 'field_type', + type: 'oneof', + arguments: [ + { + name: 'text', + type: 'pure-token', + token: 'TEXT' + }, + { + name: 'tag', + type: 'pure-token', + token: 'TAG' + }, + { + name: 'numeric', + type: 'pure-token', + token: 'NUMERIC' + }, + { + name: 'geo', + type: 'pure-token', + token: 'GEO' + }, + { + name: 'vector', + type: 'pure-token', + token: 'VECTOR' + } + ] + }, + { + name: 'withsuffixtrie', + type: 'pure-token', + token: 'WITHSUFFIXTRIE', + optional: true + }, + { + name: 'INDEXEMPTY', + type: 'pure-token', + token: 'INDEXEMPTY', + optional: true + }, + { + name: 'indexmissing', + type: 'pure-token', + token: 'INDEXMISSING', + optional: true + }, + { + name: 'sortable', + type: 'block', + optional: true, + arguments: [ + { + name: 'sortable', + type: 'pure-token', + token: 'SORTABLE' + }, + { + name: 'UNF', + type: 'pure-token', + token: 'UNF', + optional: true + } + ] + }, + { + name: 'noindex', + type: 'pure-token', + token: 'NOINDEX', + optional: true + } + ] + } + ], + since: '1.0.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.CURSOR DEL': { + summary: 'Deletes a cursor', + complexity: 'O(1)', + arguments: [ + { + name: 'index', + type: 'string' + }, + { + name: 'cursor_id', + type: 'integer' + } + ], + since: '1.1.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.CURSOR READ': { + summary: 'Reads from a cursor', + complexity: 'O(1)', + arguments: [ + { + name: 'index', + type: 'string' + }, + { + name: 'cursor_id', + type: 'integer' + }, + { + name: 'read size', + type: 'integer', + optional: true, + token: 'COUNT' + } + ], + since: '1.1.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.DICTADD': { + summary: 'Adds terms to a dictionary', + complexity: 'O(1)', + arguments: [ + { + name: 'dict', + type: 'string' + }, + { + name: 'term', + type: 'string', + multiple: true + } + ], + since: '1.4.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.DICTDEL': { + summary: 'Deletes terms from a dictionary', + complexity: 'O(1)', + arguments: [ + { + name: 'dict', + type: 'string' + }, + { + name: 'term', + type: 'string', + multiple: true + } + ], + since: '1.4.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.DICTDUMP': { + summary: 'Dumps all terms in the given dictionary', + complexity: 'O(N), where N is the size of the dictionary', + arguments: [ + { + name: 'dict', + type: 'string' + } + ], + since: '1.4.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.DROPINDEX': { + summary: 'Deletes the index', + complexity: 'O(1) or O(N) if documents are deleted, where N is the number of keys in the keyspace', + arguments: [ + { + name: 'index', + type: 'string' + }, + { + name: 'delete docs', + type: 'oneof', + arguments: [ + { + name: 'delete docs', + type: 'pure-token', + token: 'DD' + } + ], + optional: true + } + ], + since: '2.0.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.EXPLAIN': { + summary: 'Returns the execution plan for a complex query', + complexity: 'O(1)', + arguments: [ + { + name: 'index', + type: 'string' + }, + { + name: 'query', + type: 'string' + }, + { + name: 'dialect', + type: 'integer', + optional: true, + token: 'DIALECT', + since: '2.4.3' + } + ], + since: '1.0.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.EXPLAINCLI': { + summary: 'Returns the execution plan for a complex query', + complexity: 'O(1)', + arguments: [ + { + name: 'index', + type: 'string' + }, + { + name: 'query', + type: 'string' + }, + { + name: 'dialect', + type: 'integer', + optional: true, + token: 'DIALECT', + since: '2.4.3' + } + ], + since: '1.0.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.INFO': { + summary: 'Returns information and statistics on the index', + complexity: 'O(1)', + arguments: [ + { + name: 'index', + type: 'string' + } + ], + since: '1.0.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.SPELLCHECK': { + summary: 'Performs spelling correction on a query, returning suggestions for misspelled terms', + complexity: 'O(1)', + arguments: [ + { + name: 'index', + type: 'string' + }, + { + name: 'query', + type: 'string' + }, + { + name: 'distance', + token: 'DISTANCE', + type: 'integer', + optional: true + }, + { + name: 'terms', + token: 'TERMS', + type: 'block', + optional: true, + arguments: [ + { + name: 'inclusion', + type: 'oneof', + arguments: [ + { + name: 'include', + type: 'pure-token', + token: 'INCLUDE' + }, + { + name: 'exclude', + type: 'pure-token', + token: 'EXCLUDE' + } + ] + }, + { + name: 'dictionary', + type: 'string' + }, + { + name: 'terms', + type: 'string', + multiple: true, + optional: true + } + ] + }, + { + name: 'dialect', + type: 'integer', + optional: true, + token: 'DIALECT', + since: '2.4.3' + } + ], + since: '1.4.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.SUGADD': { + summary: 'Adds a suggestion string to an auto-complete suggestion dictionary', + complexity: 'O(1)', + history: [ + [ + '2.0.0', + 'Deprecated `PAYLOAD` argument' + ] + ], + arguments: [ + { + name: 'key', + type: 'string' + }, + { + name: 'string', + type: 'string' + }, + { + name: 'score', + type: 'double' + }, + { + name: 'increment score', + type: 'oneof', + arguments: [ + { + name: 'incr', + type: 'pure-token', + token: 'INCR' + } + ], + optional: true + }, + { + name: 'payload', + token: 'PAYLOAD', + type: 'string', + optional: true + } + ], + since: '1.0.0', + group: 'suggestion', + provider: 'redisearch' + }, + 'FT.SUGDEL': { + summary: 'Deletes a string from a suggestion index', + complexity: 'O(1)', + arguments: [ + { + name: 'key', + type: 'string' + }, + { + name: 'string', + type: 'string' + } + ], + since: '1.0.0', + group: 'suggestion', + provider: 'redisearch' + }, + 'FT.SUGGET': { + summary: 'Gets completion suggestions for a prefix', + complexity: 'O(1)', + history: [ + [ + '2.0.0', + 'Deprecated `WITHPAYLOADS` argument' + ] + ], + arguments: [ + { + name: 'key', + type: 'string' + }, + { + name: 'prefix', + type: 'string' + }, + { + name: 'fuzzy', + type: 'pure-token', + token: 'FUZZY', + optional: true + }, + { + name: 'withscores', + type: 'pure-token', + token: 'WITHSCORES', + optional: true + }, + { + name: 'withpayloads', + type: 'pure-token', + token: 'WITHPAYLOADS', + optional: true + }, + { + name: 'max', + token: 'MAX', + type: 'integer', + optional: true + } + ], + since: '1.0.0', + group: 'suggestion', + provider: 'redisearch' + }, + 'FT.SUGLEN': { + summary: 'Gets the size of an auto-complete suggestion dictionary', + complexity: 'O(1)', + arguments: [ + { + name: 'key', + type: 'string' + } + ], + since: '1.0.0', + group: 'suggestion', + provider: 'redisearch' + }, + 'FT.SYNDUMP': { + summary: 'Dumps the contents of a synonym group', + complexity: 'O(1)', + arguments: [ + { + name: 'index', + type: 'string' + } + ], + since: '1.2.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.SYNUPDATE': { + summary: 'Creates or updates a synonym group with additional terms', + complexity: 'O(1)', + arguments: [ + { + name: 'index', + type: 'string' + }, + { + name: 'synonym_group_id', + type: 'string' + }, + { + name: 'skipinitialscan', + type: 'pure-token', + token: 'SKIPINITIALSCAN', + optional: true + }, + { + name: 'term', + type: 'string', + multiple: true + } + ], + since: '1.2.0', + group: 'search', + provider: 'redisearch' + }, + 'FT.TAGVALS': { + summary: 'Returns the distinct tags indexed in a Tag field', + complexity: 'O(N)', + arguments: [ + { + name: 'index', + type: 'string' + }, + { + name: 'field_name', + type: 'string' + } + ], + since: '1.0.0', + group: 'search', + provider: 'redisearch' + }, + 'FT._LIST': { + summary: 'Returns a list of all existing indexes', + complexity: 'O(1)', + since: '2.0.0', + group: 'search', + provider: 'redisearch' + }, +} diff --git a/redisinsight/ui/src/pages/browser/BrowserPage.spec.tsx b/redisinsight/ui/src/pages/browser/BrowserPage.spec.tsx index 9279901966..079291de0e 100644 --- a/redisinsight/ui/src/pages/browser/BrowserPage.spec.tsx +++ b/redisinsight/ui/src/pages/browser/BrowserPage.spec.tsx @@ -9,7 +9,6 @@ import { setBrowserBulkActionOpen, setBrowserPanelSizes, setBrowserSelectedKey, - setLastPageContext } from 'uiSrc/slices/app/context' import BrowserPage from './BrowserPage' import KeyList, { Props as KeyListProps } from './components/key-list/KeyList' @@ -161,7 +160,6 @@ describe('BrowserPage', () => { setBrowserPanelSizes(expect.any(Object)), setBrowserBulkActionOpen(expect.any(Boolean)), setBrowserSelectedKey(null), - setLastPageContext('browser'), toggleBrowserFullScreen(false) ] diff --git a/redisinsight/ui/src/pages/browser/BrowserPage.tsx b/redisinsight/ui/src/pages/browser/BrowserPage.tsx index 41e0322071..c78a4e9ced 100644 --- a/redisinsight/ui/src/pages/browser/BrowserPage.tsx +++ b/redisinsight/ui/src/pages/browser/BrowserPage.tsx @@ -31,7 +31,6 @@ import { setBrowserSelectedKey, appContextBrowser, setBrowserPanelSizes, - setLastPageContext, setBrowserBulkActionOpen, appContextSelector, } from 'uiSrc/slices/app/context' @@ -110,7 +109,6 @@ const BrowserPage = () => { }) dispatch(setBrowserBulkActionOpen(isBulkActionsPanelOpenRef.current)) dispatch(setBrowserSelectedKey(selectedKeyRef.current)) - dispatch(setLastPageContext('browser')) if (!selectedKeyRef.current) { dispatch(toggleBrowserFullScreen(false)) diff --git a/redisinsight/ui/src/pages/browser/components/no-keys-found/NoKeysFound.tsx b/redisinsight/ui/src/pages/browser/components/no-keys-found/NoKeysFound.tsx index d783dadacf..6ef178f384 100644 --- a/redisinsight/ui/src/pages/browser/components/no-keys-found/NoKeysFound.tsx +++ b/redisinsight/ui/src/pages/browser/components/no-keys-found/NoKeysFound.tsx @@ -15,8 +15,10 @@ import { SidePanels } from 'uiSrc/slices/interfaces/insights' import { KeyViewType, SearchMode } from 'uiSrc/slices/interfaces/keys' import { changeKeyViewType, fetchKeys, keysSelector } from 'uiSrc/slices/browser/keys' import { SCAN_TREE_COUNT_DEFAULT } from 'uiSrc/constants/api' -import { SAMPLE_DATA_TUTORIAL } from 'uiSrc/constants' +import { TutorialsIds } from 'uiSrc/constants' + import LoadSampleData from '../load-sample-data' + import styles from './styles.module.scss' export interface Props { @@ -33,7 +35,7 @@ const NoKeysFound = (props: Props) => { const onSuccessLoadData = () => { if (openedPanel !== SidePanels.AiAssistant) { - const tutorialPath = findTutorialPath({ id: SAMPLE_DATA_TUTORIAL }) + const tutorialPath = findTutorialPath({ id: TutorialsIds.RedisUseCases }) dispatch(openTutorialByPath(tutorialPath, history, true)) } diff --git a/redisinsight/ui/src/pages/browser/styles.module.scss b/redisinsight/ui/src/pages/browser/styles.module.scss index 78257fd4fb..99b9613820 100644 --- a/redisinsight/ui/src/pages/browser/styles.module.scss +++ b/redisinsight/ui/src/pages/browser/styles.module.scss @@ -1,29 +1,18 @@ $breakpoint-to-hide-resize-panel: 1280px; .container { - height: 100%; max-width: 100vw; display: flex; flex-direction: column; + flex-grow: 1; + overflow: hidden; } .main { display: flex; - flex: 1; + flex-grow: 1; padding: 0 16px; - height: calc(100% - 280px); - - &.mainWithBackBtn { - height: calc(100% - 338px); - } - - @media only screen and (min-width: 768px) { - max-width: calc(100vw - 60px); - - &.mainWithBackBtn { - height: calc(100% - 118px); - } - } + overflow: hidden; } .resizableButton { diff --git a/redisinsight/ui/src/pages/home/components/capability-promotion/CapabilityPromotion.tsx b/redisinsight/ui/src/pages/home/components/capability-promotion/CapabilityPromotion.tsx index 4d2105159b..4c1a750520 100644 --- a/redisinsight/ui/src/pages/home/components/capability-promotion/CapabilityPromotion.tsx +++ b/redisinsight/ui/src/pages/home/components/capability-promotion/CapabilityPromotion.tsx @@ -15,6 +15,7 @@ import { guideLinksSelector } from 'uiSrc/slices/content/guide-links' import GUIDE_ICONS from 'uiSrc/components/explore-guides/icons' import { findTutorialPath } from 'uiSrc/utils' import { InsightsPanelTabs, SidePanels } from 'uiSrc/slices/interfaces/insights' +import { TutorialsIds } from 'uiSrc/constants' import styles from './styles.module.scss' export interface Props { @@ -23,7 +24,7 @@ export interface Props { capabilityIds?: string[] } -const displayedCapabilityIds = ['sq-intro', 'ds-json-intro'] +const displayedCapabilityIds = [TutorialsIds.IntroToSearch, TutorialsIds.IntroToJSON] const CapabilityPromotion = (props: Props) => { const { mode = 'wide', wrapperClassName, capabilityIds = displayedCapabilityIds } = props diff --git a/redisinsight/ui/src/pages/home/components/database-alias/DatabaseAlias.tsx b/redisinsight/ui/src/pages/home/components/database-alias/DatabaseAlias.tsx index 24b2a8a3aa..5018d2061e 100644 --- a/redisinsight/ui/src/pages/home/components/database-alias/DatabaseAlias.tsx +++ b/redisinsight/ui/src/pages/home/components/database-alias/DatabaseAlias.tsx @@ -18,7 +18,7 @@ import { useHistory } from 'react-router' import { BuildType } from 'uiSrc/constants/env' import { appInfoSelector } from 'uiSrc/slices/app/info' import { Nullable, getDbIndex } from 'uiSrc/utils' -import { PageNames, Pages, Theme } from 'uiSrc/constants' +import { Pages, Theme } from 'uiSrc/constants' import { ThemeContext } from 'uiSrc/contexts/themeContext' import InlineItemEditor from 'uiSrc/components/inline-item-editor/InlineItemEditor' import RediStackDarkMin from 'uiSrc/assets/img/modules/redistack/RediStackDark-min.svg' @@ -65,7 +65,7 @@ const DatabaseAlias = (props: Props) => { } = props const { server } = useSelector(appInfoSelector) - const { contextInstanceId, lastPage } = useSelector(appContextSelector) + const { contextInstanceId } = useSelector(appContextSelector) const [isEditing, setIsEditing] = useState(false) const [value, setValue] = useState(alias) @@ -95,11 +95,6 @@ const DatabaseAlias = (props: Props) => { dispatch(setAppContextInitialState()) } dispatch(setConnectedInstanceId(id ?? '')) - - if (lastPage === PageNames.workbench && contextInstanceId === id) { - history.push(Pages.workbench(id)) - return - } history.push(Pages.browser(id ?? '')) } diff --git a/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx b/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx index 1bb06f5718..25088a0c8f 100644 --- a/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx @@ -25,7 +25,7 @@ import CloudLinkIcon from 'uiSrc/assets/img/oauth/cloud_link.svg?react' import { ShowChildByCondition } from 'uiSrc/components' import DatabaseListModules from 'uiSrc/components/database-list-modules/DatabaseListModules' import ItemList from 'uiSrc/components/item-list' -import { BrowserStorageItem, PageNames, Pages, Theme } from 'uiSrc/constants' +import { BrowserStorageItem, Pages, Theme } from 'uiSrc/constants' import { EXTERNAL_LINKS } from 'uiSrc/constants/links' import { ThemeContext } from 'uiSrc/contexts/themeContext' import PopoverDelete from 'uiSrc/pages/browser/components/popover-delete/PopoverDelete' @@ -63,7 +63,7 @@ const DatabasesListWrapper = ({ width, onEditInstance, editedInstance, onDeleteI const { search } = useLocation() const { theme } = useContext(ThemeContext) - const { contextInstanceId, lastPage } = useSelector(appContextSelector) + const { contextInstanceId } = useSelector(appContextSelector) const instances = useSelector(instancesSelector) const [, forceRerender] = useState({}) @@ -125,10 +125,6 @@ const DatabasesListWrapper = ({ width, onEditInstance, editedInstance, onDeleteI } dispatch(setConnectedInstanceId(id)) - if (lastPage === PageNames.workbench && contextInstanceId === id) { - history.push(Pages.workbench(id)) - return - } history.push(Pages.browser(id)) } const handleCheckConnectToInstance = ( diff --git a/redisinsight/ui/src/pages/instance/InstancePage.spec.tsx b/redisinsight/ui/src/pages/instance/InstancePage.spec.tsx index bf183f94a8..e8f2b7fb53 100644 --- a/redisinsight/ui/src/pages/instance/InstancePage.spec.tsx +++ b/redisinsight/ui/src/pages/instance/InstancePage.spec.tsx @@ -28,6 +28,7 @@ import { } from 'uiSrc/slices/instances/instances' import { resetConnectedInstance as resetRdiConnectedInstance } from 'uiSrc/slices/rdi/instances' import { clearExpertChatHistory } from 'uiSrc/slices/panels/aiAssistant' +import { getAllPlugins } from 'uiSrc/slices/app/plugins' import InstancePage, { Props } from './InstancePage' const INSTANCE_ID_MOCK = 'instanceId' @@ -119,6 +120,7 @@ describe('InstancePage', () => { ] const expectedActions = [ + getAllPlugins(), setDefaultInstance(), setConnectedInstance(), getDatabaseConfigInfo(), diff --git a/redisinsight/ui/src/pages/instance/InstancePage.tsx b/redisinsight/ui/src/pages/instance/InstancePage.tsx index 220d238588..7f71ad7b49 100644 --- a/redisinsight/ui/src/pages/instance/InstancePage.tsx +++ b/redisinsight/ui/src/pages/instance/InstancePage.tsx @@ -23,6 +23,7 @@ import { localStorageService } from 'uiSrc/services' import { InstancePageTemplate } from 'uiSrc/templates' import { getPageName } from 'uiSrc/utils/routing' import { resetConnectedInstance as resetRdiConnectedInstance } from 'uiSrc/slices/rdi/instances' +import { loadPluginsAction } from 'uiSrc/slices/app/plugins' import InstancePageRouter from './InstancePageRouter' export interface Props { @@ -41,6 +42,10 @@ const InstancePage = ({ routes = [] }: Props) => { const lastPageRef = useRef() + useEffect(() => { + dispatch(loadPluginsAction()) + }, []) + useEffect(() => { dispatch(fetchConnectedInstanceAction(connectionInstanceId, () => { !modulesData.length && dispatch(fetchInstancesAction()) diff --git a/redisinsight/ui/src/pages/workbench/WorkbenchPage.tsx b/redisinsight/ui/src/pages/workbench/WorkbenchPage.tsx index 89609f6ab3..2e92e9c76b 100644 --- a/redisinsight/ui/src/pages/workbench/WorkbenchPage.tsx +++ b/redisinsight/ui/src/pages/workbench/WorkbenchPage.tsx @@ -1,12 +1,9 @@ import React, { useEffect, useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' +import { useSelector } from 'react-redux' import { useParams } from 'react-router-dom' import { formatLongName, getDbIndex, setTitle } from 'uiSrc/utils' -import { PageNames } from 'uiSrc/constants' import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' -import { setLastPageContext } from 'uiSrc/slices/app/context' -import { loadPluginsAction } from 'uiSrc/slices/app/plugins' import { sendPageViewTelemetry, TelemetryPageView } from 'uiSrc/telemetry' import WBViewWrapper from './components/wb-view' @@ -17,7 +14,6 @@ const WorkbenchPage = () => { const { instanceId } = useParams<{ instanceId: string }>() - const dispatch = useDispatch() setTitle(`${formatLongName(connectedInstanceName, 33, 0, '...')} ${getDbIndex(db)} - Workbench`) useEffect(() => { @@ -36,14 +32,6 @@ const WorkbenchPage = () => { setIsPageViewSent(true) } - useEffect(() => { - dispatch(loadPluginsAction()) - }, []) - - useEffect(() => () => { - dispatch(setLastPageContext(PageNames.workbench)) - }) - return () } diff --git a/redisinsight/ui/src/components/query/Query/Query.spec.tsx b/redisinsight/ui/src/pages/workbench/components/query/Query/Query.spec.tsx similarity index 100% rename from redisinsight/ui/src/components/query/Query/Query.spec.tsx rename to redisinsight/ui/src/pages/workbench/components/query/Query/Query.spec.tsx diff --git a/redisinsight/ui/src/components/query/Query/Query.tsx b/redisinsight/ui/src/pages/workbench/components/query/Query/Query.tsx similarity index 63% rename from redisinsight/ui/src/components/query/Query/Query.tsx rename to redisinsight/ui/src/pages/workbench/components/query/Query/Query.tsx index b59b5ec0e4..803549fa50 100644 --- a/redisinsight/ui/src/components/query/Query/Query.tsx +++ b/redisinsight/ui/src/pages/workbench/components/query/Query/Query.tsx @@ -1,65 +1,65 @@ -import React, { useContext, useEffect, useRef, useState } from 'react' +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { compact, first } from 'lodash' import cx from 'classnames' -import { EuiButtonIcon, EuiButton, EuiIcon, EuiLoadingSpinner, EuiText, EuiToolTip } from '@elastic/eui' import MonacoEditor, { monaco as monacoEditor } from 'react-monaco-editor' import { useParams } from 'react-router-dom' -import { - Theme, - MonacoLanguage, - KEYBOARD_SHORTCUTS, - DSLNaming, -} from 'uiSrc/constants' +import { DSLNaming, ICommandTokenType, IRedisCommand, MonacoLanguage, Theme, } from 'uiSrc/constants' import { actionTriggerParameterHints, createSyntaxWidget, decoration, - findArgIndexByCursor, findCompleteQuery, getMonacoAction, - getRedisCompletionProvider, - getRedisSignatureHelpProvider, - isGroupMode, + IMonacoQuery, isParamsLine, MonacoAction, Nullable, toModelDeltaDecoration } from 'uiSrc/utils' -import { KeyboardShortcut } from 'uiSrc/components' import { ThemeContext } from 'uiSrc/contexts/themeContext' import { appRedisCommandsSelector } from 'uiSrc/slices/app/redis-commands' import { IEditorMount, ISnippetController } from 'uiSrc/pages/workbench/interfaces' -import { CommandExecutionUI } from 'uiSrc/slices/interfaces' -import { RunQueryMode, ResultsMode } from 'uiSrc/slices/interfaces/workbench' +import { CommandExecutionUI, RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { ResultsMode, RunQueryMode } from 'uiSrc/slices/interfaces/workbench' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { stopProcessing, workbenchResultsSelector } from 'uiSrc/slices/workbench/wb-results' import DedicatedEditor from 'uiSrc/components/monaco-editor/components/dedicated-editor' -import RawModeIcon from 'uiSrc/assets/img/icons/raw_mode.svg?react' -import GroupModeIcon from 'uiSrc/assets/img/icons/group_mode.svg?react' - +import { QueryActions, QueryTutorials } from 'uiSrc/components/query' + +import { addOwnTokenToArgs, findCurrentArgument, } from 'uiSrc/pages/workbench/utils/query' +import { getRange, getRediSearchSignutureProvider, } from 'uiSrc/pages/workbench/utils/monaco' +import { CursorContext } from 'uiSrc/pages/workbench/types' +import { asSuggestionsRef, getCommandsSuggestions, isIndexComplete } from 'uiSrc/pages/workbench/utils/suggestions' +import { COMMANDS_TO_GET_INDEX_INFO, COMPOSITE_ARGS, EmptySuggestionsIds, } from 'uiSrc/pages/workbench/constants' +import { useDebouncedEffect } from 'uiSrc/services' +import { fetchRedisearchInfoAction } from 'uiSrc/slices/browser/redisearch' +import { findSuggestionsByArg } from 'uiSrc/pages/workbench/utils/searchSuggestions' +import { + argInQuotesRegExp, + aroundQuotesRegExp, + options, + SYNTAX_CONTEXT_ID, + SYNTAX_WIDGET_ID, + TUTORIALS +} from './constants' import styles from './styles.module.scss' export interface Props { query: string + commands: IRedisCommand[] + indexes: RedisResponseBuffer[] activeMode: RunQueryMode resultsMode?: ResultsMode setQueryEl: Function setQuery: (script: string) => void - setIsCodeBtnDisabled: (value: boolean) => void onSubmit: (query?: string) => void onKeyDown?: (e: React.KeyboardEvent, script: string) => void onQueryChangeMode: () => void onChangeGroupMode: () => void } -const SYNTAX_CONTEXT_ID = 'syntaxWidgetContext' -const SYNTAX_WIDGET_ID = 'syntax.content.widget' - -const argInQuotesRegExp = /^['"](.|[\r\n])*['"]$/ -const aroundQuotesRegExp = /(^["']|["']$)/g - let execHistoryPos: number = 0 let execHistory: CommandExecutionUI[] = [] let decorationCollection: Nullable = null @@ -67,31 +67,55 @@ let decorationCollection: Nullable { const { query = '', + commands = [], + indexes = [], activeMode, resultsMode, setQuery = () => {}, onKeyDown = () => {}, onSubmit = () => {}, setQueryEl = () => {}, - setIsCodeBtnDisabled = () => {}, onQueryChangeMode = () => {}, onChangeGroupMode = () => {} } = props let contribution: Nullable = null const [isDedicatedEditorOpen, setIsDedicatedEditorOpen] = useState(false) + const [selectedIndex, setSelectedIndex] = useState('') + + const suggestionsRef = useRef([]) + const helpWidgetRef = useRef({ + isOpen: false, + data: {} + }) + const indexesRef = useRef([]) + const attributesRef = useRef([]) + const isWidgetOpen = useRef(false) const input = useRef(null) const isWidgetEscaped = useRef(false) const selectedArg = useRef('') const syntaxCommand = useRef(null) const isDedicatedEditorOpenRef = useRef(isDedicatedEditorOpen) + const isEscapedSuggestions = useRef(false) let syntaxWidgetContext: Nullable> = null const { commandsArray: REDIS_COMMANDS_ARRAY, spec: REDIS_COMMANDS_SPEC } = useSelector(appRedisCommandsSelector) const { items: execHistoryItems, loading, processing } = useSelector(workbenchResultsSelector) const { theme } = useContext(ThemeContext) const monacoObjects = useRef>(null) - const runTooltipRef = useRef(null) + + // TODO: need refactor to avoid this + const REDIS_COMMANDS = useMemo( + () => commands.map((command) => ({ ...addOwnTokenToArgs(command.name!, command) })), + [commands] + ) + + const compositeTokens = useMemo(() => + commands + .filter((command) => command.token && command.token.includes(' ')) + .map(({ token }) => token) + .concat(...COMPOSITE_ARGS), + [commands]) const { instanceId = '' } = useParams<{ instanceId: string }>() @@ -109,6 +133,10 @@ const Query = (props: Props) => { disposeSignatureHelpProvider() }, []) + useEffect(() => { + indexesRef.current = indexes + }, [indexes]) + useEffect(() => { // HACK: The Monaco editor memoize the state and ignores updates to it execHistory = execHistoryItems @@ -143,32 +171,68 @@ const Query = (props: Props) => { }, [query]) useEffect(() => { - setIsCodeBtnDisabled(isDedicatedEditorOpen) isDedicatedEditorOpenRef.current = isDedicatedEditorOpen }, [isDedicatedEditorOpen]) - const triggerUpdateCursorPosition = (editor: monacoEditor.editor.IStandaloneCodeEditor) => { - const position = editor.getPosition() - isDedicatedEditorOpenRef.current = false - editor.trigger('mouse', '_moveTo', { position: { lineNumber: 1, column: 1 } }) - editor.trigger('mouse', '_moveTo', { position }) + useDebouncedEffect(() => { + attributesRef.current = [] + if (!isIndexComplete(selectedIndex)) return + + const index = selectedIndex.replace(/^(['"])(.*)\1$/, '$2') + dispatch(fetchRedisearchInfoAction(index, + (data: any) => { + attributesRef.current = data?.attributes || [] + })) + }, 200, [selectedIndex]) + + const editorDidMount = ( + editor: monacoEditor.editor.IStandaloneCodeEditor, + monaco: typeof monacoEditor + ) => { + monacoObjects.current = { editor, monaco } + + // hack for exit from snippet mode after click Enter until no answer from monaco authors + // https://github.com/microsoft/monaco-editor/issues/2756 + contribution = editor.getContribution('snippetController2') + + syntaxWidgetContext = editor.createContextKey(SYNTAX_CONTEXT_ID, false) editor.focus() - } + setQueryEl(editor) - const onPressWidget = () => { - if (!monacoObjects.current) return - const { editor } = monacoObjects?.current + editor.onKeyDown(onKeyDownMonaco) + editor.onDidChangeCursorPosition(onKeyChangeCursorMonaco) - setIsDedicatedEditorOpen(true) - editor.updateOptions({ readOnly: true }) - hideSyntaxWidget(editor) - sendEventTelemetry({ - event: TelemetryEvent.WORKBENCH_NON_REDIS_EDITOR_OPENED, - eventData: { - databaseId: instanceId, - lang: syntaxCommand.current.lang, + setupMonacoRedisLang(monaco) + editor.addAction( + getMonacoAction(MonacoAction.Submit, (editor) => handleSubmit(editor.getValue()), monaco) + ) + + editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Space, () => { + onPressWidget() + }, SYNTAX_CONTEXT_ID) + + editor.onMouseDown((e: monacoEditor.editor.IEditorMouseEvent) => { + if ((e.target as monacoEditor.editor.IMouseTargetContentWidget)?.detail === SYNTAX_WIDGET_ID) { + onPressWidget() } }) + + editor.addCommand(monaco.KeyCode.Escape, () => { + hideSyntaxWidget(editor) + isWidgetEscaped.current = true + }, SYNTAX_CONTEXT_ID) + + decorationCollection = editor.createDecorationsCollection() + + const suggestionWidget = editor.getContribution('editor.contrib.suggestController') + suggestionWidget?.onWillInsertSuggestItem(({ item }: Record<'item', any>) => { + if (item.completion.id === EmptySuggestionsIds.NoIndexes) { + helpWidgetRef.current.isOpen = true + editor.trigger('', 'hideSuggestWidget', null) + editor.trigger('', 'editor.action.triggerParameterHints', '') + } + }) + suggestionsRef.current = getSuggestions(editor).data } const onChange = (value: string = '') => { @@ -180,15 +244,6 @@ const Query = (props: Props) => { } } - const handleKeyDown = (e: React.KeyboardEvent) => { - onKeyDown?.(e, query) - } - - const handleSubmit = (value?: string) => { - execHistoryPos = 0 - onSubmit(value) - } - const onTriggerParameterHints = () => { if (!monacoObjects.current) return @@ -255,6 +310,21 @@ const Query = (props: Props) => { if (e.keyCode === monacoEditor.KeyCode.Enter || e.keyCode === monacoEditor.KeyCode.Space) { onExitSnippetMode() } + + if (e.keyCode === monacoEditor.KeyCode.Escape && isSuggestionsOpened()) { + isEscapedSuggestions.current = true + } + } + + const onExitSnippetMode = () => { + if (!monacoObjects.current) return + const { editor } = monacoObjects?.current + + if (contribution?.isInSnippet?.()) { + const { lineNumber = 0, column = 0 } = editor?.getPosition() ?? {} + editor.setSelection(new monacoEditor.Selection(lineNumber, column, lineNumber, column)) + contribution?.cancel?.() + } } const onKeyChangeCursorMonaco = (e: monacoEditor.editor.ICursorPositionChangedEvent) => { @@ -268,50 +338,141 @@ const Query = (props: Props) => { return } - const command = findCompleteQuery(model, e.position, REDIS_COMMANDS_SPEC, REDIS_COMMANDS_ARRAY) - if (!command) { - isWidgetEscaped.current = false + const command = findCompleteQuery( + model, + e.position, + REDIS_COMMANDS_SPEC, + REDIS_COMMANDS_ARRAY, + compositeTokens as string[] + ) + handleSuggestions(editor, command) + handleDslSyntax(e, command) + } + + const onPressWidget = () => { + if (!monacoObjects.current) return + const { editor } = monacoObjects?.current + + setIsDedicatedEditorOpen(true) + editor.updateOptions({ readOnly: true }) + hideSyntaxWidget(editor) + sendEventTelemetry({ + event: TelemetryEvent.WORKBENCH_NON_REDIS_EDITOR_OPENED, + eventData: { + databaseId: instanceId, + lang: syntaxCommand.current.lang, + } + }) + } + + const onCancelDedicatedEditor = () => { + setIsDedicatedEditorOpen(false) + if (!monacoObjects.current) return + const { editor } = monacoObjects?.current + + editor.updateOptions({ readOnly: false }) + triggerUpdateCursorPosition(editor) + + sendEventTelemetry({ + event: TelemetryEvent.WORKBENCH_NON_REDIS_EDITOR_CANCELLED, + eventData: { + databaseId: instanceId, + lang: syntaxCommand.current.lang, + } + }) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + onKeyDown?.(e, query) + } + + const handleSubmit = (value?: string) => { + execHistoryPos = 0 + onSubmit(value) + } + + const handleSuggestions = ( + editor: monacoEditor.editor.IStandaloneCodeEditor, + command?: Nullable + ) => { + const { data, forceHide, forceShow } = getSuggestions(editor, command) + suggestionsRef.current = data + + if (!forceShow) { + editor.trigger('', 'editor.action.triggerParameterHints', '') return } - const queryArgIndex = command.info?.arguments?.findIndex((arg) => arg.dsl) || -1 - const cursorPosition = command.commandCursorPosition || 0 - if (!command.args?.length || queryArgIndex < 0) { + if (data.length) { + helpWidgetRef.current.isOpen = false + triggerSuggestions() + return + } + + editor.trigger('', 'editor.action.triggerParameterHints', '') + + if (forceHide) { + setTimeout(() => editor?.trigger('', 'hideSuggestWidget', null), 0) + } else { + helpWidgetRef.current.isOpen = !isSuggestionsOpened() && helpWidgetRef.current.isOpen + } + } + + const handleDslSyntax = ( + e: monacoEditor.editor.ICursorPositionChangedEvent, + command: Nullable + ) => { + const { editor } = monacoObjects?.current || {} + if (!command || !editor) { isWidgetEscaped.current = false return } - const argIndex = findArgIndexByCursor(command.args, command.fullQuery, cursorPosition) - if (argIndex === null) { + const isContainsDSL = command.info?.arguments?.some((arg) => arg.dsl) + if (!isContainsDSL) { isWidgetEscaped.current = false return } - const queryArg = command.args[argIndex] - const argDSL = command.info?.arguments?.[argIndex]?.dsl || '' + const [beforeOffsetArgs, [currentOffsetArg]] = command.args + const foundArg = findCurrentArgument([{ + ...command.info, + type: ICommandTokenType.Block, + token: command.name, + arguments: command.info?.arguments + }], beforeOffsetArgs) - if (queryArgIndex === argIndex && argInQuotesRegExp.test(queryArg)) { + const DSL = foundArg?.stopArg?.dsl + if (DSL && argInQuotesRegExp.test(currentOffsetArg)) { if (isWidgetEscaped.current) return - const lang = DSLNaming[argDSL] ?? null + + const lang = DSLNaming[DSL] ?? null lang && showSyntaxWidget(editor, e.position, lang) - selectedArg.current = queryArg - syntaxCommand.current = { - ...command, - lang: argDSL, - argToReplace: queryArg - } + selectedArg.current = currentOffsetArg + syntaxCommand.current = { ...command, lang: DSL, argToReplace: currentOffsetArg } + } else { + isWidgetEscaped.current = false } } - const onExitSnippetMode = () => { - if (!monacoObjects.current) return - const { editor } = monacoObjects?.current + const triggerUpdateCursorPosition = (editor: monacoEditor.editor.IStandaloneCodeEditor) => { + const position = editor.getPosition() + isDedicatedEditorOpenRef.current = false + editor.trigger('mouse', '_moveTo', { position: { lineNumber: 1, column: 1 } }) + editor.trigger('mouse', '_moveTo', { position }) + editor.focus() + } - if (contribution?.isInSnippet?.()) { - const { lineNumber = 0, column = 0 } = editor?.getPosition() ?? {} - editor.setSelection(new monacoEditor.Selection(lineNumber, column, lineNumber, column)) - contribution?.cancel?.() - } + const isSuggestionsOpened = () => { + const { editor } = monacoObjects.current || {} + if (!editor) return false + const suggestController = editor.getContribution('editor.contrib.suggestController') + return suggestController?.model?.state === 1 + } + + const triggerSuggestions = () => { + const { editor } = monacoObjects.current || {} + setTimeout(() => editor?.trigger('', 'editor.action.triggerSuggest', { auto: false })) } const hideSyntaxWidget = (editor: monacoEditor.editor.IStandaloneCodeEditor) => { @@ -330,23 +491,6 @@ const Query = (props: Props) => { syntaxWidgetContext?.set(true) } - const onCancelDedicatedEditor = () => { - setIsDedicatedEditorOpen(false) - if (!monacoObjects.current) return - const { editor } = monacoObjects?.current - - editor.updateOptions({ readOnly: false }) - triggerUpdateCursorPosition(editor) - - sendEventTelemetry({ - event: TelemetryEvent.WORKBENCH_NON_REDIS_EDITOR_CANCELLED, - eventData: { - databaseId: instanceId, - lang: syntaxCommand.current.lang, - } - }) - } - const updateArgFromDedicatedEditor = (value: string = '') => { if (!syntaxCommand.current || !monacoObjects.current) return const { editor } = monacoObjects?.current @@ -382,75 +526,68 @@ const Query = (props: Props) => { }) } - const editorDidMount = ( - editor: monacoEditor.editor.IStandaloneCodeEditor, - monaco: typeof monacoEditor - ) => { - monacoObjects.current = { editor, monaco } + const setupMonacoRedisLang = (monaco: typeof monacoEditor) => { + disposeCompletionItemProvider = monaco.languages.registerCompletionItemProvider(MonacoLanguage.Redis, { + provideCompletionItems: (): monacoEditor.languages.CompletionList => ({ suggestions: suggestionsRef.current }) + }).dispose - // hack for exit from snippet mode after click Enter until no answer from monaco authors - // https://github.com/microsoft/monaco-editor/issues/2756 - contribution = editor.getContribution('snippetController2') + disposeSignatureHelpProvider = monaco.languages.registerSignatureHelpProvider(MonacoLanguage.Redis, { + provideSignatureHelp: (): any => getRediSearchSignutureProvider(helpWidgetRef?.current) + }).dispose + } - syntaxWidgetContext = editor.createContextKey(SYNTAX_CONTEXT_ID, false) - editor.focus() - setQueryEl(editor) + const getSuggestions = ( + editor: monacoEditor.editor.IStandaloneCodeEditor, + command?: Nullable + ): { + forceHide: boolean + forceShow: boolean + data: monacoEditor.languages.CompletionItem[] + } => { + const position = editor.getPosition() + const model = editor.getModel() - editor.onKeyDown(onKeyDownMonaco) - editor.onDidChangeCursorPosition(onKeyChangeCursorMonaco) + if (!position || !model) return asSuggestionsRef([]) + const word = model.getWordUntilPosition(position) + const range = getRange(position, word) - setupMonacoRedisLang(monaco) - editor.addAction( - getMonacoAction(MonacoAction.Submit, (editor) => handleSubmit(editor.getValue()), monaco) - ) + if (position.column === 1) { + helpWidgetRef.current.isOpen = false + if (command) return asSuggestionsRef([]) + return asSuggestionsRef(getCommandsSuggestions(commands, range), false, false) + } - editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Space, () => { - onPressWidget() - }, SYNTAX_CONTEXT_ID) + if (!command) { + return asSuggestionsRef(getCommandsSuggestions(commands, range), false) + } - editor.onMouseDown((e: monacoEditor.editor.IEditorMouseEvent) => { - if ((e.target as monacoEditor.editor.IMouseTargetContentWidget)?.detail === SYNTAX_WIDGET_ID) { - onPressWidget() - } - }) + const { allArgs, args, cursor } = command + const [, [currentOffsetArg]] = args - editor.addCommand(monaco.KeyCode.Escape, () => { - hideSyntaxWidget(editor) - isWidgetEscaped.current = true - }, SYNTAX_CONTEXT_ID) + if (COMMANDS_TO_GET_INDEX_INFO.some((name) => name === command.name)) { + setSelectedIndex(allArgs[1] || '') + } else { + setSelectedIndex('') + } - decorationCollection = editor.createDecorationsCollection() - } + const cursorContext: CursorContext = { ...cursor, currentOffsetArg, offset: command.commandCursorPosition, range } + const { suggestions, helpWidget } = findSuggestionsByArg( + REDIS_COMMANDS, + command, + cursorContext, + { fields: attributesRef.current, indexes: indexesRef.current }, + isEscapedSuggestions.current + ) - const setupMonacoRedisLang = (monaco: typeof monacoEditor) => { - disposeCompletionItemProvider = monaco.languages.registerCompletionItemProvider( - MonacoLanguage.Redis, - getRedisCompletionProvider(REDIS_COMMANDS_SPEC) - ).dispose - - disposeSignatureHelpProvider = monaco.languages.registerSignatureHelpProvider( - MonacoLanguage.Redis, - getRedisSignatureHelpProvider(REDIS_COMMANDS_SPEC, REDIS_COMMANDS_ARRAY, isWidgetOpen) - ).dispose - } + if (helpWidget) { + const { isOpen, data } = helpWidget + helpWidgetRef.current = { + isOpen, + data: data || helpWidgetRef.current.data + } + } - const options: monacoEditor.editor.IStandaloneEditorConstructionOptions = { - tabCompletion: 'on', - wordWrap: 'on', - padding: { top: 10 }, - automaticLayout: true, - formatOnPaste: false, - glyphMargin: true, - stickyScroll: { - enabled: true, - defaultModel: 'indentationModel' - }, - suggest: { - preview: true, - showStatusBar: true, - showIcons: false, - }, - lineNumbersMinChars: 4 + return suggestions } const isLoading = loading || processing @@ -475,80 +612,19 @@ const Query = (props: Props) => { editorDidMount={editorDidMount} /> -
- - onQueryChangeMode()} - disabled={isLoading} - className={cx(styles.textBtn, { [styles.activeBtn]: activeMode === RunQueryMode.Raw })} - data-testid="btn-change-mode" - > - - - - - {`${KEYBOARD_SHORTCUTS.workbench.runQuery?.label}:\u00A0\u00A0`} - -
- ) - } - data-testid="run-query-tooltip" - > - <> - {isLoading && ( - - )} - { - handleSubmit() - setTimeout(() => runTooltipRef?.current?.hideToolTip?.(), 0) - }} - disabled={isLoading} - iconType="playFilled" - className={cx(styles.submitButton, { [styles.submitButtonLoading]: isLoading })} - aria-label="submit" - data-testid="btn-submit" - /> - -
- - <> - onChangeGroupMode()} - disabled={isLoading} - className={cx(styles.textBtn, { [styles.activeBtn]: isGroupMode(resultsMode) })} - data-testid="btn-change-group-mode" - > - - - - +
+ +
+ {isDedicatedEditorOpen && ( div { + border: 1px solid var(--euiColorLightShade); + background-color: var(--euiColorEmptyShade); + padding: 8px 20px; + width: 100%; + } +} + +.input { + // cannot use overflow since suggestions are absolute + max-height: calc(100% - 32px); + flex-grow: 1; + width: 100%; + border: 1px solid var(--euiColorLightShade); + background-color: var(--rsInputColor); +} + +.queryFooter { + display: flex; + align-items: center; + justify-content: space-between; + + margin-top: 8px; + flex-shrink: 0; +} + +#script { + font: normal normal bold 14px/17px Inconsolata !important; + color: var(--textColorShade); + caret-color: var(--euiColorFullShade); + min-width: 5px; + display: inline; +} + diff --git a/redisinsight/ui/src/components/query/QueryWrapper.spec.tsx b/redisinsight/ui/src/pages/workbench/components/query/QueryWrapper.spec.tsx similarity index 100% rename from redisinsight/ui/src/components/query/QueryWrapper.spec.tsx rename to redisinsight/ui/src/pages/workbench/components/query/QueryWrapper.spec.tsx diff --git a/redisinsight/ui/src/components/query/QueryWrapper.tsx b/redisinsight/ui/src/pages/workbench/components/query/QueryWrapper.tsx similarity index 54% rename from redisinsight/ui/src/components/query/QueryWrapper.tsx rename to redisinsight/ui/src/pages/workbench/components/query/QueryWrapper.tsx index cab14f46bf..09242df622 100644 --- a/redisinsight/ui/src/components/query/QueryWrapper.tsx +++ b/redisinsight/ui/src/pages/workbench/components/query/QueryWrapper.tsx @@ -1,11 +1,15 @@ -import React from 'react' -import { useSelector } from 'react-redux' +import React, { useEffect, useMemo } from 'react' +import { useDispatch, useSelector } from 'react-redux' import { EuiLoadingContent } from '@elastic/eui' import { appRedisCommandsSelector } from 'uiSrc/slices/app/redis-commands' import { RunQueryMode, ResultsMode } from 'uiSrc/slices/interfaces/workbench' -import Query from './Query' +import { fetchRedisearchListAction, redisearchListSelector } from 'uiSrc/slices/browser/redisearch' +import { connectedInstanceSelector } from 'uiSrc/slices/instances/instances' +import { mergeRedisCommandsSpecs } from 'uiSrc/utils/transformers/redisCommands' +import SEARCH_COMMANDS_SPEC from 'uiSrc/pages/workbench/data/supported_commands.json' import styles from './Query/styles.module.scss' +import Query from './Query' export interface Props { query: string @@ -13,7 +17,6 @@ export interface Props { resultsMode?: ResultsMode setQuery: (script: string) => void setQueryEl: Function - setIsCodeBtnDisabled: (value: boolean) => void onKeyDown?: (e: React.KeyboardEvent, script: string) => void onSubmit: (value?: string) => void onQueryChangeMode: () => void @@ -27,15 +30,29 @@ const QueryWrapper = (props: Props) => { resultsMode, setQuery, setQueryEl, - setIsCodeBtnDisabled, onKeyDown, onSubmit, onQueryChangeMode, onChangeGroupMode } = props - const { - loading: isCommandsLoading, - } = useSelector(appRedisCommandsSelector) + const { loading: isCommandsLoading, } = useSelector(appRedisCommandsSelector) + const { id: connectedIndstanceId } = useSelector(connectedInstanceSelector) + const { data: indexes = [] } = useSelector(redisearchListSelector) + const { spec: COMMANDS_SPEC } = useSelector(appRedisCommandsSelector) + + const REDIS_COMMANDS = useMemo( + () => mergeRedisCommandsSpecs(COMMANDS_SPEC, SEARCH_COMMANDS_SPEC), + [COMMANDS_SPEC, SEARCH_COMMANDS_SPEC] + ) + + const dispatch = useDispatch() + + useEffect(() => { + if (!connectedIndstanceId) return + + // fetch indexes + dispatch(fetchRedisearchListAction(undefined, undefined, false)) + }, [connectedIndstanceId]) const Placeholder = (
@@ -49,11 +66,12 @@ const QueryWrapper = (props: Props) => { ) : ( void setScriptEl: Function - scriptEl: Nullable scrollDivRef: Ref activeMode: RunQueryMode resultsMode: ResultsMode @@ -71,7 +71,6 @@ const WBView = (props: Props) => { processing, setScript, setScriptEl, - scriptEl, activeMode, resultsMode, isResultsLoaded, @@ -94,8 +93,6 @@ const WBView = (props: Props) => { const { commandsArray: REDIS_COMMANDS_ARRAY } = useSelector(appRedisCommandsSelector) const { batchSize = PIPELINE_COUNT_DEFAULT } = useSelector(userSettingsConfigSelector) ?? {} - const [isCodeBtnDisabled, setIsCodeBtnDisabled] = useState(false) - const verticalSizesRef = useRef(vertical) const dispatch = useDispatch() @@ -178,7 +175,7 @@ const WBView = (props: Props) => { scrollable={false} className={styles.queryPanel} initialSize={vertical[verticalPanelIds.firstPanelId] ?? 20} - style={{ minHeight: '140px', zIndex: '8' }} + style={{ minHeight: '240px', zIndex: '8' }} > { resultsMode={resultsMode} setQuery={setScript} setQueryEl={setScriptEl} - setIsCodeBtnDisabled={setIsCodeBtnDisabled} onSubmit={handleSubmit} onQueryChangeMode={onQueryChangeMode} onChangeGroupMode={onChangeGroupMode} @@ -206,7 +202,7 @@ const WBView = (props: Props) => { initialSize={vertical[verticalPanelIds.secondPanelId] ?? 80} className={cx(styles.queryResults, styles.queryResultsPanel)} // Fix scroll on low height - 140px (queryPanel) - style={{ maxHeight: 'calc(100% - 140px)' }} + style={{ maxHeight: 'calc(100% - 240px)' }} > { store.clearActions() }) -jest.mock('uiSrc/components/query', () => ({ +jest.mock('uiSrc/pages/workbench/components/query', () => ({ __esModule: true, namedExport: jest.fn(), default: jest.fn(), diff --git a/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx b/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx index 921c67b96f..cb3542ea1f 100644 --- a/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx +++ b/redisinsight/ui/src/pages/workbench/components/wb-view/WBViewWrapper.tsx @@ -44,7 +44,7 @@ interface IState { let state: IState = { loading: false, - instance: instanceInitState.connectedInstance, + instance: instanceInitState?.connectedInstance, unsupportedCommands: [], blockingCommands: [], visualizations: [], @@ -161,13 +161,18 @@ const WBViewWrapper = () => { ) => { if (!commandInit?.length) return - dispatch(sendWbQueryAction(commandInit, commandId, executeParams, { - afterEach: () => { - const isNewCommand = !commandId - isNewCommand && scrollResults('start') - }, - afterAll: updateOnboardingOnSubmit - })) + dispatch(sendWbQueryAction( + commandInit, + commandId, + executeParams, + { + afterEach: () => { + const isNewCommand = !commandId + isNewCommand && scrollResults('start') + }, + afterAll: updateOnboardingOnSubmit + } + )) } const scrollResults = (inline: ScrollLogicalPosition = 'start') => { @@ -230,7 +235,6 @@ const WBViewWrapper = () => { script={script} setScript={setScript} setScriptEl={setScriptEl} - scriptEl={scriptEl} scrollDivRef={scrollDivRef} activeMode={activeRunQueryMode} onSubmit={sourceValueSubmit} diff --git a/redisinsight/ui/src/pages/workbench/constants.ts b/redisinsight/ui/src/pages/workbench/constants.ts index 571edad511..b894704f29 100644 --- a/redisinsight/ui/src/pages/workbench/constants.ts +++ b/redisinsight/ui/src/pages/workbench/constants.ts @@ -67,3 +67,41 @@ export enum ModuleCommandPrefix { TOPK = 'TOPK.', TDIGEST = 'TDIGEST.', } + +export const COMMANDS_TO_GET_INDEX_INFO = [ + 'FT.SEARCH', + 'FT.AGGREGATE', + 'FT.EXPLAIN', + 'FT.EXPLAINCLI', + 'FT.PROFILE', + 'FT.SPELLCHECK', + 'FT.TAGVALS', + 'FT.ALTER' +] + +export const COMMANDS_WITHOUT_INDEX_PROPOSE = [ + 'FT.CREATE' +] + +export const COMPOSITE_ARGS = [ + 'LOAD *', +] + +export enum DefinedArgumentName { + index = 'index', + query = 'query', + field = 'field', + expression = 'expression' +} + +export const FIELD_START_SYMBOL = '@' +export enum EmptySuggestionsIds { + NoIndexes = 'no-indexes' +} + +export const SORTED_SEARCH_COMMANDS = [ + 'FT.SEARCH', + 'FT.CREATE', + 'FT.EXPLAIN', + 'FT.PROFILE' +] diff --git a/redisinsight/ui/src/pages/workbench/data/supported_commands.json b/redisinsight/ui/src/pages/workbench/data/supported_commands.json new file mode 100644 index 0000000000..6765dceb75 --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/data/supported_commands.json @@ -0,0 +1,1152 @@ +{ + "FT.AGGREGATE": { + "summary": "Run a search query on an index and perform aggregate transformations on the results", + "complexity": "O(1)", + "arguments": [ + { + "name": "index", + "type": "string" + }, + { + "name": "query", + "type": "string" + }, + { + "name": "verbatim", + "type": "pure-token", + "token": "VERBATIM", + "optional": true + }, + { + "name": "load", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "string", + "token": "LOAD" + }, + { + "name": "field", + "type": "string", + "multiple": true + } + ] + }, + { + "name": "timeout", + "type": "integer", + "optional": true, + "token": "TIMEOUT" + }, + { + "name": "loadall", + "type": "pure-token", + "token": "LOAD *", + "optional": true + }, + { + "name": "groupby", + "type": "block", + "optional": true, + "multiple": true, + "arguments": [ + { + "name": "nargs", + "type": "integer", + "token": "GROUPBY" + }, + { + "name": "property", + "type": "string", + "multiple": true + }, + { + "name": "reduce", + "type": "block", + "optional": true, + "multiple": true, + "arguments": [ + { + "name": "reduce", + "token": "REDUCE", + "type": "pure-token" + }, + { + "name": "function", + "type": "oneof", + "arguments": [ + { + "name": "count", + "type": "pure-token", + "token": "COUNT" + }, + { + "name": "count_distinct", + "type": "pure-token", + "token": "COUNT_DISTINCT" + }, + { + "name": "count_distinctish", + "type": "pure-token", + "token": "COUNT_DISTINCTISH" + }, + { + "name": "sum", + "type": "pure-token", + "token": "SUM" + }, + { + "name": "min", + "type": "pure-token", + "token": "MIN" + }, + { + "name": "max", + "type": "pure-token", + "token": "MAX" + }, + { + "name": "avg", + "type": "pure-token", + "token": "AVG" + }, + { + "name": "stddev", + "type": "pure-token", + "token": "STDDEV" + }, + { + "name": "quantile", + "type": "pure-token", + "token": "QUANTILE" + }, + { + "name": "tolist", + "type": "pure-token", + "token": "TOLIST" + }, + { + "name": "first_value", + "type": "pure-token", + "token": "FIRST_VALUE" + }, + { + "name": "random_sample", + "type": "pure-token", + "token": "RANDOM_SAMPLE" + } + ] + }, + { + "name": "nargs", + "type": "integer" + }, + { + "name": "arg", + "type": "string", + "multiple": true + }, + { + "name": "name", + "type": "string", + "token": "AS", + "optional": true + } + ] + } + ] + }, + { + "name": "sortby", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "nargs", + "type": "integer", + "token": "SORTBY" + }, + { + "name": "fields", + "type": "block", + "optional": true, + "multiple": true, + "arguments": [ + { + "name": "property", + "type": "string" + }, + { + "name": "order", + "type": "oneof", + "arguments": [ + { + "name": "asc", + "type": "pure-token", + "token": "ASC" + }, + { + "name": "desc", + "type": "pure-token", + "token": "DESC" + } + ] + } + ] + }, + { + "name": "num", + "type": "integer", + "token": "MAX", + "optional": true + } + ] + }, + { + "name": "apply", + "type": "block", + "optional": true, + "multiple": true, + "arguments": [ + { + "name": "expression", + "type": "string", + "expression": true, + "token": "APPLY", + "arguments": [ + { + "name": "exists", + "token": "exists", + "type": "function", + "summary": "Checks whether a field exists in a document.", + "arguments": [ + { + "token": "s" + } + ] + }, + { + "name": "log", + "token": "log", + "type": "function", + "summary": "Return the logarithm of a number, property or subexpression", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "abs", + "token": "abs", + "type": "function", + "summary": "Return the absolute number of a numeric expression", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "ceil", + "token": "ceil", + "type": "function", + "summary": "Round to the smallest value not less than x", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "floor", + "token": "floor", + "type": "function", + "summary": "Round to largest value not greater than x", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "log2", + "token": "log2", + "type": "function", + "summary": "Return the logarithm of x to base 2", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "exp", + "token": "exp", + "type": "function", + "summary": "Return the exponent of x, e.g., e^x", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "sqrt", + "token": "sqrt", + "type": "function", + "summary": "Return the square root of x", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "upper", + "token": "upper", + "type": "function", + "summary": "Return the uppercase conversion of s", + "arguments": [ + { + "token": "s" + } + ] + }, + { + "name": "lower", + "token": "lower", + "type": "function", + "summary": "Return the lowercase conversion of s", + "arguments": [ + { + "token": "s" + } + ] + }, + { + "name": "startswith", + "token": "startswith", + "type": "function", + "summary": "Return 1 if s2 is the prefix of s1, 0 otherwise.", + "arguments": [ + { + "token": "s1" + }, + { + "token": "s2" + } + ] + }, + { + "name": "contains", + "token": "contains", + "type": "function", + "summary": "Return the number of occurrences of s2 in s1, 0 otherwise. If s2 is an empty string, return length(s1) + 1.", + "arguments": [ + { + "token": "s1" + }, + { + "token": "s2" + } + ] + }, + { + "name": "strlen", + "token": "strlen", + "type": "function", + "summary": "Return the length of s", + "arguments": [ + { + "token": "s" + } + ] + }, + { + "name": "substr", + "token": "substr", + "type": "function", + "summary": "Return the substring of s, starting at offset and having count characters.If offset is negative, it represents the distance from the end of the string.If count is -1, it means \"the rest of the string starting at offset\".", + "arguments": [ + { + "token": "s" + }, + { + "token": "offset" + }, + { + "token": "count" + } + ] + }, + { + "name": "format", + "token": "format", + "type": "function", + "summary": "Use the arguments following fmt to format a string.Currently the only format argument supported is %s and it applies to all types of arguments.", + "arguments": [ + { + "token": "fmt" + } + ] + }, + { + "name": "matched_terms", + "token": "matched_terms", + "type": "function", + "summary": "Return the query terms that matched for each record (up to 100), as a list. If a limit is specified, Redis will return the first N matches found, based on query order.", + "arguments": [ + { + "token": "max_terms=100", + "optional": true + } + ] + }, + { + "name": "split", + "token": "split", + "type": "function", + "summary": "Split a string by any character in the string sep, and strip any characters in strip. If only s is specified, it is split by commas and spaces are stripped. The output is an array.", + "arguments": [ + { + "token": "s" + } + ] + }, + { + "name": "timefmt", + "token": "timefmt", + "type": "function", + "summary": "Return a formatted time string based on a numeric timestamp value x.", + "arguments": [ + { + "token": "x" + }, + { + "token": "fmt", + "optional": true + } + ] + }, + { + "name": "parsetime", + "token": "parsetime", + "type": "function", + "summary": "The opposite of timefmt() - parse a time format using a given format string", + "arguments": [ + { + "token": "timesharing" + }, + { + "token": "fmt", + "optional": true + } + ] + }, + { + "name": "day", + "token": "day", + "type": "function", + "summary": "Round a Unix timestamp to midnight (00:00) start of the current day.", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "hour", + "token": "hour", + "type": "function", + "summary": "Round a Unix timestamp to the beginning of the current hour.", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "minute", + "token": "minute", + "type": "function", + "summary": "Round a Unix timestamp to the beginning of the current minute.", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "month", + "token": "month", + "type": "function", + "summary": "Round a unix timestamp to the beginning of the current month.", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "dayofweek", + "token": "dayofweek", + "type": "function", + "summary": "Convert a Unix timestamp to the day number (Sunday = 0).", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "dayofmonth", + "token": "dayofmonth", + "type": "function", + "summary": "Convert a Unix timestamp to the day of month number (1 .. 31).", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "dayofyear", + "token": "dayofyear", + "type": "function", + "summary": "Convert a Unix timestamp to the day of year number (0 .. 365).", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "year", + "token": "year", + "type": "function", + "summary": "Convert a Unix timestamp to the current year (e.g. 2018).", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "monthofyear", + "token": "monthofyear", + "type": "function", + "summary": "Convert a Unix timestamp to the current month (0 .. 11).", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "geodistance", + "token": "geodistance", + "type": "function", + "summary": "Return distance in meters.", + "arguments": [ + { + "token": "" + } + ] + } + ] + }, + { + "name": "name", + "type": "string", + "token": "AS" + } + ] + }, + { + "name": "limit", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "limit", + "type": "pure-token", + "token": "LIMIT" + }, + { + "name": "offset", + "type": "integer" + }, + { + "name": "num", + "type": "integer" + } + ] + }, + { + "name": "filter", + "type": "string", + "optional": true, + "expression": true, + "token": "FILTER" + }, + { + "name": "cursor", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "withcursor", + "type": "pure-token", + "token": "WITHCURSOR" + }, + { + "name": "read_size", + "type": "integer", + "optional": true, + "token": "COUNT" + }, + { + "name": "idle_time", + "type": "integer", + "optional": true, + "token": "MAXIDLE" + } + ] + }, + { + "name": "params", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "params", + "type": "pure-token", + "token": "PARAMS" + }, + { + "name": "nargs", + "type": "integer" + }, + { + "name": "values", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "name", + "type": "string" + }, + { + "name": "value", + "type": "string" + } + ] + } + ] + }, + { + "name": "dialect", + "type": "integer", + "optional": true, + "token": "DIALECT", + "since": "2.4.3" + } + ], + "since": "1.1.0", + "group": "search", + "provider": "redisearch" + }, + "FT.EXPLAIN": { + "summary": "Returns the execution plan for a complex query", + "complexity": "O(1)", + "arguments": [ + { + "name": "index", + "type": "string" + }, + { + "name": "query", + "type": "string" + }, + { + "name": "dialect", + "type": "integer", + "optional": true, + "token": "DIALECT", + "since": "2.4.3" + } + ], + "since": "1.0.0", + "group": "search", + "provider": "redisearch" + }, + "FT.PROFILE": { + "summary": "Performs a `FT.SEARCH` or `FT.AGGREGATE` command and collects performance information", + "complexity": "O(N)", + "arguments": [ + { + "name": "index", + "type": "string" + }, + { + "name": "querytype", + "type": "oneof", + "arguments": [ + { + "name": "search", + "type": "pure-token", + "token": "SEARCH" + }, + { + "name": "aggregate", + "type": "pure-token", + "token": "AGGREGATE" + } + ] + }, + { + "name": "limited", + "type": "pure-token", + "token": "LIMITED", + "optional": true + }, + { + "name": "query", + "type": "token", + "token": "QUERY", + "expression": true + } + ], + "since": "2.2.0", + "group": "search", + "provider": "redisearch" + }, + "FT.SEARCH": { + "summary": "Searches the index with a textual query, returning either documents or just ids", + "complexity": "O(N)", + "history": [ + [ + "2.0.0", + "Deprecated `WITHPAYLOADS` and `PAYLOAD` arguments" + ] + ], + "arguments": [ + { + "name": "index", + "type": "string" + }, + { + "name": "query", + "type": "string" + }, + { + "name": "nocontent", + "type": "pure-token", + "token": "NOCONTENT", + "optional": true + }, + { + "name": "verbatim", + "type": "pure-token", + "token": "VERBATIM", + "optional": true + }, + { + "name": "nostopwords", + "type": "pure-token", + "token": "NOSTOPWORDS", + "optional": true + }, + { + "name": "withscores", + "type": "pure-token", + "token": "WITHSCORES", + "optional": true + }, + { + "name": "withpayloads", + "type": "pure-token", + "token": "WITHPAYLOADS", + "optional": true + }, + { + "name": "withsortkeys", + "type": "pure-token", + "token": "WITHSORTKEYS", + "optional": true + }, + { + "name": "filter", + "type": "block", + "optional": true, + "multiple": true, + "arguments": [ + { + "name": "numeric_field", + "type": "string", + "token": "FILTER" + }, + { + "name": "min", + "type": "double" + }, + { + "name": "max", + "type": "double" + } + ] + }, + { + "name": "geo_filter", + "type": "block", + "optional": true, + "multiple": true, + "arguments": [ + { + "name": "geo_field", + "type": "string", + "token": "GEOFILTER" + }, + { + "name": "lon", + "type": "double" + }, + { + "name": "lat", + "type": "double" + }, + { + "name": "radius", + "type": "double" + }, + { + "name": "radius_type", + "type": "oneof", + "arguments": [ + { + "name": "m", + "type": "pure-token", + "token": "m" + }, + { + "name": "km", + "type": "pure-token", + "token": "km" + }, + { + "name": "mi", + "type": "pure-token", + "token": "mi" + }, + { + "name": "ft", + "type": "pure-token", + "token": "ft" + } + ] + } + ] + }, + { + "name": "in_keys", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "string", + "token": "INKEYS" + }, + { + "name": "key", + "type": "string", + "multiple": true + } + ] + }, + { + "name": "in_fields", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "string", + "token": "INFIELDS" + }, + { + "name": "field", + "type": "string", + "multiple": true + } + ] + }, + { + "name": "return", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "string", + "token": "RETURN" + }, + { + "name": "identifiers", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "identifier", + "type": "string" + }, + { + "name": "property", + "type": "string", + "token": "AS", + "optional": true + } + ] + } + ] + }, + { + "name": "summarize", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "summarize", + "type": "pure-token", + "token": "SUMMARIZE" + }, + { + "name": "fields", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "string", + "token": "FIELDS" + }, + { + "name": "field", + "type": "string", + "multiple": true + } + ] + }, + { + "name": "num", + "type": "integer", + "token": "FRAGS", + "optional": true + }, + { + "name": "fragsize", + "type": "integer", + "token": "LEN", + "optional": true + }, + { + "name": "separator", + "type": "string", + "token": "SEPARATOR", + "optional": true + } + ] + }, + { + "name": "highlight", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "highlight", + "type": "pure-token", + "token": "HIGHLIGHT" + }, + { + "name": "fields", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "string", + "token": "FIELDS" + }, + { + "name": "field", + "type": "string", + "multiple": true + } + ] + }, + { + "name": "tags", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "tags", + "type": "pure-token", + "token": "TAGS" + }, + { + "name": "open", + "type": "string" + }, + { + "name": "close", + "type": "string" + } + ] + } + ] + }, + { + "name": "slop", + "type": "integer", + "optional": true, + "token": "SLOP" + }, + { + "name": "timeout", + "type": "integer", + "optional": true, + "token": "TIMEOUT" + }, + { + "name": "inorder", + "type": "pure-token", + "token": "INORDER", + "optional": true + }, + { + "name": "language", + "type": "string", + "optional": true, + "token": "LANGUAGE" + }, + { + "name": "expander", + "type": "string", + "optional": true, + "token": "EXPANDER" + }, + { + "name": "scorer", + "type": "string", + "optional": true, + "token": "SCORER" + }, + { + "name": "explainscore", + "type": "pure-token", + "token": "EXPLAINSCORE", + "optional": true + }, + { + "name": "payload", + "type": "string", + "optional": true, + "token": "PAYLOAD" + }, + { + "name": "sortby", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "sortby", + "type": "string", + "token": "SORTBY" + }, + { + "name": "order", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "asc", + "type": "pure-token", + "token": "ASC" + }, + { + "name": "desc", + "type": "pure-token", + "token": "DESC" + } + ] + } + ] + }, + { + "name": "limit", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "limit", + "type": "pure-token", + "token": "LIMIT" + }, + { + "name": "offset", + "type": "integer" + }, + { + "name": "num", + "type": "integer" + } + ] + }, + { + "name": "params", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "params", + "type": "pure-token", + "token": "PARAMS" + }, + { + "name": "nargs", + "type": "integer" + }, + { + "name": "values", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "name", + "type": "string" + }, + { + "name": "value", + "type": "string" + } + ] + } + ] + }, + { + "name": "dialect", + "type": "integer", + "optional": true, + "token": "DIALECT", + "since": "2.4.3" + } + ], + "since": "1.0.0", + "group": "search", + "provider": "redisearch" + } +} diff --git a/redisinsight/ui/src/pages/workbench/types.ts b/redisinsight/ui/src/pages/workbench/types.ts new file mode 100644 index 0000000000..05c961c1a1 --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/types.ts @@ -0,0 +1,28 @@ +import { monaco as monacoEditor } from 'react-monaco-editor' +import { Maybe } from 'uiSrc/utils' +import { IRedisCommand, IRedisCommandTree } from 'uiSrc/constants' + +export enum ArgName { + NArgs = 'nargs', + Count = 'count' +} + +export interface FoundCommandArgument { + isComplete: boolean + stopArg: Maybe + isBlocked: boolean + append: Maybe> + parent: Maybe + token: Maybe +} + +export interface CursorContext { + prevCursorChar: string + nextCursorChar: string + isCursorInQuotes: boolean + currentOffsetArg: string + offset: number + argLeftOffset: number + argRightOffset: number + range: monacoEditor.IRange +} diff --git a/redisinsight/ui/src/pages/workbench/utils/helpers.ts b/redisinsight/ui/src/pages/workbench/utils/helpers.ts new file mode 100644 index 0000000000..72617c0d7c --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/utils/helpers.ts @@ -0,0 +1,17 @@ +import { ICommandTokenType, IRedisCommand } from 'uiSrc/constants' +import { Maybe } from 'uiSrc/utils' + +export const isStringsEqual = (str1?: string, str2?: string) => str1?.toLowerCase() === str2?.toLowerCase() + +export const isTokenEqualsArg = (token: IRedisCommand, arg: string) => { + if (token.type === ICommandTokenType.OneOf) { + return token.arguments + ?.some((oneOfArg: IRedisCommand) => isStringsEqual(oneOfArg?.token, arg)) + } + if (isStringsEqual(token.token, arg)) return true + if (token.type === ICommandTokenType.Block) return isStringsEqual(token.arguments?.[0]?.token, arg) + return false +} + +export const findArgByToken = (list: IRedisCommand[], arg: string): Maybe => + list.find((command) => isTokenEqualsArg(command, arg)) diff --git a/redisinsight/ui/src/pages/workbench/utils/monaco.ts b/redisinsight/ui/src/pages/workbench/utils/monaco.ts new file mode 100644 index 0000000000..8f032ce262 --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/utils/monaco.ts @@ -0,0 +1,80 @@ +import { monaco } from 'react-monaco-editor' +import * as monacoEditor from 'monaco-editor' +import { isString } from 'lodash' +import { generateDetail } from 'uiSrc/pages/workbench/utils/query' +import { Maybe, Nullable } from 'uiSrc/utils' +import { IRedisCommand, ICommandTokenType } from 'uiSrc/constants' + +export const setCursorPositionAtTheEnd = (editor: monacoEditor.editor.IStandaloneCodeEditor) => { + if (!editor) return + + const rows = editor.getValue().split('\n') + + editor.setPosition({ + column: rows[rows.length - 1].trimEnd().length + 1, + lineNumber: rows.length + }) + + editor.focus() +} + +export const getRange = (position: monaco.Position, word: monaco.editor.IWordAtPosition): monaco.IRange => ({ + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + endColumn: word.endColumn, + startColumn: word.startColumn, +}) + +export const buildSuggestion = (arg: IRedisCommand, range: monaco.IRange, options: any = {}) => { + const extraQuotes = arg.expression ? '\'$1\'' : '' + return { + label: isString(arg) ? arg : arg.token || arg.arguments?.[0].token || arg.name || '', + insertText: `${arg.token || arg.arguments?.[0].token || arg.name?.toUpperCase() || ''} ${extraQuotes}`, + insertTextRules: monacoEditor.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range, + kind: options?.kind || monacoEditor.languages.CompletionItemKind.Function, + ...options, + } +} + +export const getRediSearchSignutureProvider = (options: Maybe<{ + isOpen: boolean + data: { + currentArg: IRedisCommand + parent: Maybe + token: Maybe + } +}>) => { + const { isOpen, data } = options || {} + const { currentArg, parent, token } = data || {} + if (!isOpen) return null + + const label = generateDetail(parent) + let signaturePosition: Nullable<[number, number]> = null + const arg = currentArg?.type === ICommandTokenType.Block + ? (currentArg?.arguments?.[0]?.name || currentArg?.token || '') + : (currentArg?.name || currentArg?.type || '') + + // we may have several the same args inside documentation, so we get proper arg after token + const numberOfArgsInside = label.split(arg).length - 1 + if (token && numberOfArgsInside > 1) { + const parentToken = token.token || token.arguments?.[0]?.token + const parentTokenPosition = parentToken ? label.indexOf(parentToken) : 0 + const startPosition = label.indexOf(arg, parentTokenPosition) + signaturePosition = [startPosition, startPosition + arg.length] + } + + return { + dispose: () => {}, + value: { + activeParameter: 0, + activeSignature: 0, + signatures: [{ + label: label || '', + parameters: [ + { label: signaturePosition || arg } + ], + }] + } + } +} diff --git a/redisinsight/ui/src/pages/workbench/utils.ts b/redisinsight/ui/src/pages/workbench/utils/profile.ts similarity index 77% rename from redisinsight/ui/src/pages/workbench/utils.ts rename to redisinsight/ui/src/pages/workbench/utils/profile.ts index 2bca2b87c2..5ba0ae140c 100644 --- a/redisinsight/ui/src/pages/workbench/utils.ts +++ b/redisinsight/ui/src/pages/workbench/utils/profile.ts @@ -1,4 +1,4 @@ -import { ProfileQueryType, SEARCH_COMMANDS, GRAPH_COMMANDS } from './constants' +import { ProfileQueryType, SEARCH_COMMANDS, GRAPH_COMMANDS } from '../constants' export const generateGraphProfileQuery = (query: string, type: ProfileQueryType) => { const q = query?.split(' ')?.slice(1) @@ -20,12 +20,11 @@ export const generateSearchProfileQuery = (query: string, type: ProfileQueryType if (type === ProfileQueryType.Explain) { return [`ft.${type.toLowerCase()}`, ...commandSplit?.slice(1)].join(' ') - } else { - let index = commandSplit?.[1] - - const queryType = cmd.split('.')?.[1] // SEARCH / AGGREGATE - return [`ft.${type.toLowerCase()}`, index, queryType, 'QUERY', ...commandSplit?.slice(2)].join(' ') } + const index = commandSplit?.[1] + + const queryType = cmd.split('.')?.[1] // SEARCH / AGGREGATE + return [`ft.${type.toLowerCase()}`, index, queryType, 'QUERY', ...commandSplit?.slice(2)].join(' ') } export const generateProfileQueryForCommand = (query: string, type: ProfileQueryType) => { @@ -33,7 +32,7 @@ export const generateProfileQueryForCommand = (query: string, type: ProfileQuery if (GRAPH_COMMANDS.includes(cmd)) { return generateGraphProfileQuery(query, type) - } else if (SEARCH_COMMANDS.includes(cmd)) { + } if (SEARCH_COMMANDS.includes(cmd)) { return generateSearchProfileQuery(query, type) } diff --git a/redisinsight/ui/src/pages/workbench/utils/query.ts b/redisinsight/ui/src/pages/workbench/utils/query.ts new file mode 100644 index 0000000000..c9eceb774b --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/utils/query.ts @@ -0,0 +1,399 @@ +/* eslint-disable no-continue */ + +import { findLastIndex, isNumber, toNumber } from 'lodash' +import { generateArgsNames, Maybe, Nullable } from 'uiSrc/utils' +import { CommandProvider, IRedisCommand, IRedisCommandTree, ICommandTokenType } from 'uiSrc/constants' +import { isStringsEqual } from './helpers' +import { ArgName, FoundCommandArgument } from '../types' + +export const findCurrentArgument = ( + args: IRedisCommand[], + prev: string[], + untilTokenArgs: string[] = [], + parent?: IRedisCommandTree +): Nullable => { + for (let i = prev.length - 1; i >= 0; i--) { + const arg = prev[i] + const currentArg = findArgByToken(args, arg) + const currentWithParent: IRedisCommandTree = { ...currentArg, parent } + + if (currentArg?.arguments && currentArg?.type === ICommandTokenType.Block) { + return findCurrentArgument(currentArg.arguments, prev.slice(i), prev, currentWithParent) + } + + const tokenIndex = args.findIndex((cArg) => isStringsEqual(cArg.token, arg)) + const token = args[tokenIndex] + + if (token) { + const pastArgs = prev.slice(i) + const commandArgs = parent ? args.slice(tokenIndex, args.length) : [token] + + // getArgByRest - here we preparing the list of arguments which can be inserted, + // this is the main function which creates the list of arguments + return { + ...getArgumentSuggestions({ tokenArgs: pastArgs, untilTokenArgs }, commandArgs, parent), + token, + parent: parent || token + } + } + } + + return null +} + +const findStopArgumentInQuery = ( + queryArgs: string[], + restCommandArgs: Maybe = [], +): { + restArguments: IRedisCommand[] + stopArgIndex: number + argumentsIntered?: number + isBlocked: boolean + parent?: IRedisCommand +} => { + let currentCommandArgIndex = 0 + let argumentsIntered = 0 + let isBlockedOnCommand = false + let multipleIndexStart = 0 + let multipleCountNumber = 0 + + const moveToNextCommandArg = () => { + currentCommandArgIndex++ + argumentsIntered++ + } + const blockCommand = () => { isBlockedOnCommand = true } + const unBlockCommand = () => { isBlockedOnCommand = false } + + const skipArg = () => { + argumentsIntered -= 1 + moveToNextCommandArg() + unBlockCommand() + } + + for (let i = 0; i < queryArgs.length; i++) { + const arg = queryArgs[i] + const currentCommandArg = restCommandArgs[currentCommandArgIndex] + + if (currentCommandArg?.type === ICommandTokenType.PureToken) { + skipArg() + continue + } + + if (!isBlockedOnCommand && currentCommandArg?.optional) { + const isNotToken = currentCommandArg?.token && !isStringsEqual(currentCommandArg.token, arg) + const isNotOneOfToken = !currentCommandArg?.token && currentCommandArg?.type === ICommandTokenType.OneOf + && currentCommandArg?.arguments?.every(({ token }) => !isStringsEqual(token, arg)) + + if (isNotToken || isNotOneOfToken) { + moveToNextCommandArg() + skipArg() + continue + } + } + + if (currentCommandArg?.type === ICommandTokenType.Block) { + let blockArguments = currentCommandArg.arguments ? [...currentCommandArg.arguments] : [] + const nArgs = toNumber(queryArgs[i - 1]) || 0 + + // if block is multiple - we duplicate nArgs inner arguments + if (currentCommandArg?.multiple && nArgs) { + blockArguments = Array(nArgs).fill(currentCommandArg.arguments).flat() + } + + const currentQueryArg = queryArgs.slice(i)?.[0] + const isBlockHasToken = isStringsEqual(blockArguments?.[0]?.token, currentQueryArg) + + if (currentCommandArg.token && !isBlockHasToken && currentQueryArg) { + blockArguments.unshift({ + type: ICommandTokenType.PureToken, + token: currentQueryArg + }) + } + + const blockSuggestion = findStopArgumentInQuery(queryArgs.slice(i), blockArguments) + const stopArg = blockSuggestion.restArguments?.[blockSuggestion.stopArgIndex] + const { argumentsIntered } = blockSuggestion + + if (nArgs && currentCommandArg?.multiple && isNumber(argumentsIntered) && argumentsIntered >= nArgs) { + i += queryArgs.slice(i).length - 1 + skipArg() + continue + } + + if (blockSuggestion.isBlocked || stopArg) { + return { + ...blockSuggestion, + parent: currentCommandArg + } + } + + i += queryArgs.slice(i).length - 1 + skipArg() + continue + } + + // if we are on token - that requires one more argument + if (isStringsEqual(currentCommandArg?.token, arg)) { + blockCommand() + continue + } + + if (currentCommandArg?.name === ArgName.NArgs || currentCommandArg?.name === ArgName.Count) { + const numberOfArgs = toNumber(arg) + + if (numberOfArgs === 0) { + moveToNextCommandArg() + skipArg() + continue + } + + moveToNextCommandArg() + blockCommand() + continue + } + + if (currentCommandArg?.type === ICommandTokenType.OneOf && currentCommandArg?.optional) { + // if oneof is optional then we can switch to another argument + if (!currentCommandArg?.arguments?.some(({ token }) => isStringsEqual(token, arg))) { + moveToNextCommandArg() + } + + skipArg() + continue + } + + if (currentCommandArg?.multiple) { + if (!multipleIndexStart) { + multipleCountNumber = toNumber(queryArgs[i - 1]) + multipleIndexStart = i - 1 + } + + if (i - multipleIndexStart >= multipleCountNumber) { + skipArg() + multipleIndexStart = 0 + continue + } + + blockCommand() + continue + } + + moveToNextCommandArg() + + isBlockedOnCommand = false + } + + return { + restArguments: restCommandArgs, + stopArgIndex: currentCommandArgIndex, + argumentsIntered, + isBlocked: isBlockedOnCommand + } +} + +export const getArgumentSuggestions = ( + { tokenArgs, untilTokenArgs }: { + tokenArgs: string[], + untilTokenArgs: string[] + }, + pastCommandArgs: IRedisCommand[], + current?: IRedisCommandTree +): { + isComplete: boolean + stopArg: Maybe, + isBlocked: boolean, + append: Array, +} => { + const { + restArguments, + stopArgIndex, + isBlocked: isWasBlocked, + parent + } = findStopArgumentInQuery(tokenArgs, pastCommandArgs) + + const prevArg = restArguments[stopArgIndex - 1] + const stopArgument = restArguments[stopArgIndex] + const restNotFilledArgs = restArguments.slice(stopArgIndex) + + const isOneOfArgument = stopArgument?.type === ICommandTokenType.OneOf + || (stopArgument?.type === ICommandTokenType.PureToken && current?.parent?.type === ICommandTokenType.OneOf) + + if (isWasBlocked) { + return { + isComplete: false, + stopArg: stopArgument, + isBlocked: !isOneOfArgument, + append: isOneOfArgument ? [stopArgument.arguments!] : [], + } + } + + const isPrevArgWasMandatory = prevArg && !prevArg.optional + if (isPrevArgWasMandatory && stopArgument && !stopArgument.optional) { + const isCanAppend = stopArgument?.token || isOneOfArgument + const append = isCanAppend ? [[isOneOfArgument ? stopArgument.arguments! : stopArgument].flat()] : [] + + return { + isComplete: false, + stopArg: stopArgument, + isBlocked: !isCanAppend, + append, + } + } + + // if we finished argument - stopArgument will be undefined, then we get it as token + const lastArgument = stopArgument ?? restArguments[0] + const isBlockHasParent = current?.arguments?.some(({ name }) => parent?.name && name === parent?.name) + const foundParent = isBlockHasParent ? { ...parent, parent: current } : (parent || current) + + const isBlockComplete = !stopArgument && isPrevArgWasMandatory + const beforeMandatoryOptionalArgs = getAllRestArguments(foundParent, lastArgument, untilTokenArgs, isBlockComplete) + const requiredArgsLength = restNotFilledArgs.filter((arg) => !arg.optional).length + + return { + isComplete: requiredArgsLength === 0, + stopArg: stopArgument, + isBlocked: false, + append: beforeMandatoryOptionalArgs, + } +} + +export const getRestArguments = ( + current: Maybe, + stopArgument: Nullable +): IRedisCommandTree[] => { + const argumentIndexInArg = current?.arguments + ?.findIndex(({ name }) => name === stopArgument?.name) + const nextMandatoryIndex = stopArgument && !stopArgument.optional + ? argumentIndexInArg + : argumentIndexInArg && argumentIndexInArg > -1 ? current?.arguments + ?.findIndex(({ optional }, i) => !optional && i > argumentIndexInArg) : -1 + + const prevMandatory = current?.arguments?.slice(0, argumentIndexInArg).reverse() + .find(({ optional }) => !optional) + const prevMandatoryIndex = current?.arguments?.findIndex(({ name }) => name === prevMandatory?.name) + + const beforeMandatoryOptionalArgs = ( + nextMandatoryIndex && nextMandatoryIndex > -1 + ? current?.arguments?.slice(prevMandatoryIndex, nextMandatoryIndex) + : current?.arguments?.slice((prevMandatoryIndex || 0) + 1) + ) || [] + + const nextMandatoryArg = nextMandatoryIndex && nextMandatoryIndex > -1 + ? current?.arguments?.[nextMandatoryIndex] + : undefined + + if (nextMandatoryArg?.token) { + beforeMandatoryOptionalArgs.unshift(nextMandatoryArg) + } + + if (nextMandatoryArg?.type === ICommandTokenType.OneOf) { + beforeMandatoryOptionalArgs.unshift(...(nextMandatoryArg.arguments || [])) + } + + return beforeMandatoryOptionalArgs.map((arg) => ({ ...arg, parent: current })) +} + +export const getAllRestArguments = ( + current: Maybe, + stopArgument: Nullable, + untilTokenArgs: string[] = [], + skipLevel = false +) => { + const appendArgs: Array = [] + + const currentToken = current?.type === ICommandTokenType.Block ? current?.arguments?.[0].token : current?.token + const lastTokenIndex = findLastIndex( + untilTokenArgs, + (arg) => isStringsEqual(arg, currentToken) + ) + const currentLvlNextArgs = removeNotSuggestedArgs( + untilTokenArgs.slice(lastTokenIndex > 0 ? lastTokenIndex : 0), + getRestArguments(current, stopArgument) + ) + + if (!skipLevel) { + appendArgs.push(fillArgsByType(currentLvlNextArgs)) + } + + if (current?.parent) { + const parentArgs = getAllRestArguments(current.parent, current, untilTokenArgs) + if (parentArgs?.length) { + appendArgs.push(...parentArgs) + } + } + + return appendArgs +} + +export const removeNotSuggestedArgs = (args: string[], commandArgs: IRedisCommandTree[]) => + commandArgs.filter((arg) => { + if (arg.token && arg.multiple) return true + + if (arg.type === ICommandTokenType.OneOf) { + return !args + .some((queryArg) => arg.arguments + ?.some((oneOfArg) => isStringsEqual(oneOfArg.token, queryArg))) + } + + if (arg.type === ICommandTokenType.Block) { + if (arg.token) return !args.includes(arg.token) || arg.multiple + return arg.arguments?.[0]?.token && (!args.includes(arg.arguments?.[0]?.token?.toUpperCase()) || arg.multiple) + } + + return arg.token && !args.includes(arg.token) + }) + +export const fillArgsByType = (args: IRedisCommand[], expandBlock = true): IRedisCommandTree[] => { + const result: IRedisCommandTree[] = [] + + for (let i = 0; i < args.length; i++) { + const currentArg = args[i] + + if (expandBlock && currentArg.type === ICommandTokenType.OneOf && !currentArg.token) { + result.push(...(currentArg?.arguments?.map((arg) => ({ ...arg, parent: currentArg })) || [])) + } + + if (currentArg.token) { + result.push(currentArg) + continue + } + + if (currentArg.type === ICommandTokenType.Block) { + result.push({ + multiple: currentArg.multiple, + optional: currentArg.optional, + parent: currentArg, + ...(currentArg?.arguments?.[0] as IRedisCommand || {}), + }) + } + } + + return result +} + +export const findArgByToken = (list: IRedisCommand[], arg: string): Maybe => + list.find((cArg) => + (cArg.type === ICommandTokenType.OneOf + ? cArg.arguments?.some((oneOfArg: IRedisCommand) => isStringsEqual(oneOfArg?.token, arg)) + : isStringsEqual(cArg.arguments?.[0].token, arg))) + +export const generateDetail = (command: Maybe) => { + if (!command) return '' + if (command.arguments) { + const args = generateArgsNames(CommandProvider.Main, command.arguments).join(' ') + return command.token ? `${command.token} ${args}` : args + } + if (command.token) { + if (command.type === ICommandTokenType.PureToken) return command.token + return `${command.token}` + } + + return '' +} + +export const addOwnTokenToArgs = (token: string, command: IRedisCommand) => { + if (command.arguments) { + return ({ ...command, arguments: [{ token, type: ICommandTokenType.PureToken }, ...command.arguments] }) + } + return command +} diff --git a/redisinsight/ui/src/pages/workbench/utils/query_refactor.ts b/redisinsight/ui/src/pages/workbench/utils/query_refactor.ts new file mode 100644 index 0000000000..774892d4a3 --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/utils/query_refactor.ts @@ -0,0 +1,71 @@ +/* eslint-disable no-continue */ +import { ICommandTokenType, IRedisCommand } from 'uiSrc/constants' +import { Maybe } from 'uiSrc/utils' +import { isStringsEqual, isTokenEqualsArg } from './helpers' + +interface BlockTokensTree { + queryArgs: string[] + command?: IRedisCommand + parent?: BlockTokensTree +} + +export const findSuggestionsByQueryArgs = ( + commands: IRedisCommand[], + queryArgs: string[], +) => { + const firstQueryArg = queryArgs[0] + const scopeCommand = firstQueryArg + ? commands.find((command) => isStringsEqual(command.token, firstQueryArg)) + : undefined + + const getLastBlock = ( + args: string[], + command?: IRedisCommand, + parent?: any, + ): BlockTokensTree => { + for (let i = args.length - 1; i >= 0; i--) { + const arg = args[i] + const currentArg = findArgByToken(command?.arguments || [], arg) + + if (currentArg?.type === ICommandTokenType.Block) { + return getLastBlock(args.slice(i), currentArg, { queryArgs: queryArgs.slice(i), command: currentArg, parent }) + } + } + + return parent + } + + const blockToken: BlockTokensTree = { queryArgs: queryArgs.slice(scopeCommand ? 1 : 0), command: scopeCommand } + const currentBlock = getLastBlock(queryArgs, scopeCommand, blockToken) + const stopArgument = findStopArgumentWithSuggestions(currentBlock) + + console.log(stopArgument) + + return null +} + +const getStopArgument = ( + queryArgs: string[], + command: Maybe +) => { + let currentCommandArgIndex = 0 + + for (let i = 0; i < queryArgs.length; i++) { + const arg = queryArgs[i] + const currentCommandArg = command?.arguments?.[currentCommandArgIndex] + + currentCommandArgIndex++ + } + + return null +} + +const findStopArgumentWithSuggestions = (currentBlock: BlockTokensTree) => { + console.log(currentBlock) + const stopArgument = getStopArgument(currentBlock.queryArgs, currentBlock.command) + + return null +} + +const findArgByToken = (list: IRedisCommand[], arg: string): Maybe => + list.find((command) => isTokenEqualsArg(command, arg)) diff --git a/redisinsight/ui/src/pages/workbench/utils/searchSuggestions.ts b/redisinsight/ui/src/pages/workbench/utils/searchSuggestions.ts new file mode 100644 index 0000000000..0d408b170f --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/utils/searchSuggestions.ts @@ -0,0 +1,203 @@ +import { monaco as monacoEditor } from 'react-monaco-editor' +import { isNumber } from 'lodash' +import { IMonacoQuery, Nullable, splitQueryByArgs } from 'uiSrc/utils' +import { CursorContext, FoundCommandArgument } from 'uiSrc/pages/workbench/types' +import { findCurrentArgument } from 'uiSrc/pages/workbench/utils/query' +import { IRedisCommand } from 'uiSrc/constants' +import { + asSuggestionsRef, + getFieldsSuggestions, + getFunctionsSuggestions, + getGeneralSuggestions, + getIndexesSuggestions, + getNoIndexesSuggestion +} from 'uiSrc/pages/workbench/utils/suggestions' +import { + COMMANDS_WITHOUT_INDEX_PROPOSE, + DefinedArgumentName, + FIELD_START_SYMBOL, + ModuleCommandPrefix +} from 'uiSrc/pages/workbench/constants' + +export const findSuggestionsByArg = ( + listOfCommands: IRedisCommand[], + command: IMonacoQuery, + cursorContext: CursorContext, + additionData: { + indexes?: any[] + fields?: any[], + }, + isEscaped: boolean = false +): { + suggestions: any, + helpWidget?: any +} => { + const { allArgs, args, cursor } = command + const { prevCursorChar } = cursor + const [beforeOffsetArgs, [currentOffsetArg]] = args + + const scopedList = command.name + ? listOfCommands.filter(({ token }) => token === command?.name) + : listOfCommands + const foundArg = findCurrentArgument(scopedList, beforeOffsetArgs) + + if (!command.name.startsWith(ModuleCommandPrefix.RediSearch)) { + return { + helpWidget: { isOpen: !!foundArg, data: { parent: foundArg?.parent, currentArg: foundArg?.stopArg } }, + suggestions: asSuggestionsRef([]) + } + } + + if (prevCursorChar === FIELD_START_SYMBOL) { + return handleFieldSuggestions(additionData.fields || [], foundArg, cursorContext.range) + } + + if (foundArg?.stopArg?.token && !foundArg?.isBlocked) { + return handleCommonSuggestions( + command.fullQuery, + foundArg, + allArgs, + additionData.fields || [], + cursorContext, + isEscaped + ) + } + + const { indexes, fields } = additionData + switch (foundArg?.stopArg?.name) { + case DefinedArgumentName.index: { + return handleIndexSuggestions(indexes, command, foundArg, currentOffsetArg, cursorContext) + } + case DefinedArgumentName.query: { + return handleQuerySuggestions(foundArg) + } + default: { + return handleCommonSuggestions(command.fullQuery, foundArg, allArgs, fields, cursorContext, isEscaped) + } + } +} + +const handleFieldSuggestions = ( + fields: any[], + foundArg: Nullable, + range: monacoEditor.IRange +) => { + const isInQuery = foundArg?.stopArg?.name === DefinedArgumentName.query + const fieldSuggestions = getFieldsSuggestions(fields, range, true, isInQuery) + return { + suggestions: asSuggestionsRef(fieldSuggestions, true) + } +} + +const handleIndexSuggestions = ( + indexes: any[] = [], + command: IMonacoQuery, + foundArg: FoundCommandArgument, + currentOffsetArg: Nullable, + cursorContext: CursorContext +) => { + const isIndex = indexes.length > 0 + const helpWidget = { isOpen: isIndex, data: { parent: foundArg.parent, currentArg: foundArg?.stopArg } } + const currentCommand = command.info + + if (COMMANDS_WITHOUT_INDEX_PROPOSE.includes(command.name || '')) { + return { + suggestions: asSuggestionsRef([]), + helpWidget + } + } + + if (!isIndex) { + helpWidget.isOpen = !!currentOffsetArg + + return { + suggestions: asSuggestionsRef(!currentOffsetArg ? getNoIndexesSuggestion(cursorContext.range) : [], true), + helpWidget + } + } + + if (!isIndex || currentOffsetArg) { + return { + suggestions: asSuggestionsRef([], !currentOffsetArg), + helpWidget + } + } + + const argumentIndex = currentCommand?.arguments + ?.findIndex(({ name }) => foundArg?.stopArg?.name === name) + const isNextArgQuery = isNumber(argumentIndex) + && currentCommand?.arguments?.[argumentIndex + 1]?.name === DefinedArgumentName.query + + return { + suggestions: asSuggestionsRef(getIndexesSuggestions(indexes, cursorContext.range, isNextArgQuery)), + helpWidget + } +} + +const handleQuerySuggestions = (foundArg: FoundCommandArgument) => ({ + helpWidget: { isOpen: true, data: { parent: foundArg?.parent, currentArg: foundArg?.stopArg } }, + suggestions: asSuggestionsRef([], false) +}) + +const handleExpressionSuggestions = ( + value: string, + foundArg: FoundCommandArgument, + cursorContext: CursorContext, +) => { + const helpWidget = { isOpen: true, data: { parent: foundArg?.parent, currentArg: foundArg?.stopArg } } + + const { isCursorInQuotes, offset, argLeftOffset } = cursorContext + if (!isCursorInQuotes) { + return { + suggestions: asSuggestionsRef([]), + helpWidget + } + } + + const stringBeforeCursor = value.substring(argLeftOffset, offset) || '' + const expression = stringBeforeCursor.replace(/^["']|["']$/g, '') + const { args } = splitQueryByArgs(expression, offset - argLeftOffset) + const [, [currentArg]] = args + + const functions = foundArg?.stopArg?.arguments ?? [] + const suggestions = getFunctionsSuggestions(functions, cursorContext.range) + const isStartsWithFunction = functions.some(({ token }) => token?.startsWith(currentArg)) + + return { + suggestions: asSuggestionsRef(suggestions, true, isStartsWithFunction), + helpWidget + } +} + +const handleCommonSuggestions = ( + value: string, + foundArg: Nullable, + allArgs: string[], + fields: any[] = [], + cursorContext: CursorContext, + isEscaped: boolean +) => { + if (foundArg?.stopArg?.expression && foundArg.isBlocked) { + return handleExpressionSuggestions(value, foundArg, cursorContext) + } + + const { prevCursorChar, nextCursorChar, isCursorInQuotes } = cursorContext + const shouldHideSuggestions = isCursorInQuotes || nextCursorChar || (prevCursorChar && isEscaped) + if (shouldHideSuggestions) { + return { + helpWidget: { isOpen: !!foundArg, data: { parent: foundArg?.parent, currentArg: foundArg?.stopArg } }, + suggestions: asSuggestionsRef([]) + } + } + + const { + suggestions, + forceHide, + helpWidgetData + } = getGeneralSuggestions(foundArg, allArgs, cursorContext.range, fields) + + return { + suggestions: asSuggestionsRef(suggestions, forceHide), + helpWidget: helpWidgetData + } +} diff --git a/redisinsight/ui/src/pages/workbench/utils/suggestions.ts b/redisinsight/ui/src/pages/workbench/utils/suggestions.ts new file mode 100644 index 0000000000..9e443d3f06 --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/utils/suggestions.ts @@ -0,0 +1,221 @@ +import { monaco } from 'react-monaco-editor' +import * as monacoEditor from 'monaco-editor' +import { findIndex } from 'lodash' +import { RedisResponseBuffer } from 'uiSrc/slices/interfaces' +import { bufferToString, formatLongName, generateArgsForInsertText, getCommandMarkdown, Nullable } from 'uiSrc/utils' +import { FoundCommandArgument } from 'uiSrc/pages/workbench/types' +import { + DefinedArgumentName, + EmptySuggestionsIds, + ModuleCommandPrefix, + SORTED_SEARCH_COMMANDS +} from 'uiSrc/pages/workbench/constants' +import { getUtmExternalLink } from 'uiSrc/utils/links' +import { IRedisCommand } from 'uiSrc/constants' +import { generateDetail, removeNotSuggestedArgs } from './query' +import { buildSuggestion, } from './monaco' + +export const asSuggestionsRef = ( + suggestions: monacoEditor.languages.CompletionItem[], + forceHide = true, + forceShow = true +) => ({ + data: suggestions, + forceHide, + forceShow +}) + +const NO_INDEXES_DOC_LINK = getUtmExternalLink('https://redis.io/docs/latest/commands/ft.create/', { campaign: 'workbench' }) +export const getNoIndexesSuggestion = (range: monaco.IRange) => [ + { + id: EmptySuggestionsIds.NoIndexes, + label: 'No indexes to display', + kind: monacoEditor.languages.CompletionItemKind.Issue, + insertText: '', + insertTextRules: monacoEditor.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range, + detail: 'Create an index', + documentation: { + value: `See the [documentation](${NO_INDEXES_DOC_LINK}) for detailed instructions on how to create an index.`, + } + } +] + +export const getIndexesSuggestions = (indexes: RedisResponseBuffer[], range: monaco.IRange, isNextArgQuery = true) => + indexes.map((index) => { + const value = formatLongName(bufferToString(index)) + const insertQueryQuotes = isNextArgQuery ? " '\${1:query to search}'" : '' + + return { + label: value || ' ', + kind: monacoEditor.languages.CompletionItemKind.Snippet, + insertText: `'${value}'${insertQueryQuotes} `, + insertTextRules: monacoEditor.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range, + detail: value || ' ', + } + }) + +export const addFieldAttribute = (attribute: string, type: string) => { + switch (type) { + case 'TAG': return `${attribute}:{\${1:tag}}` + case 'TEXT': return `${attribute}:(\${1:term})` + case 'NUMERIC': return `${attribute}:[\${1:range}]` + case 'GEO': return `${attribute}:[\${1:lon} \${2:lat} \${3:radius} \${4:unit}]` + case 'VECTOR': return `${attribute} \\$\${1:vector}` + default: return attribute + } +} + +export const getFieldsSuggestions = ( + fields: any[], + range: monaco.IRange, + spaceAfter = false, + withType = false +) => + fields.map((field) => { + const { attribute, type } = field + const attibuteText = attribute.trim() ? attribute : `\\'${attribute}\\'` + const insertText = withType ? addFieldAttribute(attibuteText, type) : attibuteText + + return { + label: attribute || ' ', + kind: monacoEditor.languages.CompletionItemKind.Reference, + insertText: `${insertText}${spaceAfter ? ' ' : ''}`, + insertTextRules: monacoEditor.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range, + detail: attribute || ' ', + } + }) + +const insertFunctionArguments = (args: IRedisCommand[]) => + generateArgsForInsertText( + args.map(({ token, optional }) => (optional ? `[${token}]` : (token || ''))) as string[], + ', ' + ) + +export const getFunctionsSuggestions = (functions: IRedisCommand[], range: monaco.IRange) => functions + .map(({ token, summary, arguments: args }) => ({ + label: token || '', + insertText: `${token}(${insertFunctionArguments(args || [])})`, + insertTextRules: monacoEditor.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range, + kind: monacoEditor.languages.CompletionItemKind.Function, + detail: summary + })) + +export const getSortingForCommand = (command: IRedisCommand) => { + if (!command.token?.startsWith(ModuleCommandPrefix.RediSearch)) return command.token + if (!SORTED_SEARCH_COMMANDS.includes(command.token)) return command.token + + const index = findIndex(SORTED_SEARCH_COMMANDS, (token) => token === command.token) + return `${ModuleCommandPrefix.RediSearch}_${index}` +} + +export const getCommandsSuggestions = (commands: IRedisCommand[], range: monaco.IRange) => + commands.map((command) => buildSuggestion(command, range, { + detail: generateDetail(command), + insertTextRules: monacoEditor.languages.CompletionItemInsertTextRule.InsertAsSnippet, + documentation: { + value: getCommandMarkdown(command as any) + }, + sortText: getSortingForCommand(command) + })) + +export const getMandatoryArgumentSuggestions = ( + foundArg: FoundCommandArgument, + fields: any[], + range: monaco.IRange +): monacoEditor.languages.CompletionItem[] => { + if (foundArg.stopArg?.name === DefinedArgumentName.field) { + if (!fields.length) return [] + return getFieldsSuggestions(fields, range, true) + } + + if (foundArg.isBlocked) return [] + if (foundArg.append?.length) { + return foundArg.append[0].map((arg: any) => buildSuggestion(arg, range, { + kind: monacoEditor.languages.CompletionItemKind.Property, + detail: generateDetail(foundArg?.parent) + })) + } + + return [] +} + +export const getCommandSuggestions = ( + foundArg: Nullable, + allArgs: string[], + range: monaco.IRange, +) => { + const appendCommands = foundArg?.append ?? [] + const suggestions = [] + + for (let i = 0; i < appendCommands.length; i++) { + const isLastLevel = i === appendCommands.length - 1 + const filteredFileldArgs = isLastLevel + ? removeNotSuggestedArgs(allArgs, appendCommands[i]) + : appendCommands[i] + + const leveledSuggestions = filteredFileldArgs + .map((arg) => buildSuggestion(arg, range, { + sortText: `${i}`, + kind: isLastLevel + ? monacoEditor.languages.CompletionItemKind.Reference + : monacoEditor.languages.CompletionItemKind.Property, + detail: generateDetail(arg?.parent) + })) + + suggestions.push(leveledSuggestions) + } + + return suggestions.flat() +} + +export const getGeneralSuggestions = ( + foundArg: Nullable, + allArgs: string[], + range: monacoEditor.IRange, + fields: any[] +): { + suggestions: monacoEditor.languages.CompletionItem[], + forceHide?: boolean + helpWidgetData?: any +} => { + if (foundArg && !foundArg.isComplete) { + return { + suggestions: getMandatoryArgumentSuggestions(foundArg, fields, range), + helpWidgetData: { + isOpen: !!foundArg?.stopArg, + data: { + parent: foundArg?.parent, + currentArg: foundArg?.stopArg, + token: foundArg?.token + } + } + } + } + + return { + suggestions: getCommandSuggestions(foundArg, allArgs, range), + helpWidgetData: { isOpen: false } + } +} + +export const isIndexComplete = (index: string) => { + if (index.length === 0) return false + + const firstChar = index[0] + const lastChar = index[index.length - 1] + + if (firstChar !== '"' && firstChar !== "'") return true + if (index.length === 1 && (firstChar === '"' || firstChar === "'")) return false + if (firstChar !== lastChar) return false + + let escape = false + for (let i = 1; i < index.length - 1; i++) { + escape = index[i] === '\\' && !escape + } + + return !escape +} diff --git a/redisinsight/ui/src/pages/workbench/utils/tests/monaco.spec.ts b/redisinsight/ui/src/pages/workbench/utils/tests/monaco.spec.ts new file mode 100644 index 0000000000..01978197c0 --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/utils/tests/monaco.spec.ts @@ -0,0 +1,65 @@ +import { MOCKED_REDIS_COMMANDS } from 'uiSrc/mocks/data/mocked_redis_commands' +import { getRediSearchSignutureProvider } from 'uiSrc/pages/workbench/utils/monaco' + +const ftAggregateCommand = MOCKED_REDIS_COMMANDS['FT.AGGREGATE'] + +const getRediSearchSignatureProviderTests = [ + { + input: { + isOpen: false, + data: { + currentArg: {}, + parent: {} + } + }, + result: null + }, + { + input: { + isOpen: true, + data: { + currentArg: ftAggregateCommand.arguments.find(({ name }) => name === 'groupby'), + parent: null + } + }, + result: { + dispose: expect.any(Function), + value: { + activeParameter: 0, + activeSignature: 0, + signatures: [{ + label: '', + parameters: [{ label: 'nargs' }] + }] + } + } + }, + { + input: { + isOpen: true, + data: { + currentArg: { name: 'expression' }, + parent: ftAggregateCommand.arguments.find(({ name }) => name === 'apply') + } + }, + result: { + dispose: expect.any(Function), + value: { + activeParameter: 0, + activeSignature: 0, + signatures: [{ + label: 'APPLY expression AS name', + parameters: [{ label: 'expression' }] + }] + } + } + } +] + +describe('getRediSearchSignatureProvider', () => { + it.each(getRediSearchSignatureProviderTests)('should properly return result', ({ input, result }) => { + const testResult = getRediSearchSignutureProvider(input) + + expect(result).toEqual(testResult) + }) +}) diff --git a/redisinsight/ui/src/pages/workbench/utils.spec.ts b/redisinsight/ui/src/pages/workbench/utils/tests/profile.spec.ts similarity index 90% rename from redisinsight/ui/src/pages/workbench/utils.spec.ts rename to redisinsight/ui/src/pages/workbench/utils/tests/profile.spec.ts index 3d91dad1d8..e7aef603fe 100644 --- a/redisinsight/ui/src/pages/workbench/utils.spec.ts +++ b/redisinsight/ui/src/pages/workbench/utils/tests/profile.spec.ts @@ -1,11 +1,10 @@ -import { ProfileQueryType, SEARCH_COMMANDS, GRAPH_COMMANDS } from './constants' +import { ProfileQueryType } from '../../constants' import { generateGraphProfileQuery, generateSearchProfileQuery, generateProfileQueryForCommand, -} from './utils' - +} from '../profile' const generateGraphProfileQueryTests: Record[] = [ { input: 'GRAPH.QUERY key "MATCH (n) RETURN n"', output: 'graph.profile key "MATCH (n) RETURN n"', type: ProfileQueryType.Profile }, @@ -17,14 +16,13 @@ const generateGraphProfileQueryTests: Record[] = [ ] describe('generateGraphProfileQuery', () => { - generateGraphProfileQueryTests.forEach(test => { + generateGraphProfileQueryTests.forEach((test) => { it(`should be output: ${test.output} for input: ${test.input} and type: ${test.type}`, () => { - const result = generateGraphProfileQuery(test.input, test.type); - expect(result).toEqual(test.output); - }); + const result = generateGraphProfileQuery(test.input, test.type) + expect(result).toEqual(test.output) + }) }) -}); - +}) const generateSearchProfileQueryTests: Record[] = [ { input: 'FT.SEARCH index tomatoes', output: 'ft.profile index SEARCH QUERY tomatoes', type: ProfileQueryType.Profile }, @@ -44,13 +42,13 @@ const generateSearchProfileQueryTests: Record[] = [ ] describe('generateSearchProfileQuery', () => { - generateSearchProfileQueryTests.forEach(test => { + generateSearchProfileQueryTests.forEach((test) => { it(`should be output: ${test.output} for input: ${test.input} and type: ${test.type}`, () => { - const result = generateSearchProfileQuery(test.input, test.type); - expect(result).toEqual(test.output); - }); + const result = generateSearchProfileQuery(test.input, test.type) + expect(result).toEqual(test.output) + }) }) -}); +}) const generateProfileQueryForCommandTests: Record[] = [ ...generateGraphProfileQueryTests, @@ -69,11 +67,11 @@ const generateProfileQueryForCommandTests: Record[] = [ { input: 'ft.explain index tomatoes', output: null, type: ProfileQueryType.Explain }, ] describe('generateProfileQueryForCommand', () => { - generateProfileQueryForCommandTests.forEach(test => { + generateProfileQueryForCommandTests.forEach((test) => { it(`should be output: ${test.output} for input: ${test.input} and type: ${test.type}`, () => { - const result = generateProfileQueryForCommand(test.input, test.type); + const result = generateProfileQueryForCommand(test.input, test.type) - expect(result).toEqual(test.output); - }); + expect(result).toEqual(test.output) + }) }) -}); +}) diff --git a/redisinsight/ui/src/pages/workbench/utils/tests/query.spec.ts b/redisinsight/ui/src/pages/workbench/utils/tests/query.spec.ts new file mode 100644 index 0000000000..07010aaa38 --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/utils/tests/query.spec.ts @@ -0,0 +1,113 @@ +import { Maybe, splitQueryByArgs } from 'uiSrc/utils' +import { MOCKED_REDIS_COMMANDS } from 'uiSrc/mocks/data/mocked_redis_commands' +import { IRedisCommand, ICommandTokenType } from 'uiSrc/constants' +import { + commonfindCurrentArgumentCases, + findArgumentftAggreageTests, + findArgumentftSearchTests +} from './test-cases' +import { addOwnTokenToArgs, findCurrentArgument, generateDetail } from '../query' + +const ftSearchCommand = MOCKED_REDIS_COMMANDS['FT.SEARCH'] +const ftAggregateCommand = MOCKED_REDIS_COMMANDS['FT.AGGREGATE'] +const COMMANDS = Object.keys(MOCKED_REDIS_COMMANDS).map((name) => ({ + name, + ...MOCKED_REDIS_COMMANDS[name] +})) +const COMPOSITE_ARGS = COMMANDS + .filter((command) => command.name && command.name.includes(' ')) + .map(({ name }) => name) + +describe('findCurrentArgument', () => { + describe('with list of commands', () => { + commonfindCurrentArgumentCases.forEach(({ input, result, appendIncludes, appendNotIncludes }) => { + it(`should return proper suggestions for ${input}`, () => { + const { args } = splitQueryByArgs(input, 0, COMPOSITE_ARGS.concat('LOAD *')) + const COMMANDS_LIST = COMMANDS.map((command) => ({ + ...addOwnTokenToArgs(command.name!, command), + token: command.name!, + type: ICommandTokenType.Block + })) + + const testResult = findCurrentArgument( + COMMANDS_LIST, + args.flat() + ) + expect(testResult).toEqual(result) + expect( + testResult?.append?.flat()?.map((arg) => arg.token) + ).toEqual( + expect.arrayContaining(appendIncludes) + ) + + if (appendNotIncludes) { + appendNotIncludes.forEach((token) => { + expect( + testResult?.append?.flat()?.map((arg) => arg.token) + ).not.toEqual( + expect.arrayContaining([token]) + ) + }) + } + }) + }) + }) + + describe('FT.AGGREGATE', () => { + findArgumentftAggreageTests.forEach(({ args, result: testResult }) => { + it(`should return proper suggestions for ${args.join(' ')}`, () => { + const result = findCurrentArgument( + ftAggregateCommand.arguments as IRedisCommand[], + args + ) + expect(testResult).toEqual(result) + }) + }) + }) + + describe('FT.SEARCH', () => { + findArgumentftSearchTests.forEach(({ args, result: testResult }) => { + it(`should return proper suggestions for ${args.join(' ')}`, () => { + const result = findCurrentArgument( + ftSearchCommand.arguments as IRedisCommand[], + args + ) + expect(testResult).toEqual(result) + }) + }) + }) +}) + +const generateDetailTests: Array<{ input: Maybe, result: any }> = [ + { + input: ftSearchCommand.arguments.find(({ name }) => name === 'nocontent') as IRedisCommand, + result: 'NOCONTENT' + }, + { + input: ftSearchCommand.arguments.find(({ name }) => name === 'filter') as IRedisCommand, + result: 'FILTER numeric_field min max' + }, + { + input: ftSearchCommand.arguments.find(({ name }) => name === 'geo_filter') as IRedisCommand, + result: 'GEOFILTER geo_field lon lat radius m | km | mi | ft' + }, + { + input: ftAggregateCommand.arguments.find(({ name }) => name === 'groupby') as IRedisCommand, + result: 'GROUPBY nargs property [property ...] [REDUCE function nargs arg [arg ...] [AS name] [REDUCE function nargs arg [arg ...] [AS name] ...]]' + }, +] + +describe('generateDetail', () => { + it.each(generateDetailTests)('should return for %input proper result', ({ input, result }) => { + const testResult = generateDetail(input) + expect(testResult).toEqual(result) + }) +}) + +describe('addOwnTokenToArgs', () => { + it('should add FT.SEARCH to args', () => { + const result = addOwnTokenToArgs('FT.SEARCH', { arguments: [] }) + + expect({ arguments: [{ token: 'FT.SEARCH', type: 'pure-token' }] }).toEqual(result) + }) +}) diff --git a/redisinsight/ui/src/pages/workbench/utils/tests/test-cases/common.ts b/redisinsight/ui/src/pages/workbench/utils/tests/test-cases/common.ts new file mode 100644 index 0000000000..02ace2613c --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/utils/tests/test-cases/common.ts @@ -0,0 +1,357 @@ +// Common test cases +export const commonfindCurrentArgumentCases = [ + { + input: 'FT.SEARCH index "" DIALECT 1', + result: { + stopArg: undefined, + append: expect.any(Array), + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + }, + appendIncludes: ['WITHSCORES', 'VERBATIM', 'FILTER', 'SORTBY', 'RETURN'], + appendNotIncludes: ['DIALECT'] + }, + { + input: 'FT.AGGREGATE "idx:schools" "" GROUPBY 1 p REDUCE AVG 1 a1 AS name ', + result: { + stopArg: undefined, + append: expect.any(Array), + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + }, + appendIncludes: ['REDUCE', 'APPLY', 'SORTBY', 'GROUPBY'], + appendNotIncludes: ['AS'], + }, + { + input: 'FT.AGGREGATE \'idx1:vd\' "*" GROUPBY 1 @location REDUCE COUNT 0 AS item_count REDUCE SUM 1 @students ', + result: { + stopArg: { + name: 'name', + optional: true, + token: 'AS', + type: 'string' + }, + append: expect.any(Array), + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + }, + appendIncludes: ['AS', 'REDUCE', 'APPLY', 'SORTBY', 'GROUPBY'], + }, + { + input: 'FT.SEARCH "idx:bicycle" "*" ', + result: { + stopArg: expect.any(Object), + append: expect.any(Array), + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + }, + appendIncludes: ['DIALECT', 'EXPANDER', 'INKEYS', 'LIMIT'], + appendNotIncludes: ['ASC'], + }, + { + input: 'FT.SEARCH "idx:bicycle" "*" DIALECT 2', + result: expect.any(Object), + appendIncludes: ['EXPANDER', 'INKEYS', 'LIMIT'], + appendNotIncludes: ['DIALECT'], + }, + { + input: 'FT.PROFILE \'idx:schools\' SEARCH ', + result: expect.any(Object), + appendIncludes: ['LIMITED', 'QUERY'], + appendNotIncludes: ['AGGREGATE', 'SEARCH'], + }, + { + input: 'FT.PROFILE idx AGGREGATE LIMITED ', + result: expect.any(Object), + appendIncludes: ['QUERY'], + appendNotIncludes: ['LIMITED', 'SEARCH'], + }, + { + input: 'FT.PROFILE \'idx:schools\' SEARCH QUERY \'q\' ', + result: expect.any(Object), + appendIncludes: [], + appendNotIncludes: ['LIMITED'], + }, + { + input: 'FT.CREATE "idx:schools" ', + result: expect.any(Object), + appendIncludes: ['FILTER', 'ON', 'SCHEMA', 'SCORE', 'NOHL', 'STOPWORDS'], + appendNotIncludes: ['HASH', 'JSON'], + }, + { + input: 'FT.CREATE "idx:schools" ON', + result: expect.any(Object), + appendIncludes: ['HASH', 'JSON'], + appendNotIncludes: ['SCHEMA', 'SCORE', 'NOHL'], + }, + { + input: 'FT.CREATE "idx:schools" ON JSON NOFREQS', + result: expect.any(Object), + appendIncludes: ['TEMPORARY', 'NOFIELDS', 'PAYLOAD_FIELD', 'MAXTEXTFIELDS', 'PREFIX', 'SKIPINITIALSCAN'], + appendNotIncludes: ['ON', 'JSON', 'NOFREQS'], + }, + { + input: 'FT.CREATE "idx:schools" ON JSON NOFREQS SKIPINITIALSCAN', + result: expect.any(Object), + appendIncludes: ['TEMPORARY', 'NOFIELDS', 'PAYLOAD_FIELD', 'MAXTEXTFIELDS', 'PREFIX'], + appendNotIncludes: ['ON', 'JSON', 'NOFREQS', 'SKIPINITIALSCAN'], + }, + { + input: 'FT.CREATE "idx:schools" ON JSON SCHEMA address ', + result: { + stopArg: expect.any(Object), + append: expect.any(Array), + isBlocked: false, + isComplete: false, + parent: expect.any(Object), + token: expect.any(Object) + }, + appendIncludes: ['AS', 'GEO', 'TEXT', 'VECTOR'], + appendNotIncludes: ['SCHEMA', 'SCORE', 'NOHL'], + }, + { + input: 'FT.CREATE "idx:schools" ON JSON SCHEMA address TEXT NOINDEX INDEXMISSING ', + result: { + stopArg: expect.any(Object), + append: expect.any(Array), + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + }, + appendIncludes: ['INDEXEMPTY', 'SORTABLE', 'WITHSUFFIXTRIE'], + appendNotIncludes: ['SCHEMA', 'SCORE', 'NOHL'], + }, + { + input: 'FT.ALTER "idx:schools" ', + result: { + stopArg: expect.any(Object), + append: expect.any(Array), + isBlocked: false, + isComplete: false, + parent: expect.any(Object), + token: expect.any(Object) + }, + appendIncludes: ['SCHEMA', 'SKIPINITIALSCAN'], + appendNotIncludes: ['ADD'], + }, + { + input: 'FT.ALTER "idx:schools" SCHEMA', + result: expect.any(Object), + appendIncludes: ['ADD'], + appendNotIncludes: ['SKIPINITIALSCAN'], + }, + { + input: 'FT.CONFIG SET ', + result: { + stopArg: { + name: 'option', + type: 'string' + }, + append: [], + isBlocked: true, + isComplete: false, + parent: expect.any(Object), + token: expect.any(Object) + }, + appendIncludes: [], + appendNotIncludes: [expect.any(String)], + }, + { + input: 'FT.CURSOR READ "idx:schools" 1 ', + result: expect.any(Object), + appendIncludes: ['COUNT'], + }, + { + input: 'FT.DICTADD dict term1 ', + result: { + append: [], + isBlocked: true, + isComplete: false, + parent: expect.any(Object), + token: expect.any(Object), + stopArg: { + multiple: true, + name: 'term', + type: 'string' + } + }, + appendIncludes: [], + }, + { + input: 'FT.SUGADD key string ', + result: { + append: [], + isBlocked: true, + isComplete: false, + parent: expect.any(Object), + stopArg: { + name: 'score', + type: 'double' + }, + token: expect.any(Object) + }, + appendIncludes: [], + }, + { + input: 'FT.SUGADD key string 1.0 ', + result: expect.any(Object), + appendIncludes: ['INCR', 'PAYLOAD'], + }, + { + input: 'FT.SUGADD key string 1.0 PAYLOAD 1 ', + result: expect.any(Object), + appendIncludes: ['INCR'], + appendNotIncludes: ['PAYLOAD'], + }, + { + input: 'FT.SUGGET k p FUZZY MAX 2 ', + result: expect.any(Object), + appendIncludes: ['WITHPAYLOADS', 'WITHSCORES'], + appendNotIncludes: ['FUZZY', 'MAX'], + }, + { + input: 'FT.ALTER index SKIPINITIALSCAN ', + result: expect.any(Object), + appendIncludes: ['SCHEMA'], + appendNotIncludes: ['ADD'], + }, + { + input: 'FT.SPELLCHECK idx "" ', + result: expect.any(Object), + appendIncludes: ['DIALECT', 'DISTANCE', 'TERMS'], + appendNotIncludes: ['EXCLUDE', 'INCLUDE'], + }, + { + input: 'FT.SEARCH index "" HIGHLIGHT FIELDS 1 f1 ', + result: expect.any(Object), + appendIncludes: ['TAGS', 'SUMMARIZE', 'DIALECT', 'FILTER', 'WITHSCORES', 'INKEYS'], + appendNotIncludes: ['FIELDS'], + }, + { + input: 'FT.SEARCH index "*" SORTBY price ', + result: expect.any(Object), + appendIncludes: ['ASC', 'DESC', 'FILTER', 'LIMIT', 'DIALECT', 'WITHSCORES', 'INFIELDS'], + appendNotIncludes: ['SORTBY'], + }, + { + input: 'FT.SEARCH textVehicles "(-@make:Toyota)" FILTER @year 2021 2022 ', + result: expect.any(Object), + appendIncludes: ['FILTER', 'GEOFILTER', 'TIMEOUT', 'WITHSORTKEYS'], + appendNotIncludes: ['AS', 'ASC'], + }, + { + input: 'FT.SEARCH textVehicles "*" GEOFILTER geo_field lon lat radius ', + result: expect.any(Object), + appendIncludes: ['ft', 'km', 'm', 'mi'], + appendNotIncludes: ['SORTBY', 'FILTER', 'LIMIT', 'DIALECT', 'AS', 'ASC'], + }, + // skip + // { + // input: 'FT.SEARCH textVehicles "*" RETURN 2 test ', + // result: expect.any(Object), + // appendIncludes: ['AS'], + // appendNotIncludes: ['SORTBY', 'FILTER', 'LIMIT', 'DIALECT', 'ASC'], + // }, + { + input: 'FT.CREATE textVehicles ON ', + result: expect.any(Object), + appendIncludes: ['HASH', 'JSON'], + appendNotIncludes: ['SORTBY', 'FILTER', 'LIMIT', 'DIALECT', 'WITHSCORES', 'INFIELDS'], + }, + { + input: 'FT.CREATE textVehicles SCHEMA make ', + result: expect.any(Object), + appendIncludes: ['AS', 'GEO', 'NUMERIC', 'TAG', 'TEXT', 'VECTOR'], + appendNotIncludes: ['FILTER', 'LIMIT', 'DIALECT', 'WITHSCORES', 'INFIELDS'], + }, + { + input: 'FT.AGGREGATE \'idx:articles\' \'@body:(term) \' APPLY \'test\' ', + result: expect.any(Object), + appendIncludes: ['AS'], + appendNotIncludes: ['REDUCE', 'APPLY', 'LOAD', 'SORTBY', 'GROUPBY'], + }, + { + input: 'FT.AGGREGATE \'idx:articles\' \'@body:(term) \' APPLY \'test\' AS test1', + result: expect.any(Object), + appendIncludes: ['APPLY', 'LOAD', 'SORTBY', 'GROUPBY'], + }, + { + input: 'FT.AGGREGATE \'idx:articles\' \'@body:(term) \' LOAD * ', + result: expect.any(Object), + appendIncludes: ['APPLY', 'LOAD', 'SORTBY', 'GROUPBY'], + }, + { + input: 'FT.AGGREGATE \'idx:articles\' \'@body:(term) \' SORTBY nargs property ', + result: expect.any(Object), + appendIncludes: ['ASC', 'DESC'], + appendNotIncludes: ['REDUCE', 'APPLY', 'LOAD', 'SORTBY', 'GROUPBY'], + }, + { + input: 'FT.AGGREGATE \'idx:articles\' \'@body:(term) \' SORTBY nargs property ASC ', + result: expect.any(Object), + appendIncludes: ['MAX', 'APPLY', 'LOAD', 'GROUPBY'], + appendNotIncludes: ['SORTBY'], + }, + { + input: 'FT.AGGREGATE \'idx:articles\' \'@body:(term) \' PARAMS 4 name1 value1 name2 value2 ', + result: expect.any(Object), + appendIncludes: ['APPLY', 'LOAD', 'SORTBY', 'GROUPBY'], + appendNotIncludes: ['PARAMS', 'REDUCE'], + }, + { + input: 'FT.ALTER index SCHEMA ADD sdfsd fsdfsd ', + result: expect.any(Object), + appendIncludes: [], + appendNotIncludes: ['SKIPINITIALSCAN', 'ADD', 'SCHEMA'], + }, + { + input: 'FT.DROPINDEX \'vd\' ', + result: expect.any(Object), + appendIncludes: ['DD'], + }, + { + input: 'FT.EXPLAIN index query ', + result: expect.any(Object), + appendIncludes: ['DIALECT'], + appendNotIncludes: ['SKIPINITIALSCAN', 'ADD', 'SCHEMA', 'APPLY', 'LOAD', 'SORTBY', 'GROUPBY'], + }, + { + input: 'FT.EXPLAINCLI index query ', + result: expect.any(Object), + appendIncludes: ['DIALECT'], + appendNotIncludes: ['SKIPINITIALSCAN', 'ADD', 'SCHEMA', 'APPLY', 'LOAD', 'SORTBY', 'GROUPBY'], + }, + { + input: 'FT.INFO index ', + result: expect.any(Object), + appendIncludes: [], + appendNotIncludes: ['ADD', 'SCHEMA', 'APPLY', 'LOAD', 'SORTBY', 'GROUPBY'], + }, + { + input: 'FT.PROFILE \'idx:schools\' ', + result: expect.any(Object), + appendIncludes: ['AGGREGATE', 'SEARCH'], + appendNotIncludes: ['LIMITED'], + }, + { + input: 'FT.SPELLCHECK \'idx:articles\' \'test\' DIALECT dialect DISTANCE distance TERMS ', + result: expect.any(Object), + appendIncludes: ['EXCLUDE', 'INCLUDE'], + appendNotIncludes: ['DIALECT', 'DISTANCE', 'TERMS'], + }, + { + input: 'FT.SYNUPDATE \'idx:products\' synonym_group_id ', + result: expect.any(Object), + appendIncludes: ['SKIPINITIALSCAN'], + appendNotIncludes: ['DIALECT', 'DISTANCE', 'TERMS', 'INCLUDE', 'SCHEMA', 'APPLY', 'LOAD', 'SORTBY', 'GROUPBY'], + }, +] diff --git a/redisinsight/ui/src/pages/workbench/utils/tests/test-cases/ft-aggregate.ts b/redisinsight/ui/src/pages/workbench/utils/tests/test-cases/ft-aggregate.ts new file mode 100644 index 0000000000..03211cafbe --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/utils/tests/test-cases/ft-aggregate.ts @@ -0,0 +1,282 @@ +export const findArgumentftAggreageTests = [ + { args: [''], result: null }, + { args: ['', ''], result: null }, + { + args: ['index', '"query"', 'APPLY'], + result: { + stopArg: { name: 'expression', token: 'APPLY', type: 'string' }, + append: [], + isBlocked: true, + isComplete: false, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['index', '"query"', 'APPLY', 'expression'], + result: { + stopArg: { name: 'name', token: 'AS', type: 'string' }, + append: expect.any(Array), + isBlocked: false, + isComplete: false, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['index', '"query"', 'APPLY', 'expression', 'AS'], + result: { + stopArg: { name: 'name', token: 'AS', type: 'string' }, + append: expect.any(Array), + isBlocked: true, + isComplete: false, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['index', '"query"', 'APPLY', 'expression', 'AS', 'name'], + result: { + stopArg: undefined, + append: expect.any(Array), + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['""', '""', 'GROUPBY', '2', 'p1', 'p2', 'REDUCE', 'f'], + result: { + stopArg: { name: 'nargs', type: 'integer' }, + append: [], + isBlocked: true, + isComplete: false, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['""', '""', 'GROUPBY', '2', 'p1', 'p2', 'REDUCE', 'f', '0'], + result: { + stopArg: { + name: 'name', + type: 'string', + token: 'AS', + optional: true + }, + append: [ + [ + { + name: 'name', + type: 'string', + token: 'AS', + optional: true, + parent: { + name: 'reduce', + type: 'block', + optional: true, + multiple: true, + arguments: [ + { + name: 'function', + token: 'REDUCE', + type: 'string' + }, + { + name: 'nargs', + type: 'integer' + }, + { + name: 'arg', + type: 'string', + multiple: true + }, + { + name: 'name', + type: 'string', + token: 'AS', + optional: true + } + ], + parent: expect.any(Object) + } + } + ], + [ + { + name: 'function', + token: 'REDUCE', + type: 'string', + multiple: true, + optional: true, + parent: expect.any(Object) + } + ] + ], + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['""', '""', 'GROUPBY', '2', 'p1', 'p2', 'REDUCE', 'f', '1', 'AS', 'name'], + result: { + stopArg: undefined, + append: [ + [], + [ + { + name: 'function', + token: 'REDUCE', + type: 'string', + multiple: true, + optional: true, + parent: expect.any(Object) + } + ] + ], + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['index', '"query"', 'SORTBY'], + result: { + stopArg: { name: 'nargs', token: 'SORTBY', type: 'integer' }, + append: expect.any(Array), + isBlocked: true, + isComplete: false, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['index', '"query"', 'SORTBY', '1', 'p1'], + result: { + stopArg: { + name: 'num', + type: 'integer', + token: 'MAX', + optional: true + }, + append: [ + [ + { + name: 'num', + type: 'integer', + token: 'MAX', + optional: true, + parent: expect.any(Object) + } + ] + ], + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['index', '"query"', 'SORTBY', '2', 'p1', 'ASC'], + result: { + stopArg: { + name: 'num', + type: 'integer', + token: 'MAX', + optional: true + }, + append: [ + [ + { + name: 'num', + type: 'integer', + token: 'MAX', + optional: true, + parent: expect.any(Object) + } + ] + ], + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + }, + }, + { + args: ['index', '"query"', 'SORTBY', '0'], + result: { + stopArg: { + name: 'num', + type: 'integer', + token: 'MAX', + optional: true + }, + append: [ + [{ + name: 'num', + type: 'integer', + token: 'MAX', + optional: true, + parent: expect.any(Object) + }] + ], + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['index', '"query"', 'SORTBY', '2', 'p1', 'ASC', 'MAX'], + result: { + stopArg: { + name: 'num', + type: 'integer', + token: 'MAX', + optional: true + }, + append: [], + isBlocked: true, + isComplete: false, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['index', '"query"', 'LOAD', '4'], + result: { + stopArg: { multiple: true, name: 'field', type: 'string' }, + append: [], + isBlocked: true, + isComplete: false, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['index', '"query"', 'LOAD', '4', '1', '2', '3'], + result: { + stopArg: { multiple: true, name: 'field', type: 'string' }, + append: [], + isBlocked: true, + isComplete: false, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['index', '"query"', 'LOAD', '4', '1', '2', '3', '4'], + result: { + stopArg: undefined, + append: [], + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + } + }, +] diff --git a/redisinsight/ui/src/pages/workbench/utils/tests/test-cases/ft-search.ts b/redisinsight/ui/src/pages/workbench/utils/tests/test-cases/ft-search.ts new file mode 100644 index 0000000000..a9d54d33e3 --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/utils/tests/test-cases/ft-search.ts @@ -0,0 +1,290 @@ +export const findArgumentftSearchTests = [ + { args: [''], result: null }, + { args: ['', ''], result: null }, + { + args: ['', '', 'SUMMARIZE'], + result: { + stopArg: { + name: 'fields', + type: 'block', + optional: true, + arguments: [ + { + name: 'count', + type: 'string', + token: 'FIELDS' + }, + { + name: 'field', + type: 'string', + multiple: true + } + ] + }, + append: [[ + { + name: 'count', + type: 'string', + token: 'FIELDS', + optional: true, + parent: expect.any(Object), + }, + { + name: 'num', + type: 'integer', + token: 'FRAGS', + optional: true, + parent: expect.any(Object) + }, + { + name: 'fragsize', + type: 'integer', + token: 'LEN', + optional: true, + parent: expect.any(Object) + }, + { + name: 'separator', + type: 'string', + token: 'SEPARATOR', + optional: true, + parent: expect.any(Object) + } + ]], + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['', '', 'SUMMARIZE', 'FIELDS'], + result: { + stopArg: { + name: 'count', + type: 'string', + token: 'FIELDS' + }, + append: [], + isBlocked: true, + isComplete: false, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['', '', 'SUMMARIZE', 'FIELDS', '1'], + result: { + stopArg: { + name: 'field', + type: 'string', + multiple: true + }, + append: [], + isBlocked: true, + isComplete: false, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['', '', 'SUMMARIZE', 'FIELDS', '1', 'f', 'FRAGS'], + result: { + stopArg: { + name: 'num', + type: 'integer', + token: 'FRAGS', + optional: true + }, + append: [], + isBlocked: true, + isComplete: false, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['', '', 'SUMMARIZE', 'FIELDS', '1', 'f', 'FRAGS', '10'], + result: { + stopArg: { + name: 'fragsize', + type: 'integer', + token: 'LEN', + optional: true + }, + append: [[ + { + name: 'fragsize', + type: 'integer', + token: 'LEN', + optional: true, + parent: expect.any(Object) + }, + { + name: 'separator', + type: 'string', + token: 'SEPARATOR', + optional: true, + parent: expect.any(Object) + } + ]], + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['', '', 'RETURN', '1', 'iden'], + result: { + stopArg: undefined, + append: [], + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['', '', 'RETURN', '2', 'iden'], + result: { + stopArg: { + name: 'property', + type: 'string', + token: 'AS', + optional: true + }, + append: [ + [ + { + name: 'property', + type: 'string', + token: 'AS', + optional: true, + parent: expect.any(Object) + } + ], + [] + ], + isBlocked: false, + isComplete: false, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['', '', 'RETURN', '2', 'iden', 'iden'], + result: { + stopArg: undefined, + append: [], + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['', '', 'RETURN', '3', 'iden', 'iden'], + result: { + stopArg: { + name: 'property', + type: 'string', + token: 'AS', + optional: true + }, + append: [ + [ + { + name: 'property', + type: 'string', + token: 'AS', + optional: true, + parent: expect.any(Object) + } + ], + [] + ], + isBlocked: false, + isComplete: false, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['', '', 'RETURN', '3', 'iden', 'iden', 'AS', 'iden2'], + result: { + stopArg: undefined, + append: [], + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['', '', 'SORTBY', 'f'], + result: { + stopArg: { + name: 'order', + type: 'oneof', + optional: true, + arguments: [ + { + name: 'asc', + type: 'pure-token', + token: 'ASC' + }, + { + name: 'desc', + type: 'pure-token', + token: 'DESC' + } + ] + }, + append: [ + [ + { + name: 'asc', + type: 'pure-token', + token: 'ASC', + parent: expect.any(Object) + }, + { + name: 'desc', + type: 'pure-token', + token: 'DESC', + parent: expect.any(Object) + } + ] + ], + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['', '', 'SORTBY', 'f', 'DESC'], + result: { + stopArg: undefined, + append: [[]], + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + } + }, + { + args: ['', '', 'DIALECT', '1'], + result: { + stopArg: undefined, + append: [ + [] + ], + isBlocked: false, + isComplete: true, + parent: expect.any(Object), + token: expect.any(Object) + } + }, +] diff --git a/redisinsight/ui/src/pages/workbench/utils/tests/test-cases/index.ts b/redisinsight/ui/src/pages/workbench/utils/tests/test-cases/index.ts new file mode 100644 index 0000000000..42889e7be5 --- /dev/null +++ b/redisinsight/ui/src/pages/workbench/utils/tests/test-cases/index.ts @@ -0,0 +1,3 @@ +export * from './ft-aggregate' +export * from './ft-search' +export * from './common' diff --git a/redisinsight/ui/src/slices/app/context.ts b/redisinsight/ui/src/slices/app/context.ts index d00dfff572..4421886dff 100644 --- a/redisinsight/ui/src/slices/app/context.ts +++ b/redisinsight/ui/src/slices/app/context.ts @@ -75,6 +75,12 @@ export const initialState: StateAppContext = { vertical: {} } }, + searchAndQuery: { + script: '', + panelSizes: { + vertical: {} + } + }, pubsub: { channel: '', message: '' @@ -178,6 +184,12 @@ const appContextSlice = createSlice({ setWorkbenchVerticalPanelSizes: (state, { payload }: { payload: any }) => { state.workbench.panelSizes.vertical = payload }, + setSQVerticalPanelSizes: (state, { payload }: { payload: any }) => { + state.searchAndQuery.panelSizes.vertical = payload + }, + setSQScript: (state, { payload }: { payload: any }) => { + state.searchAndQuery.script = payload + }, setLastPageContext: (state, { payload }: { payload: string }) => { state.lastPage = payload }, @@ -248,6 +260,8 @@ export const { resetBrowserTree, setWorkbenchScript, setWorkbenchVerticalPanelSizes, + setSQVerticalPanelSizes, + setSQScript, setLastPageContext, setPubSubFieldsContext, setBrowserBulkActionOpen, @@ -276,6 +290,8 @@ export const appContextBrowserKeyDetails = (state: RootState) => state.app.context.browser.keyDetailsSizes export const appContextWorkbench = (state: RootState) => state.app.context.workbench +export const appContextSearchAndQuery = (state: RootState) => + state.app.context.searchAndQuery export const appContextSelectedKey = (state: RootState) => state.app.context.browser.keyList.selectedKey export const appContextPubSub = (state: RootState) => diff --git a/redisinsight/ui/src/slices/app/plugins.ts b/redisinsight/ui/src/slices/app/plugins.ts index 77c248067c..1f30dea8a5 100644 --- a/redisinsight/ui/src/slices/app/plugins.ts +++ b/redisinsight/ui/src/slices/app/plugins.ts @@ -9,7 +9,7 @@ import { } from 'uiSrc/utils' import { apiService } from 'uiSrc/services' import { ApiEndpoints } from 'uiSrc/constants' -import { IPlugin, PluginsResponse, StateAppPlugins } from 'uiSrc/slices/interfaces' +import { CommandExecutionType, IPlugin, PluginsResponse, StateAppPlugins } from 'uiSrc/slices/interfaces' import { SendCommandResponse } from 'apiSrc/modules/cli/dto/cli.dto' import { PluginState } from 'apiSrc/modules/workbench/models/plugin-state' @@ -95,11 +95,19 @@ export function loadPluginsAction() { } // Asynchronous thunk action -export function sendPluginCommandAction({ command = '', onSuccessAction, onFailAction }: { - command: string - onSuccessAction?: (responseData: any) => void - onFailAction?: (error: any) => void -}) { +export function sendPluginCommandAction( + { + command = '', + executionType = CommandExecutionType.Workbench, + onSuccessAction, + onFailAction + }: { + command: string + executionType?: CommandExecutionType + onSuccessAction?: (responseData: any) => void + onFailAction?: (error: any) => void + } +) { return async (_dispatch: AppDispatch, stateInit: () => RootState) => { try { const state = stateInit() @@ -112,7 +120,8 @@ export function sendPluginCommandAction({ command = '', onSuccessAction, onFailA ApiEndpoints.COMMAND_EXECUTIONS ), { - command: multilineCommandToOneLine(command) + command: multilineCommandToOneLine(command), + type: executionType } ) diff --git a/redisinsight/ui/src/slices/browser/redisearch.ts b/redisinsight/ui/src/slices/browser/redisearch.ts index 911497e8c6..10a3cb46be 100644 --- a/redisinsight/ui/src/slices/browser/redisearch.ts +++ b/redisinsight/ui/src/slices/browser/redisearch.ts @@ -532,3 +532,30 @@ export function deleteRedisearchHistoryAction( } } } + +export function fetchRedisearchInfoAction( + index: string, + onSuccess?: (value: RedisResponseBuffer[]) => void, + onFailed?: () => void, +) { + return async (_: AppDispatch, stateInit: () => RootState) => { + try { + const state = stateInit() + const { data, status } = await apiService.post( + getUrl( + state.connections.instances.connectedInstance?.id, + ApiEndpoints.REDISEARCH_INFO + ), + { + index + } + ) + + if (isStatusSuccessful(status)) { + onSuccess?.(data) + } + } catch (_err) { + onFailed?.() + } + } +} diff --git a/redisinsight/ui/src/slices/interfaces/app.ts b/redisinsight/ui/src/slices/interfaces/app.ts index 2eff3e510b..994b3c41e2 100644 --- a/redisinsight/ui/src/slices/interfaces/app.ts +++ b/redisinsight/ui/src/slices/interfaces/app.ts @@ -106,6 +106,14 @@ export interface StateAppContext { } } } + searchAndQuery: { + script: string + panelSizes: { + vertical: { + [key: string]: number + } + } + } pubsub: { channel: string message: string diff --git a/redisinsight/ui/src/slices/interfaces/index.ts b/redisinsight/ui/src/slices/interfaces/index.ts index e3294a071d..052fd92082 100644 --- a/redisinsight/ui/src/slices/interfaces/index.ts +++ b/redisinsight/ui/src/slices/interfaces/index.ts @@ -2,9 +2,10 @@ export * from './instances' export * from './hash' export * from './app' export * from './workbench' +export * from './redisearch' export * from './monitor' export * from './api' export * from './bulkActions' -export * from './redisearch' +export * from './searchAndQuery' export * from './cloud' export * from './rdi' diff --git a/redisinsight/ui/src/slices/interfaces/searchAndQuery.ts b/redisinsight/ui/src/slices/interfaces/searchAndQuery.ts new file mode 100644 index 0000000000..dd75a9be03 --- /dev/null +++ b/redisinsight/ui/src/slices/interfaces/searchAndQuery.ts @@ -0,0 +1,11 @@ +import { CommandExecutionUI, RunQueryMode } from 'uiSrc/slices/interfaces/workbench' + +export interface StateSearchAndQuery { + isLoaded: boolean + loading: boolean + processing: boolean + clearing: boolean + error: string + items: CommandExecutionUI[] + activeRunQueryMode: RunQueryMode +} diff --git a/redisinsight/ui/src/slices/interfaces/workbench.ts b/redisinsight/ui/src/slices/interfaces/workbench.ts index 0dafcdb0ec..94652c9768 100644 --- a/redisinsight/ui/src/slices/interfaces/workbench.ts +++ b/redisinsight/ui/src/slices/interfaces/workbench.ts @@ -69,6 +69,11 @@ export enum ResultsMode { GroupMode = 'GROUP_MODE', } +export enum CommandExecutionType { + Workbench = 'WORKBENCH', + Search = 'SEARCH', +} + export interface ResultsSummary { total: number success: number diff --git a/redisinsight/ui/src/slices/search/searchAndQuery.ts b/redisinsight/ui/src/slices/search/searchAndQuery.ts new file mode 100644 index 0000000000..76b3c147fd --- /dev/null +++ b/redisinsight/ui/src/slices/search/searchAndQuery.ts @@ -0,0 +1,34 @@ +import { createSlice } from '@reduxjs/toolkit' +import { RunQueryMode, StateSearchAndQuery } from 'uiSrc/slices/interfaces' +import { localStorageService } from 'uiSrc/services' +import { BrowserStorageItem } from 'uiSrc/constants' +import { RootState } from 'uiSrc/slices/store' + +export const initialState: StateSearchAndQuery = { + isLoaded: false, + loading: false, + processing: false, + clearing: false, + error: '', + items: [], + activeRunQueryMode: localStorageService?.get(BrowserStorageItem.SQRunQueryMode) ?? RunQueryMode.ASCII, +} + +const searchAndQuerySlice = createSlice({ + name: 'searchAndQuery', + initialState, + reducers: { + changeSQActiveRunQueryMode: (state, { payload }) => { + state.activeRunQueryMode = payload + localStorageService.set(BrowserStorageItem.SQRunQueryMode, payload) + }, + } +}) + +export const searchAndQuerySelector = (state: RootState) => state.search.query + +export default searchAndQuerySlice.reducer + +export const { + changeSQActiveRunQueryMode, +} = searchAndQuerySlice.actions diff --git a/redisinsight/ui/src/slices/store.ts b/redisinsight/ui/src/slices/store.ts index c7a3bbf4e4..89ede143e9 100644 --- a/redisinsight/ui/src/slices/store.ts +++ b/redisinsight/ui/src/slices/store.ts @@ -32,6 +32,7 @@ import appOauthReducer from './oauth/cloud' import workbenchResultsReducer from './workbench/wb-results' import workbenchTutorialsReducer from './workbench/wb-tutorials' import workbenchCustomTutorialsReducer from './workbench/wb-custom-tutorials' +import searchAndQueryReducer from './search/searchAndQuery' import contentCreateRedisButtonReducer from './content/create-redis-buttons' import contentGuideLinksReducer from './content/guide-links' import pubSubReducer from './pubsub/pubsub' @@ -95,6 +96,9 @@ export const rootReducer = combineReducers({ tutorials: workbenchTutorialsReducer, customTutorials: workbenchCustomTutorialsReducer, }), + search: combineReducers({ + query: searchAndQueryReducer, + }), content: combineReducers({ createRedisButtons: contentCreateRedisButtonReducer, guideLinks: contentGuideLinksReducer, diff --git a/redisinsight/ui/src/slices/tests/browser/redisearch.spec.ts b/redisinsight/ui/src/slices/tests/browser/redisearch.spec.ts index 8df3a44642..295f812c9a 100644 --- a/redisinsight/ui/src/slices/tests/browser/redisearch.spec.ts +++ b/redisinsight/ui/src/slices/tests/browser/redisearch.spec.ts @@ -47,7 +47,7 @@ import reducer, { deleteRediSearchHistorySuccess, deleteRediSearchHistoryFailure, fetchRedisearchHistoryAction, - deleteRedisearchHistoryAction, + deleteRedisearchHistoryAction, fetchRedisearchInfoAction, } from '../../browser/redisearch' let store: typeof mockedStore @@ -1276,5 +1276,40 @@ describe('redisearch slice', () => { expect(store.getActions()).toEqual(expectedActions) }) }) + + describe('fetchRedisearchInfoAction', () => { + it('success fetch info', async () => { + // Arrange + const responsePayload = { status: 200 } + const onSuccess = jest.fn() + + apiService.post = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch(fetchRedisearchInfoAction('index', onSuccess)) + + expect(onSuccess).toBeCalled() + }) + + it('failed to delete history', async () => { + // Arrange + const onFailed = jest.fn() + const errorMessage = 'some error' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.post = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch(fetchRedisearchInfoAction('index', undefined, onFailed)) + + // Assert + expect(onFailed).toBeCalled() + }) + }) }) }) diff --git a/redisinsight/ui/src/slices/tests/search/searchAndQuery.spec.ts b/redisinsight/ui/src/slices/tests/search/searchAndQuery.spec.ts new file mode 100644 index 0000000000..29af826a88 --- /dev/null +++ b/redisinsight/ui/src/slices/tests/search/searchAndQuery.spec.ts @@ -0,0 +1,58 @@ +import { cloneDeep } from 'lodash' + +import { + cleanup, + initialStateDefault, + mockedStore, +} from 'uiSrc/utils/test-utils' + +import { RunQueryMode } from 'uiSrc/slices/interfaces' +import reducer, { + initialState, + searchAndQuerySelector, + changeSQActiveRunQueryMode +} from '../../search/searchAndQuery' + +jest.mock('uiSrc/services', () => ({ + ...jest.requireActual('uiSrc/services'), +})) + +let store: typeof mockedStore +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('slices', () => { + describe('reducer, actions and selectors', () => { + it('should return the initial state on first run', () => { + // Arrange + const nextState = initialState + + // Act + const result = reducer(undefined, {} as any) + + // Assert + expect(result).toEqual(nextState) + }) + }) + + describe('changeSQActiveRunQueryMode', () => { + it('should properly set mode', () => { + const state = { + ...initialState, + activeRunQueryMode: RunQueryMode.Raw + } + + const nextState = reducer(initialState, changeSQActiveRunQueryMode(RunQueryMode.Raw)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + search: { query: nextState }, + }) + + expect(searchAndQuerySelector(rootState)).toEqual(state) + }) + }) +}) diff --git a/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts b/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts index c9d9669382..e459ee3011 100644 --- a/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts +++ b/redisinsight/ui/src/slices/tests/workbench/wb-results.spec.ts @@ -12,7 +12,7 @@ import { apiService } from 'uiSrc/services' import { addErrorNotification } from 'uiSrc/slices/app/notifications' import { ClusterNodeRole, CommandExecutionStatus } from 'uiSrc/slices/interfaces/cli' import { EMPTY_COMMAND } from 'uiSrc/constants' -import { ResultsMode } from 'uiSrc/slices/interfaces' +import { CommandExecutionType, ResultsMode } from 'uiSrc/slices/interfaces' import { setDbIndexState } from 'uiSrc/slices/app/context' import { SendClusterCommandDto } from 'apiSrc/modules/cli/dto/cli.dto' import reducer, { @@ -67,7 +67,8 @@ describe('workbench results slice', () => { // Arrange const mockPayload = { commands: ['command', 'command2'], - commandId: '123' + commandId: '123', + executionType: CommandExecutionType.Workbench } const state = { ...initialState, diff --git a/redisinsight/ui/src/slices/workbench/wb-results.ts b/redisinsight/ui/src/slices/workbench/wb-results.ts index 7c1c570ebd..526f416a25 100644 --- a/redisinsight/ui/src/slices/workbench/wb-results.ts +++ b/redisinsight/ui/src/slices/workbench/wb-results.ts @@ -1,13 +1,14 @@ -import { createSlice } from '@reduxjs/toolkit' +import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { AxiosError } from 'axios' import { chunk, reverse } from 'lodash' import { apiService, localStorageService } from 'uiSrc/services' import { ApiEndpoints, BrowserStorageItem, CodeButtonParams, EMPTY_COMMAND } from 'uiSrc/constants' import { addErrorNotification } from 'uiSrc/slices/app/notifications' import { CliOutputFormatterType } from 'uiSrc/constants/cliOutput' -import { RunQueryMode, ResultsMode } from 'uiSrc/slices/interfaces/workbench' +import { RunQueryMode, ResultsMode, CommandExecutionType } from 'uiSrc/slices/interfaces/workbench' import { - getApiErrorMessage, getCommandsForExecution, + getApiErrorMessage, + getCommandsForExecution, getExecuteParams, getMultiCommands, getUrl, @@ -110,8 +111,13 @@ const workbenchResultsSlice = createSlice({ state.processing = false }, - sendWBCommand: (state, { payload: { commands, commandId } }: - { payload: { commands: string[], commandId: string } }) => { + sendWBCommand: ( + state, + { + payload: { commands, commandId } + }: + { payload: { commands: string[], commandId: string } } + ) => { let newItems = [ ...commands.map((command, i) => ({ command, @@ -242,7 +248,10 @@ export const workbenchResultsSelector = (state: RootState) => state.workbench.re export default workbenchResultsSlice.reducer // Asynchronous thunk actions -export function fetchWBHistoryAction(instanceId: string) { +export function fetchWBHistoryAction( + instanceId: string, + executionType = CommandExecutionType.Workbench, +) { return async (dispatch: AppDispatch) => { dispatch(loadWBHistory()) @@ -251,7 +260,8 @@ export function fetchWBHistoryAction(instanceId: string) { getUrl( instanceId, ApiEndpoints.WORKBENCH_COMMAND_EXECUTIONS, - ) + ), + { params: { type: executionType } } ) if (isStatusSuccessful(status)) { @@ -273,6 +283,7 @@ export function sendWBCommandAction({ mode = RunQueryMode.ASCII, resultsMode = ResultsMode.Default, commandId = `${Date.now()}`, + executionType = CommandExecutionType.Workbench, onSuccessAction, onFailAction, }: { @@ -281,6 +292,7 @@ export function sendWBCommandAction({ commandId?: string mode: RunQueryMode resultsMode?: ResultsMode + executionType?: CommandExecutionType onSuccessAction?: (multiCommands: string[]) => void onFailAction?: () => void }) { @@ -291,7 +303,7 @@ export function sendWBCommandAction({ dispatch(sendWBCommand({ commands: isGroupResults(resultsMode) ? [`${commands.length} - Command(s)`] : commands, - commandId + commandId, })) dispatch(setDbIndexState(true)) @@ -304,7 +316,8 @@ export function sendWBCommandAction({ { commands, mode, - resultsMode + resultsMode, + type: executionType } ) @@ -335,6 +348,7 @@ export function sendWBCommandClusterAction({ mode = RunQueryMode.ASCII, resultsMode = ResultsMode.Default, commandId = `${Date.now()}`, + executionType = CommandExecutionType.Workbench, onSuccessAction, onFailAction, }: { @@ -344,6 +358,7 @@ export function sendWBCommandClusterAction({ multiCommands?: string[] mode?: RunQueryMode, resultsMode?: ResultsMode + executionType?: CommandExecutionType onSuccessAction?: (multiCommands: string[]) => void onFailAction?: () => void }) { @@ -354,7 +369,7 @@ export function sendWBCommandClusterAction({ dispatch(sendWBCommand({ commands: isGroupResults(resultsMode) ? [`${commands.length} - Commands`] : commands, - commandId + commandId, })) const { data, status } = await apiService.post( @@ -367,6 +382,7 @@ export function sendWBCommandClusterAction({ commands, mode, resultsMode, + type: executionType, outputFormat: CliOutputFormatterType.Raw } ) @@ -462,6 +478,7 @@ export function deleteWBCommandAction( // Asynchronous thunk action export function clearWbResultsAction( + executionType = CommandExecutionType.Workbench, onSuccessAction?: () => void, onFailAction?: () => void, ) { @@ -477,6 +494,9 @@ export function clearWbResultsAction( id, ApiEndpoints.WORKBENCH_COMMAND_EXECUTIONS, ), + { + data: { type: executionType } + } ) if (isStatusSuccessful(status)) { diff --git a/redisinsight/ui/src/telemetry/pageViews.ts b/redisinsight/ui/src/telemetry/pageViews.ts index 9562baebc5..1d4960beb0 100644 --- a/redisinsight/ui/src/telemetry/pageViews.ts +++ b/redisinsight/ui/src/telemetry/pageViews.ts @@ -4,6 +4,7 @@ export enum TelemetryPageView { SETTINGS_PAGE = 'Settings', BROWSER_PAGE = 'Browser', WORKBENCH_PAGE = 'Workbench', + SEARCH_AND_QUERY_PAGE = 'Search and Query', SLOWLOG_PAGE = 'Slow Log', CLUSTER_DETAILS_PAGE = 'Overview', PUBSUB_PAGE = 'Pub/Sub', diff --git a/redisinsight/ui/src/templates/explore-panel/styles.module.scss b/redisinsight/ui/src/templates/explore-panel/styles.module.scss index 49cef4b420..9f5842eadc 100644 --- a/redisinsight/ui/src/templates/explore-panel/styles.module.scss +++ b/redisinsight/ui/src/templates/explore-panel/styles.module.scss @@ -4,7 +4,6 @@ height: 100%; width: 100%; position: relative; - overflow: hidden; } .mainPanel { diff --git a/redisinsight/ui/src/utils/monaco/monacoInterfaces.ts b/redisinsight/ui/src/utils/monaco/monacoInterfaces.ts index fac8c23166..1832854dc3 100644 --- a/redisinsight/ui/src/utils/monaco/monacoInterfaces.ts +++ b/redisinsight/ui/src/utils/monaco/monacoInterfaces.ts @@ -1,18 +1,26 @@ import { monaco as monacoEditor } from 'react-monaco-editor' -import { ICommand } from 'uiSrc/constants' +import { IRedisCommand } from 'uiSrc/constants' export interface IMonacoCommand { name: string - info?: ICommand + info?: IRedisCommand position?: monacoEditor.Position } export interface IMonacoQuery { name: string fullQuery: string - args?: string[] - info?: ICommand + args: [string[], string[]], + cursor: { + isCursorInQuotes: boolean, + prevCursorChar: string, + nextCursorChar: string, + argLeftOffset: number, + argRightOffset: number + } + allArgs: string[] + info?: IRedisCommand commandPosition: any position?: monacoEditor.Position - commandCursorPosition?: number + commandCursorPosition: number } diff --git a/redisinsight/ui/src/utils/monaco/monacoRedisMonarchTokensProvider.ts b/redisinsight/ui/src/utils/monaco/monacoRedisMonarchTokensProvider.ts index a4a1ce63bd..ee62d07d51 100644 --- a/redisinsight/ui/src/utils/monaco/monacoRedisMonarchTokensProvider.ts +++ b/redisinsight/ui/src/utils/monaco/monacoRedisMonarchTokensProvider.ts @@ -1,36 +1,33 @@ import { monaco as monacoEditor } from 'react-monaco-editor' +import { remove } from 'lodash' +import { ModuleCommandPrefix } from 'uiSrc/pages/workbench/constants' +import { IRedisCommand } from 'uiSrc/constants' const STRING_DOUBLE = 'string.double' -export const getRedisMonarchTokensProvider = (commands: string[]): monacoEditor.languages.IMonarchLanguage => ( - { +export const getRedisMonarchTokensProvider = (commands: IRedisCommand[]): monacoEditor.languages.IMonarchLanguage => { + const commandRedisCommands = [...commands] + const searchCommands = remove(commandRedisCommands, ({ token }) => token?.startsWith(ModuleCommandPrefix.RediSearch)) + const COMMON_COMMANDS_REGEX = `^\\s*(${commandRedisCommands.map(({ token }) => token).join('|')})\\b` + const SEARCH_COMMANDS_REGEX = `^\\s*(${searchCommands.map(({ token }) => token).join('|')})\\b` + + return { defaultToken: '', tokenPostfix: '.redis', ignoreCase: true, + includeLF: true, brackets: [ { open: '[', close: ']', token: 'delimiter.square' }, { open: '(', close: ')', token: 'delimiter.parenthesis' }, ], - keywords: commands, - operators: [ - // NOT SUPPORTED - ], - builtinFunctions: [ - // NOT SUPPORTED - ], - builtinVariables: [ - // NOT SUPPORTED - ], - pseudoColumns: [ - // NOT SUPPORTED - ], + keywords: commands.map(({ token }) => token), + operators: [], tokenizer: { root: [ + { include: '@startOfLine' }, { include: '@whitespace' }, - { include: '@pseudoColumns' }, { include: '@numbers' }, { include: '@strings' }, - { include: '@scopes' }, { include: '@keyword' }, [/[;,.]/, 'delimiter'], [/[()]/, '@brackets'], @@ -40,8 +37,6 @@ export const getRedisMonarchTokensProvider = (commands: string[]): monacoEditor. cases: { '@keywords': 'keyword', '@operators': 'operator', - '@builtinVariables': 'predefined', - '@builtinFunctions': 'predefined', '@default': 'identifier', }, }, @@ -49,26 +44,13 @@ export const getRedisMonarchTokensProvider = (commands: string[]): monacoEditor. [/[<>=!%&+\-*/|~^]/, 'operator'], ], keyword: [ - [ - `(${commands.join('|')})\\b`, - 'keyword' - ] + [COMMON_COMMANDS_REGEX, { token: 'keyword' }], + [SEARCH_COMMANDS_REGEX, { token: '@rematch', nextEmbedded: 'redisearch', next: '@endRedisearch' }], ], whitespace: [ [/\s+/, 'white'], [/\/\/.*$/, 'comment'], ], - pseudoColumns: [ - [ - /[$][A-Za-z_][\w@#$]*/, - { - cases: { - '@pseudoColumns': 'predefined', - '@default': 'identifier', - }, - }, - ], - ], numbers: [ [/0[xX][0-9a-fA-F]*/, 'number'], [/[$][+-]*\d*(\.\d*)?/, 'number'], @@ -79,18 +61,22 @@ export const getRedisMonarchTokensProvider = (commands: string[]): monacoEditor. [/"/, { token: STRING_DOUBLE, next: '@stringDouble' }], ], string: [ - [/[^']+/, 'string'], - [/''/, 'string'], + [/\\./, 'string'], [/'/, { token: 'string', next: '@pop' }], + [/[^\\']+/, 'string'], ], stringDouble: [ - [/[^"]+/, STRING_DOUBLE], - [/""/, STRING_DOUBLE], + [/\\./, STRING_DOUBLE], [/"/, { token: STRING_DOUBLE, next: '@pop' }], + [/[^\\"]+/, STRING_DOUBLE], ], - scopes: [ - // NOT SUPPORTED + // TODO: can be tokens or functions the same - need to think how to avoid wrong ending + endRedisearch: [ + [`^\\s*${COMMON_COMMANDS_REGEX}`, { token: '@rematch', next: '@root', nextEmbedded: '@pop', log: 'end' }], ], + startOfLine: [ + [/\n/, { next: '@root', token: '@pop' }], + ] }, } -) +} diff --git a/redisinsight/ui/src/utils/monaco/monacoUtils.ts b/redisinsight/ui/src/utils/monaco/monacoUtils.ts index dadcb5234d..9a98e4dc5b 100644 --- a/redisinsight/ui/src/utils/monaco/monacoUtils.ts +++ b/redisinsight/ui/src/utils/monaco/monacoUtils.ts @@ -1,7 +1,7 @@ import { monaco as monacoEditor } from 'react-monaco-editor' import { first, isEmpty, isUndefined, reject, without } from 'lodash' import { decode } from 'html-entities' -import { ICommands, ICommand } from 'uiSrc/constants' +import { ICommand, ICommands } from 'uiSrc/constants' import { generateArgsForInsertText, generateArgsNames, @@ -96,20 +96,121 @@ export const findCommandEarlier = ( return null } - const command:IMonacoCommand = { + return { position, name: matchedCommand, info: commandsSpec[matchedCommand] } +} + +export const isCompositeArgument = (arg: string, prevArg?: string, args: string[] = []) => + args.includes([prevArg?.toUpperCase(), arg?.toUpperCase()].join(' ')) + +export const splitQueryByArgs = ( + query: string, + position: number = 0, + compositeArgs: string[] = [] +) => { + const args: [string[], string[]] = [[], []] + let arg = '' + let inQuotes = false + let escapeNextChar = false + let quoteChar = '' + let isCursorInQuotes = false + let lastArg = '' + let argLeftOffset = 0 + let argRightOffset = 0 + + const pushToProperTuple = (isAfterOffset: boolean, arg: string) => { + lastArg = arg + isAfterOffset ? args[1].push(arg) : args[0].push(arg) + } + + const updateLastArgument = (isAfterOffset: boolean, arg: string) => { + const argsBySide = args[isAfterOffset ? 1 : 0] + argsBySide[argsBySide.length - 1] = `${argsBySide[argsBySide.length - 1]} ${arg}` + } + + const updateArgOffsets = (left: number, right: number) => { + argLeftOffset = left + argRightOffset = right + } + + for (let i = 0; i < query.length; i++) { + const char = query[i] + const isAfterOffset = i >= position + (inQuotes ? -1 : 0) + + if (escapeNextChar) { + arg += char + escapeNextChar = !quoteChar + } else if (char === '\\') { + escapeNextChar = true + } else if (inQuotes) { + if (char === quoteChar) { + inQuotes = false + const argWithChar = arg + char + + if (isAfterOffset && !argLeftOffset) { + updateArgOffsets(i - arg.length, i + 1) + } + + if (isCompositeArgument(argWithChar, lastArg, compositeArgs)) { + updateLastArgument(isAfterOffset, argWithChar) + } else { + pushToProperTuple(isAfterOffset, argWithChar) + } + + arg = '' + } else { + arg += char + } + } else if (char === '"' || char === "'") { + inQuotes = true + quoteChar = char + arg += char + } else if (char === ' ' || char === '\n') { + if (arg.length > 0) { + if (isAfterOffset && !argLeftOffset) { + updateArgOffsets(i - arg.length, i) + } + + if (isCompositeArgument(arg, lastArg, compositeArgs)) { + updateLastArgument(isAfterOffset, arg) + } else { + pushToProperTuple(isAfterOffset, arg) + } + + arg = '' + } + } else { + arg += char + } + + if (i === position - 1) isCursorInQuotes = inQuotes + } + + if (arg.length > 0) { + if (!argLeftOffset) updateArgOffsets(query.length - arg.length, query.length) + pushToProperTuple(true, arg) + } + + const cursor = { + isCursorInQuotes, + prevCursorChar: query[position - 1]?.trim() || '', + nextCursorChar: query[position]?.trim() || '', + argLeftOffset, + argRightOffset + } - return command + return { args, cursor } } export const findCompleteQuery = ( model: monacoEditor.editor.ITextModel, position: monacoEditor.Position, commandsSpec: ICommands = {}, - commandsArray: string[] = [] + commandsArray: string[] = [], + compositeArgs: string[] = [] ): Nullable => { const { lineNumber } = position let commandName = '' @@ -137,13 +238,6 @@ export const findCompleteQuery = ( fullQuery = `\n${fullQuery}` } - const matchedCommand = commandsArray - .find((command) => commandName?.trim().toUpperCase().startsWith(command.toUpperCase())) - - if (isUndefined(matchedCommand)) { - return null - } - const commandCursorPosition = fullQuery.length // find args in the next lines const linesCount = model.getLineCount() @@ -166,9 +260,19 @@ export const findCompleteQuery = ( fullQuery += lineAfterPosition } - const args = fullQuery - .replace(matchedCommand, '') - .match(/(?:[^\s"']+|["][^"]*["]|['][^']*['])+/g) + const { args, cursor } = splitQueryByArgs( + fullQuery, + commandCursorPosition, + compositeArgs, + ) + + const [[firstQueryArg]] = args + const matchedCommand = commandsArray + .find((command) => firstQueryArg?.toUpperCase() === command.toUpperCase()) + + if (isUndefined(matchedCommand)) { + return null + } return { position, @@ -176,6 +280,8 @@ export const findCompleteQuery = ( commandCursorPosition, fullQuery, args, + cursor, + allArgs: args.flat(), name: matchedCommand, info: commandsSpec[matchedCommand] } as IMonacoQuery diff --git a/redisinsight/ui/src/utils/monaco/monarchTokens/redisearchTokensSubRedis.ts b/redisinsight/ui/src/utils/monaco/monarchTokens/redisearchTokensSubRedis.ts new file mode 100644 index 0000000000..01e02f9680 --- /dev/null +++ b/redisinsight/ui/src/utils/monaco/monarchTokens/redisearchTokensSubRedis.ts @@ -0,0 +1,130 @@ +import { monaco as monacoEditor } from 'react-monaco-editor' +import { remove } from 'lodash' +import { IRedisCommandTree } from 'uiSrc/constants' +import { + generateKeywords, + generateTokens, + generateTokensWithFunctions, + getBlockTokens, + isIndexAfterKeyword, + isQueryAfterIndex +} from 'uiSrc/utils/monaco/redisearch/utils' +import { generateQuery } from 'uiSrc/utils/monaco/monarchTokens/redisearchTokensTemplates' + +const STRING_DOUBLE = 'string.double' + +export const getRediSearchSubRedisMonarchTokensProvider = ( + commands: IRedisCommandTree[], +): monacoEditor.languages.IMonarchLanguage => { + const withoutIndexSuggestions = [...commands] + const withNextIndexSuggestions = remove(withoutIndexSuggestions, isIndexAfterKeyword) + const withNextQueryIndexSuggestions = remove([...withNextIndexSuggestions], isQueryAfterIndex) + + const generateTokensForCommands = () => { + let commandTokens: any = {} + + commands.forEach((command) => { + const isIndexAfterCommand = isIndexAfterKeyword(command) + const argTokens = generateTokens(command) + const tokenName = command.token?.replace(/(\.| )/g, '_') + const blockTokens = getBlockTokens(tokenName, argTokens?.pureTokens) + + if (blockTokens.length) { + commandTokens[`argument.block.${tokenName}`] = blockTokens + } + + if (isIndexAfterCommand) { + commandTokens = { + ...commandTokens, + ...generateTokensWithFunctions(tokenName, argTokens?.tokensWithQueryAfter) + } + } + }) + + return commandTokens + } + + const tokens = generateTokensForCommands() + + const includeTokens = () => { + const tokensToInclude = Object.keys(tokens).filter((name) => name.startsWith('argument.block')) + return tokensToInclude.map((include) => ({ include: `@${include}` })) + } + + return ( + { + defaultToken: '', + tokenPostfix: '.redisearch', + includeLF: true, + ignoreCase: true, + brackets: [ + { open: '[', close: ']', token: 'delimiter.square' }, + { open: '(', close: ')', token: 'delimiter.parenthesis' }, + ], + keywords: [], + tokenizer: { + root: [ + { include: '@startOfLine' }, + { include: '@keywords' }, + ...includeTokens(), + { include: '@fields' }, + { include: '@whitespace' }, + { include: '@numbers' }, + { include: '@strings' }, + [/[;,.]/, 'delimiter'], + [/[()]/, '@brackets'], + [/[<>=!%&+\-*/|~^]/, 'operator'], + [/[\w@#$.]+/, 'identifier'] + ], + keywords: [ + [`^\\s*(${generateKeywords(withNextQueryIndexSuggestions).join('|')})\\b`, { token: 'keyword', next: '@index.query' }], + [`^\\s*(${generateKeywords(withNextIndexSuggestions).join('|')})\\b`, { token: 'keyword', next: '@index' }], + [`^\\s*(${generateKeywords(withoutIndexSuggestions).join('|')})\\b`, { token: 'keyword', next: '@root' }], + ], + ...tokens, + ...generateQuery(), + index: [ + [/"([^"\\]|\\.)*"/, { token: 'index', next: '@root' }], + [/'([^'\\]|\\.)*'/, { token: 'index', next: '@root' }], + [/[\w:]+/, { token: 'index', next: '@root' }], + { include: 'root' } // Fallback to the root state if nothing matches + ], + 'index.query': [ + [/"([^"\\]|\\.)*"/, { token: 'index', next: '@query' }], + [/'([^'\\]|\\.)*'/, { token: 'index', next: '@query' }], + [/[\w:]+/, { token: 'index', next: '@query' }], + { include: 'root' } // Fallback to the root state if nothing matches + ], + fields: [ + [/@\w+/, { token: 'field' }] + ], + whitespace: [ + [/\s+/, 'white'], + [/\/\/.*$/, 'comment'], + ], + numbers: [ + [/0[xX][0-9a-fA-F]*/, 'number'], + [/[$][+-]*\d*(\.\d*)?/, 'number'], + [/((\d+(\.\d*)?)|(\.\d+))([eE][-+]?\d+)?/, 'number'], + ], + strings: [ + [/'/, { token: 'string', next: '@string' }], + [/"/, { token: STRING_DOUBLE, next: '@stringDouble' }], + ], + string: [ + [/\\./, 'string'], + [/'/, { token: 'string', next: '@pop' }], + [/[^\\']+/, 'string'], + ], + stringDouble: [ + [/\\./, STRING_DOUBLE], + [/"/, { token: STRING_DOUBLE, next: '@pop' }], + [/[^\\"]+/, STRING_DOUBLE], + ], + startOfLine: [ + [/\n/, { next: '@keywords', token: '@pop' }] + ] + }, + } + ) +} diff --git a/redisinsight/ui/src/utils/monaco/monarchTokens/redisearchTokensTemplates.ts b/redisinsight/ui/src/utils/monaco/monarchTokens/redisearchTokensTemplates.ts new file mode 100644 index 0000000000..0ebc7c1d36 --- /dev/null +++ b/redisinsight/ui/src/utils/monaco/monarchTokens/redisearchTokensTemplates.ts @@ -0,0 +1,104 @@ +import { languages } from 'monaco-editor' +import { curryRight } from 'lodash' +import { Maybe } from 'uiSrc/utils' +import { IRedisCommand } from 'uiSrc/constants' + +const appendToken = (token: string, name: Maybe) => (name ? `${token}.${name}` : token) +export const generateQuery = ( + argToken?: IRedisCommand, + args?: IRedisCommand[] +): { [name: string]: languages.IMonarchLanguageRule[] } => { + const curriedAppendToken = curryRight(appendToken) + const appendTokenName = curriedAppendToken(argToken?.token) + + const getFunctionsTokens = (tokenName: string): languages.IMonarchLanguageRule => (args?.length ? [ + `(${args?.map(({ token }) => token).join('|')})\\b`, { token: 'function', next: appendTokenName(tokenName) } + ] : [/_/, '']) + + return { + [appendTokenName('query')]: [ + [/"/, { token: appendTokenName('query'), next: appendTokenName('@query.inside.double') }], + [/'/, { token: appendTokenName('query'), next: appendTokenName('@query.inside.single') }], + [/[a-zA-Z_]\w*/, { token: appendTokenName('query'), next: '@root' }], + { include: 'root' } // Fallback to the root state if nothing matches + ], + [appendTokenName('query.inside.double')]: [ + [/@/, { token: 'field', next: appendTokenName('@field.inside.double') }], + [/\\"/, { token: 'query', next: appendTokenName('@query.inside.double') }], + [/==|!=|<=|>=|<|>/, { token: 'query.operator' }], + [/&&|\|\|/, { token: 'query.operator' }], + getFunctionsTokens('@function.inside.double'), + [/"/, { token: appendTokenName('query'), next: '@root' }], + [/./, { token: appendTokenName('query'), next: appendTokenName('@query.inside.double') }], + { include: '@query' } // Fallback to the root state if nothing matches + ], + [appendTokenName('query.inside.single')]: [ + [/@/, { token: 'field', next: appendTokenName('@field.inside.single') }], + [/\\'/, { token: appendTokenName('query'), next: appendTokenName('query.inside.single') }], + [/==|!=|<=|>=|<|>/, { token: 'query.operator' }], + [/&&|\|\|/, { token: 'query.operator' }], + getFunctionsTokens('@function.inside.single'), + [/'/, { token: appendTokenName('query'), next: '@root' }], + [/./, { token: appendTokenName('query'), next: appendTokenName('@query.inside.single') }], + { include: appendTokenName('@query') } // Fallback to the root state if nothing matches + ], + [appendTokenName('field.inside.double')]: [ + [/\w+/, { token: 'field', next: appendTokenName('@query.inside.double') }], + [/\s+/, { token: '@rematch', next: appendTokenName('@query.inside.double') }], + [/"/, { token: appendTokenName('query'), next: '@root' }], + { include: appendTokenName('@query') } // Fallback to the root state if nothing matches + ], + [appendTokenName('field.inside.single')]: [ + [/\w+/, { token: 'field', next: appendTokenName('@query.inside.single') }], + [/\s+/, { token: '@rematch', next: appendTokenName('@query.inside.single') }], + [/'/, { token: appendTokenName('query'), next: '@root' }], + + { include: appendTokenName('@query') } + ], + [appendTokenName('function.inside.double')]: [ + [/\s+/, 'white'], // Handle whitespace + [/\(/, { token: 'delimiter.parenthesis', next: appendTokenName('@function.args.double') }], + { include: appendTokenName('@query') } + ], + [appendTokenName('function.inside.double')]: [ + [/\s+/, 'white'], // Handle whitespace + [/\(/, { token: 'delimiter.parenthesis', next: appendTokenName('@function.args.double') }], + { include: appendTokenName('@query') } + ], + [appendTokenName('function.args.double')]: [ + [/\)/, { token: 'delimiter.parenthesis', next: appendTokenName('@query.inside.double') }], + [/,/, 'delimiter.comma'], // Match commas between arguments + getFunctionsTokens('@function.inside.double'), + [/[a-zA-Z_]\w*/, { token: 'parameter' }], // Highlight parameters + [/\s+/, 'white'], // Handle whitespace + [/@\w+/, { token: 'field' }], + + // // Handle strings with escaped quotes + [/\\"/, 'parameter'], // Match escaped double quote + [/\\'/, 'parameter'], // Match escaped single quote + [/'/, 'parameter'], // Match escaped single quote + + { include: appendTokenName('@query') } // Fallback to root state + ], + [appendTokenName('function.inside.single')]: [ + [/\s+/, 'white'], // Handle whitespace + [/\(/, { token: 'delimiter.parenthesis', next: appendTokenName('@function.args.single') }], + { include: appendTokenName('@query') } + ], + [appendTokenName('function.args.single')]: [ + [/\)/, { token: 'delimiter.parenthesis', next: appendTokenName('@query.inside.single') }], + [/,/, 'delimiter.comma'], // Match commas between arguments + getFunctionsTokens('@function.inside.single'), + [/[a-zA-Z_]\w*/, { token: 'parameter' }], // Highlight parameters + [/\s+/, 'white'], // Handle whitespace + [/@\w+/, { token: 'field' }], + + [/"/, 'parameter'], // Match escaped double quote + // // Handle strings with escaped quotes + [/\\"/, 'parameter'], // Match escaped double quote + [/\\'/, 'parameter'], // Match escaped single quote + + { include: appendTokenName('@query') } // Fallback to root state + ] + } +} diff --git a/redisinsight/ui/src/utils/monaco/redisearch/utils.ts b/redisinsight/ui/src/utils/monaco/redisearch/utils.ts new file mode 100644 index 0000000000..36d05a7c01 --- /dev/null +++ b/redisinsight/ui/src/utils/monaco/redisearch/utils.ts @@ -0,0 +1,149 @@ +import { isNumber, remove } from 'lodash' +import { languages } from 'monaco-editor' +import { Maybe, Nullable } from 'uiSrc/utils' +import { generateQuery } from 'uiSrc/utils/monaco/monarchTokens/redisearchTokensTemplates' +import { ICommandTokenType, IRedisCommand } from 'uiSrc/constants' +import { DefinedArgumentName } from 'uiSrc/pages/workbench/constants' + +export const generateKeywords = (commands: IRedisCommand[]) => commands.map(({ name }) => name) +export const generateTokens = (command?: IRedisCommand): Nullable<{ + pureTokens: Array> + tokensWithQueryAfter: Array> +}> => { + if (!command) return null + const pureTokens: Array> = [] + const tokensWithQueryAfter: Array> = [] + + function processArguments(args: IRedisCommand[], level = 0) { + if (!pureTokens[level]) pureTokens[level] = [] + if (!tokensWithQueryAfter[level]) tokensWithQueryAfter[level] = [] + + args.forEach((arg) => { + if (arg.token) pureTokens[level].push(arg) + + if (arg.type === ICommandTokenType.Block && arg.arguments) { + const blockToken = arg.arguments[0] + const nextArgs = arg.arguments + const isArgHasOwnSyntax = arg.arguments[0].expression && !!arg.arguments[0].arguments?.length + + if (blockToken?.token) { + if (isArgHasOwnSyntax) { + tokensWithQueryAfter[level].push({ + token: blockToken, + arguments: arg.arguments[0].arguments as IRedisCommand[] + }) + } else { + pureTokens[level].push(blockToken) + } + } + + processArguments(blockToken ? nextArgs.slice(1, nextArgs.length) : nextArgs, level + 1) + } + + if (arg.type === ICommandTokenType.OneOf && arg.arguments) { + arg.arguments.forEach((choice) => { + if (choice?.token) pureTokens[level].push(choice) + }) + } + }) + } + + if (command.arguments) { + processArguments(command.arguments, 0) + } + + return { pureTokens, tokensWithQueryAfter } +} + +export const isIndexAfterKeyword = (command?: IRedisCommand) => { + if (!command) return false + + const index = command.arguments?.findIndex(({ name }) => name === DefinedArgumentName.index) + return isNumber(index) && index === 0 +} + +export const isQueryAfterIndex = (command?: IRedisCommand) => { + if (!command) return false + + const index = command.arguments?.findIndex(({ name }) => name === DefinedArgumentName.index) + return isNumber(index) && index > -1 ? command.arguments?.[index + 1]?.name === DefinedArgumentName.query : false +} + +export const appendTokenWithQuery = ( + args: Array<{ token: IRedisCommand, arguments: IRedisCommand[] }>, + level: number +): languages.IMonarchLanguageRule[] => + args.map(({ token }) => [`(${token.token})\\b`, { token: `argument.block.${level}`, next: `@query.${token.token}` }]) + +export const appendQueryWithNextFunctions = ( + tokens: Array<{ token: IRedisCommand, arguments: IRedisCommand[] }> +): { + [name: string]: languages.IMonarchLanguageRule[] +} => { + let result: { [name: string]: languages.IMonarchLanguageRule[] } = {} + + tokens.forEach(({ token, arguments: args }) => { + result = { + ...result, + ...generateQuery(token, args) + } + }) + + return result +} + +export const generateTokensWithFunctions = ( + name: string = '', + tokens?: Array> +): { + [name: string]: languages.IMonarchLanguageRule[] +} => { + if (!tokens) return {} + + const actualTokens = tokens.filter((tokens) => tokens.length) + + if (!actualTokens.length) return {} + + return { + [`argument.block.${name}.withFunctions`]: [ + ...actualTokens + .map((tokens, lvl) => appendTokenWithQuery(tokens, lvl)) + .flat() + ], + ...appendQueryWithNextFunctions(actualTokens.flat()) + } +} + +export const getBlockTokens = ( + name: string = '', + pureTokens: Maybe[]> +): languages.IMonarchLanguageRule[] => { + if (!pureTokens) return [] + + const getLeveledToken = ( + tokens: IRedisCommand[], + lvl: number + ): languages.IMonarchLanguageRule[] => { + const result: languages.IMonarchLanguageRule[] = [] + const restTokens = [...tokens] + const tokensWithNextExpression = remove(restTokens, (({ expression }) => expression)) + + if (tokensWithNextExpression.length) { + result.push([ + `(${tokensWithNextExpression.map(({ token }) => token).join('|')})\\b`, + { + token: `argument.block.${lvl}.${name}`, + next: '@query' + }, + ]) + } + + if (restTokens.length) { + result.push([`(${restTokens.map(({ token }) => token).join('|')})\\b`, { token: `argument.block.${lvl}.${name}`, next: '@root' }]) + } + + return result + } + + return pureTokens.map((tokens, lvl) => getLeveledToken(tokens, lvl)).flat() +} diff --git a/redisinsight/ui/src/utils/routerWithSubRoutes.tsx b/redisinsight/ui/src/utils/routerWithSubRoutes.tsx index a934bfcf2e..690ad23ff3 100644 --- a/redisinsight/ui/src/utils/routerWithSubRoutes.tsx +++ b/redisinsight/ui/src/utils/routerWithSubRoutes.tsx @@ -1,13 +1,12 @@ import React from 'react' import { Redirect, Route } from 'react-router-dom' import { useSelector } from 'react-redux' -import { isUndefined } from 'lodash' import { userSettingsSelector } from 'uiSrc/slices/user/user-settings' import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' import { IRoute, FeatureFlags } from 'uiSrc/constants' const PrivateRoute = (route: IRoute) => { - const { path, exact, routes, featureFlag } = route + const { path, exact, routes, featureFlag, redirect } = route const { [featureFlag as FeatureFlags]: feature, } = useSelector(appFeatureFlagsFeaturesSelector) @@ -17,15 +16,26 @@ const PrivateRoute = (route: IRoute) => { - haveToAcceptAgreements || feature?.flag === false + render={(props) => { + if (redirect) { + return ( + + ) + } + + return haveToAcceptAgreements || feature?.flag === false ? : ( // pass the sub-routes down to keep nesting // @ts-ignore ) - } + }} /> ) } diff --git a/redisinsight/ui/src/utils/test-utils.tsx b/redisinsight/ui/src/utils/test-utils.tsx index ae0d35d545..f6464e8005 100644 --- a/redisinsight/ui/src/utils/test-utils.tsx +++ b/redisinsight/ui/src/utils/test-utils.tsx @@ -38,6 +38,7 @@ import { initialState as initialStateUserSettings } from 'uiSrc/slices/user/user import { initialState as initialStateWBResults } from 'uiSrc/slices/workbench/wb-results' import { initialState as initialStateWBETutorials } from 'uiSrc/slices/workbench/wb-tutorials' import { initialState as initialStateWBECustomTutorials } from 'uiSrc/slices/workbench/wb-custom-tutorials' +import { initialState as initialStateSearchAndQuery } from 'uiSrc/slices/search/searchAndQuery' import { initialState as initialStateCreateRedisButtons } from 'uiSrc/slices/content/create-redis-buttons' import { initialState as initialStateGuideLinks } from 'uiSrc/slices/content/guide-links' import { initialState as initialStateSlowLog } from 'uiSrc/slices/analytics/slowlog' @@ -110,6 +111,9 @@ const initialStateDefault: RootState = { tutorials: cloneDeep(initialStateWBETutorials), customTutorials: cloneDeep(initialStateWBECustomTutorials), }, + search: { + query: cloneDeep(initialStateSearchAndQuery), + }, content: { createRedisButtons: cloneDeep(initialStateCreateRedisButtons), guideLinks: cloneDeep(initialStateGuideLinks), diff --git a/redisinsight/ui/src/utils/tests/monaco/cyber/monarchTokensProvider.spec.ts b/redisinsight/ui/src/utils/tests/monaco/cyber/monarchTokensProvider.spec.ts index caaedee9a4..313fd10721 100644 --- a/redisinsight/ui/src/utils/tests/monaco/cyber/monarchTokensProvider.spec.ts +++ b/redisinsight/ui/src/utils/tests/monaco/cyber/monarchTokensProvider.spec.ts @@ -1,6 +1,8 @@ import { getCypherMonarchTokensProvider } from 'uiSrc/utils/monaco/monarchTokens/cypherTokens' import { getJmespathMonarchTokensProvider } from 'uiSrc/utils/monaco/monarchTokens/jmespathTokens' import { getSqliteFunctionsMonarchTokensProvider } from 'uiSrc/utils/monaco/monarchTokens/sqliteFunctionsTokens' +import { getRediSearchSubRedisMonarchTokensProvider } from 'uiSrc/utils/monaco/monarchTokens/redisearchTokensSubRedis' +import { MOCKED_REDIS_COMMANDS } from 'uiSrc/mocks/data/mocked_redis_commands' describe('getCypherMonarchTokensProvider', () => { it('should be truthy', () => { @@ -19,3 +21,18 @@ describe('getSqliteFunctionsMonarchTokensProvider', () => { expect(getSqliteFunctionsMonarchTokensProvider([])).toBeTruthy() }) }) + +describe('getRediSearchMonarchTokensProvider', () => { + it('should be truthy', () => { + expect(getRediSearchSubRedisMonarchTokensProvider([])).toBeTruthy() + }) + + it('should be truthy with command', () => { + const commands = Object.keys(MOCKED_REDIS_COMMANDS) + .map((key) => ({ + ...MOCKED_REDIS_COMMANDS[key], + name: key, + })) + expect(getRediSearchSubRedisMonarchTokensProvider(commands)).toBeTruthy() + }) +}) diff --git a/redisinsight/ui/src/utils/tests/monaco/monacoUtils.spec.ts b/redisinsight/ui/src/utils/tests/monaco/monacoUtils.spec.ts index 89c21ed025..89ddbf8245 100644 --- a/redisinsight/ui/src/utils/tests/monaco/monacoUtils.spec.ts +++ b/redisinsight/ui/src/utils/tests/monaco/monacoUtils.spec.ts @@ -4,7 +4,9 @@ import { splitMonacoValuePerLines, findArgIndexByCursor, isParamsLine, - getMonacoLines, getCommandsFromQuery + getMonacoLines, + getCommandsFromQuery, + splitQueryByArgs } from 'uiSrc/utils' describe('removeMonacoComments', () => { @@ -209,3 +211,81 @@ describe('getCommandsFromQuery', () => { } ) }) + +const splitQueryByArgsTests: Array<{ + input: [string, number?] + result: any +}> = [ + { + input: ['FT.SEARCH "idx:bicycle" "" WITHSORTKEYS'], + result: { + args: [[], ['FT.SEARCH', '"idx:bicycle"', '""', 'WITHSORTKEYS']], + cursor: { + argLeftOffset: 10, + argRightOffset: 23, + isCursorInQuotes: false, + nextCursorChar: 'F', + prevCursorChar: '' + } + } + }, + { + input: ['FT.SEARCH "idx:bicycle" "" WITHSORTKEYS', 17], + result: { + args: [['FT.SEARCH'], ['"idx:bicycle"', '""', 'WITHSORTKEYS']], + cursor: { + argLeftOffset: 10, + argRightOffset: 23, + isCursorInQuotes: true, + nextCursorChar: 'c', + prevCursorChar: 'i' + } + } + }, + { + input: ['FT.SEARCH "idx:bicycle" "" WITHSORTKEYS', 39], + result: { + args: [['FT.SEARCH', '"idx:bicycle"', '""'], ['WITHSORTKEYS']], + cursor: { + argLeftOffset: 27, + argRightOffset: 39, + isCursorInQuotes: false, + nextCursorChar: '', + prevCursorChar: 'S' + } + } + }, + { + input: ['FT.SEARCH "idx:bicycle" "" WITHSORTKEYS ', 40], + result: { + args: [['FT.SEARCH', '"idx:bicycle"', '""', 'WITHSORTKEYS'], []], + cursor: { + argLeftOffset: 0, + argRightOffset: 0, + isCursorInQuotes: false, + nextCursorChar: '', + prevCursorChar: '' + } + } + }, + { + input: ['FT.SEARCH "idx:bicycle \\" \\"" "" WITHSORTKEYS ', 46], + result: { + args: [['FT.SEARCH', '"idx:bicycle " ""', '""', 'WITHSORTKEYS'], []], + cursor: { + argLeftOffset: 0, + argRightOffset: 0, + isCursorInQuotes: false, + nextCursorChar: '', + prevCursorChar: '' + } + } + } +] + +describe('splitQueryByArgs', () => { + it.each(splitQueryByArgsTests)('should return for %input proper result', ({ input, result }) => { + const testResult = splitQueryByArgs(...input) + expect(testResult).toEqual(result) + }) +}) diff --git a/redisinsight/ui/src/utils/tests/routing.spec.ts b/redisinsight/ui/src/utils/tests/routing.spec.ts index 6d4e88443f..adc3a1a3b3 100644 --- a/redisinsight/ui/src/utils/tests/routing.spec.ts +++ b/redisinsight/ui/src/utils/tests/routing.spec.ts @@ -16,6 +16,8 @@ const getRedirectionPageTests = [ { input: ['settings'], expected: '/settings' }, { input: ['workbench', databaseId], expected: '/1/workbench' }, { input: ['/workbench', databaseId], expected: '/1/workbench' }, + { input: ['browser', databaseId], expected: '/1/browser' }, + { input: ['/browser', databaseId], expected: '/1/browser' }, { input: ['/analytics/slowlog', databaseId], expected: '/1/analytics/slowlog' }, { input: ['/analytics/slowlog'], expected: null }, { input: ['/analytics', databaseId], expected: '/1/analytics' }, diff --git a/redisinsight/ui/src/utils/transformers/redisCommands.ts b/redisinsight/ui/src/utils/transformers/redisCommands.ts new file mode 100644 index 0000000000..3dd444ff5a --- /dev/null +++ b/redisinsight/ui/src/utils/transformers/redisCommands.ts @@ -0,0 +1,12 @@ +import { ICommand, ICommands, ICommandTokenType } from 'uiSrc/constants' + +export const mergeRedisCommandsSpecs = ( + initialSpec: ICommands, + updatedSpec: ICommands +): ICommand[] => + Object.keys(initialSpec).map((name) => ({ + name, + token: name, + type: ICommandTokenType.Block, + ...(name in updatedSpec ? updatedSpec[name] : (initialSpec[name] || {})), + })) diff --git a/tests/e2e/.env b/tests/e2e/.env index 509b8704a1..9242dec68e 100644 --- a/tests/e2e/.env +++ b/tests/e2e/.env @@ -1,5 +1,6 @@ COMMON_URL=https://app:5540 API_URL=https://app:5540/api +BUILD_TYPE=DOCKER_ON_PREMISE OSS_SENTINEL_PASSWORD=password RI_NOTIFICATION_UPDATE_URL=https://s3.amazonaws.com/redisinsight.test/public/tests/e2e/notifications.json RI_NOTIFICATION_SYNC_INTERVAL=30000 diff --git a/tests/e2e/desktop.runner.ci.ts b/tests/e2e/desktop.runner.ci.ts new file mode 100644 index 0000000000..dfb4bcf3f2 --- /dev/null +++ b/tests/e2e/desktop.runner.ci.ts @@ -0,0 +1,53 @@ +import testcafe from 'testcafe'; + +(async(): Promise => { + await testcafe('localhost') + .then(t => { + return t + .createRunner() + .compilerOptions({ + 'typescript': { + configPath: 'tsconfig.testcafe.json', + experimentalDecorators: true + } }) + .src((process.env.TEST_FILES || 'tests/electron/**/*.e2e.ts').split('\n')) + .browsers(['electron']) + .screenshots({ + path: './report/screenshots/', + takeOnFails: true, + pathPattern: '${USERAGENT}/${DATE}_${TIME}/${FIXTURE}_${TEST}_${FILE_INDEX}.png' + }) + .reporter([ + 'spec', + { + name: 'xunit', + output: './results/results.xml' + }, + { + name: 'json', + output: './results/e2e.results.json' + }, + { + name: 'html', + output: './report/report.html' + } + ]) + .run({ + skipJsErrors: true, + browserInitTimeout: 60000, + selectorTimeout: 5000, + assertionTimeout: 5000, + speed: 1, + quarantineMode: { successThreshold: 1, attemptLimit: 3 }, + pageRequestTimeout: 8000, + disableMultipleWindows: true + }); + }) + .then((failedCount) => { + process.exit(failedCount); + }) + .catch((e) => { + console.error(e); + process.exit(1); + }); +})(); diff --git a/tests/e2e/desktop.runner.ts b/tests/e2e/desktop.runner.ts index dfb4bcf3f2..7afe4f0daf 100644 --- a/tests/e2e/desktop.runner.ts +++ b/tests/e2e/desktop.runner.ts @@ -38,7 +38,6 @@ import testcafe from 'testcafe'; selectorTimeout: 5000, assertionTimeout: 5000, speed: 1, - quarantineMode: { successThreshold: 1, attemptLimit: 3 }, pageRequestTimeout: 8000, disableMultipleWindows: true }); diff --git a/tests/e2e/local.web.docker-compose.yml b/tests/e2e/local.web.docker-compose.yml index b093f1e149..788a88304e 100644 --- a/tests/e2e/local.web.docker-compose.yml +++ b/tests/e2e/local.web.docker-compose.yml @@ -13,7 +13,7 @@ services: - rihomedir:/root/.redis-insight - tmp:/tmp - ./remote:/root/remote - - ./rdi:/root/rdi + # - ./rdi:/root/rdi env_file: - ./.env entrypoint: [ @@ -43,9 +43,10 @@ services: env_file: - ./.env environment: - RI_ENCRYPTION_KEY: $E2E_RI_ENCRYPTION_KEY + RI_ENCRYPTION_KEY: $RI_ENCRYPTION_KEY RI_SERVER_TLS_CERT: $RI_SERVER_TLS_CERT RI_SERVER_TLS_KEY: $RI_SERVER_TLS_KEY + BUILD_TYPE: DOCKER_ON_PREMISE volumes: - ./rihomedir:/data - tmp:/tmp diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 4fa6148c5e..f8ab7bbc05 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -14,12 +14,12 @@ "redis:last": "docker run --name redis-last-version -p 7777:6379 -d redislabs/redismod", "start:app": "cross-env yarn start:api", "test:chrome": "testcafe --compiler-options typescript.configPath=tsconfig.testcafe.json --cache --disable-multiple-windows --concurrency 1 chrome tests/ -r html:./report/report.html,spec -e -s takeOnFails=true,path=report/screenshots/,pathPattern=${OS}_${BROWSER}/${DATE}_${TIME}/${FIXTURE}_${TEST}_${FILE_INDEX}.png", - "test:chrome:ci": "ts-node ./web.runner.ts", + "test:chrome:ci": "ts-node ./web.runner.ci.ts", "test": "yarn test:chrome", "lint": "eslint . --ext .ts,.js,.tsx,.jsx", - "test:desktop:ci": "ts-node ./desktop.runner.ts", + "test:desktop:ci": "ts-node ./desktop.runner.ci.ts", "test:desktop:ci:win": "ts-node ./desktop.runner.win.ts", - "test:desktop": "testcafe electron tests/ --compiler-options typescript.configPath=tsconfig.testcafe.json --browser-init-timeout 180000 -e -r html:./report/desktop-report.html,spec -s takeOnFails=true,path=report/screenshots/,pathPattern=${OS}_${BROWSER}/${DATE}_${TIME}/${FIXTURE}_${TEST}_${FILE_INDEX}.png" + "test:desktop": "ts-node ./desktop.runner.ts" }, "keywords": [], "author": "", diff --git a/tests/e2e/pageObjects/components/insights-panel.ts b/tests/e2e/pageObjects/components/insights-panel.ts index 1e34c9eacc..e3ccf480fb 100644 --- a/tests/e2e/pageObjects/components/insights-panel.ts +++ b/tests/e2e/pageObjects/components/insights-panel.ts @@ -7,7 +7,7 @@ export class InsightsPanel { // CONTAINERS sidePanel = Selector('[data-testid=side-panels-insights]'); closeButton = Selector('[data-testid=close-insights-btn]'); - activeTab = Selector('[class*=euiTab-isSelected]'); + activeTab = this.sidePanel.find('[class*="euiTab-isSelected"]'); recommendationsTab = Selector('[data-testid=recommendations-tab]'); exploreTab = Selector('[data-testid=explore-tab]'); diff --git a/tests/e2e/pageObjects/components/monaco-editor.ts b/tests/e2e/pageObjects/components/monaco-editor.ts index 642920213f..5bfff1b56d 100644 --- a/tests/e2e/pageObjects/components/monaco-editor.ts +++ b/tests/e2e/pageObjects/components/monaco-editor.ts @@ -10,6 +10,7 @@ export class MonacoEditor { monacoHintWithArguments = Selector('[widgetid="editor.widget.parameterHintsWidget"]'); monacoCommandIndicator = Selector('div.monaco-glyph-run-command'); monacoWidget = Selector('[data-testid=monaco-widget]'); + monacoSuggestWidget = Selector('.suggest-widget'); nonRedisEditorResizeBottom = Selector('.t_resize-bottom'); nonRedisEditorResizeTop = Selector('.t_resize-top'); @@ -22,7 +23,7 @@ export class MonacoEditor { async sendTextToMonaco(input: Selector, command: string, clean = true): Promise { await t.click(input); - if(clean) { + if (clean) { await t // remove text since replace doesn't work here .pressKey('ctrl+a') @@ -38,10 +39,10 @@ export class MonacoEditor { * @param depth level of depth of the object */ async insertTextByLines(input: Selector, lines: string[], depth: number): Promise { - for(let i = 0; i < lines.length; i++) { + for (let i = 0; i < lines.length; i++) { const line = lines[i]; - for(let j = 0; j < depth; j++) { + for (let j = 0; j < depth; j++) { await t.pressKey('shift+tab'); } @@ -60,4 +61,22 @@ export class MonacoEditor { const textAreaMonaco = Selector('[class^=view-lines ]'); return (await textAreaMonaco.textContent).replace(/\s+/g, ' '); } + + /** + * Get suggestions as ordered array from monaco from the beginning + * @param suggestions number of elements to get + */ + async getSuggestionsArrayFromMonaco(suggestions: number): Promise { + const textArray: string[] = []; + const suggestionElements = this.monacoSuggestion; + + for (let i = 0; i < suggestions; i++) { + const suggestionItem = suggestionElements.nth(i); + if (await suggestionItem.exists) { + textArray.push(await suggestionItem.textContent); + } + } + + return textArray; + } } diff --git a/tests/e2e/pageObjects/components/navigation-panel.ts b/tests/e2e/pageObjects/components/navigation-panel.ts index bf772e7442..b4edad4b30 100644 --- a/tests/e2e/pageObjects/components/navigation-panel.ts +++ b/tests/e2e/pageObjects/components/navigation-panel.ts @@ -7,6 +7,4 @@ export class NavigationPanel extends BaseNavigationPanel{ analysisPageButton = Selector('[data-testid=analytics-page-btn]'); browserButton = Selector('[data-testid=browser-page-btn]'); pubSubButton = Selector('[data-testid=pub-sub-page-btn]'); - - triggeredFunctionsButton = Selector('[data-testid=triggered-functions-page-btn]'); } diff --git a/tests/e2e/pageObjects/workbench-page.ts b/tests/e2e/pageObjects/workbench-page.ts index 111ac3d4e4..4666e9c03d 100644 --- a/tests/e2e/pageObjects/workbench-page.ts +++ b/tests/e2e/pageObjects/workbench-page.ts @@ -5,19 +5,20 @@ export class WorkbenchPage extends InstancePage { //CSS selectors cssSelectorPaginationButtonPrevious = '[data-test-subj=pagination-button-previous]'; cssSelectorPaginationButtonNext = '[data-test-subj=pagination-button-next]'; - cssReRunCommandButton = '[data-testid=re-run-command]'; - cssDeleteCommandButton = '[data-testid=delete-command]'; - cssTableViewTypeOption = '[data-testid=view-type-selected-Plugin-redisearch__redisearch]'; - cssClientListViewTypeOption = '[data-testid=view-type-selected-Plugin-client-list__clients-list]'; - cssJsonViewTypeOption = '[data-testid=view-type-selected-Plugin-client-list__json-view]'; cssMonacoCommandPaletteLine = '[aria-label="Command Palette"]'; - cssQueryTextResult = '[data-testid=query-cli-result]'; cssWorkbenchCommandInHistory = '[data-testid=wb-command]'; - cssQueryTableResult = '[data-testid^=query-table-result-]'; queryGraphContainer = '[data-testid=query-graph-container]'; cssQueryCardCommand = '[data-testid=query-card-command]'; - cssCommandExecutionDateTime = '[data-testid=command-execution-date-time]'; cssRowInVirtualizedTable = '[data-testid^=row-]'; + cssClientListViewTypeOption = '[data-testid=view-type-selected-Plugin-client-list__clients-list]'; + cssQueryCardContainer = '[data-testid^="query-card-container-"]'; + cssQueryTextResult = '[data-testid=query-cli-result]'; + cssReRunCommandButton = '[data-testid=re-run-command]'; + cssDeleteCommandButton = '[data-testid=delete-command]'; + cssJsonViewTypeOption = '[data-testid=view-type-selected-Plugin-client-list__json-view]'; + cssQueryTableResult = '[data-testid^=query-table-result-]'; + cssTableViewTypeOption = '[data-testid=view-type-selected-Plugin-redisearch__redisearch]'; + cssCommandExecutionDateTime = '[data-testid=command-execution-date-time]'; //------------------------------------------------------------------------------------------- //DECLARATION OF SELECTORS //*Declare all elements/components of the relevant page. @@ -26,49 +27,38 @@ export class WorkbenchPage extends InstancePage { //------------------------------------------------------------------------------------------- //BUTTON submitCommandButton = Selector('[data-testid=btn-submit]'); + queryInput = Selector('[data-testid=query-input-container]'); + queryInputForText = Selector('[data-testid=query-input-container] .view-lines'); resizeButtonForScriptingAndResults = Selector('[data-test-subj=resize-btn-scripting-area-and-results]'); - collapsePreselectAreaButton = Selector('[data-testid=collapse-enablement-area]'); - expandPreselectAreaButton = Selector('[data-testid=expand-enablement-area]'); paginationButtonPrevious = Selector(this.cssSelectorPaginationButtonPrevious); paginationButtonNext = Selector(this.cssSelectorPaginationButtonNext); - preselectIndexInformation = Selector('[data-testid="preselect-Additional index information"]'); - preselectExactSearch = Selector('[data-testid="preselect-Exact text search"]'); - preselectCreateHashIndex = Selector('[data-testid="preselect-Create a hash index"]'); - preselectGroupBy = Selector('[data-testid*=preselect-Group]'); preselectButtons = Selector('[data-testid^=preselect-]'); - copyBtn = Selector('[data-testid^=copy-btn-]'); - reRunCommandButton = Selector('[data-testid=re-run-command]'); preselectManual = Selector('[data-testid=preselect-Manual]'); - fullScreenButton = Selector('[data-testid=toggle-full-screen]'); queryCardNoModuleButton = Selector('[data-testid=query-card-no-module-button] a'); - rawModeBtn = Selector('[data-testid="btn-change-mode"]'); - closeEnablementPage = Selector('[data-testid=enablement-area__page-close]'); groupMode = Selector('[data-testid=btn-change-group-mode]'); - copyCommand = Selector('[data-testid=copy-command]'); + runButtonToolTip = Selector('[data-testid=run-query-tooltip]'); + loadedCommand = Selector('[class=euiLoadingContent__singleLine]'); + runButtonSpinner = Selector('[data-testid=loading-spinner]'); + commandExecutionDateAndTime = Selector('[data-testid=command-execution-date-time]'); + executionCommandTime = Selector('[data-testid=command-execution-time-value]'); + executionCommandIcon = Selector('[data-testid=command-execution-time-icon]'); + executedCommandTitle = Selector('[data-testid=query-card-tooltip-anchor]', { timeout: 500 }); + queryResult = Selector('[data-testid=query-common-result]'); + queryInputScriptArea = Selector('[data-testid=query-input-container] .view-line'); + parametersAnchor = Selector('[data-testid=parameters-anchor]'); clearResultsBtn = Selector('[data-testid=clear-history-btn]'); + //ICONS noCommandHistoryIcon = Selector('[data-testid=wb_no-results__icon]'); - parametersAnchor = Selector('[data-testid=parameters-anchor]'); groupModeIcon = Selector('[data-testid=group-mode-tooltip]'); - rawModeIcon = Selector('[data-testid=raw-mode-tooltip]'); silentModeIcon = Selector('[data-testid=silent-mode-tooltip]'); - //LINKS - //TEXT INPUTS (also referred to as 'Text fields') - queryInput = Selector('[data-testid=query-input-container]'); - iframe = Selector('[data-testid=pluginIframe]'); + rawModeIcon = Selector('[data-testid=raw-mode-tooltip]'); + //TEXT ELEMENTS - queryPluginResult = Selector('[data-testid=query-plugin-result]'); responseInfo = Selector('[class="responseInfo"]'); parsedRedisReply = Selector('[class="parsedRedisReply"]'); - scriptsLines = Selector('[data-testid=query-input-container] .view-lines'); - queryCardContainer = Selector('[data-testid^=query-card-container]'); - queryCardCommand = Selector('[data-testid=query-card-command]'); - queryTableResult = Selector('[data-testid^=query-table-result-]'); - queryJsonResult = Selector('[data-testid=json-view]'); mainEditorArea = Selector('[data-testid=main-input-container-area]'); - queryTextResult = Selector(this.cssQueryTextResult); queryColumns = Selector('[data-testid*=query-column-]'); - queryInputScriptArea = Selector('[data-testid=query-input-container] .view-line'); noCommandHistorySection = Selector('[data-testid=wb_no-results]'); noCommandHistoryTitle = Selector('[data-testid=wb_no-results__title]'); noCommandHistoryText = Selector('[data-testid=wb_no-results__summary]'); @@ -76,51 +66,37 @@ export class WorkbenchPage extends InstancePage { commandExecutionResult = Selector('[data-testid=welcome-page-title]'); commandExecutionResultFailed = Selector('[data-testid=cli-output-response-fail]'); chartViewTypeOptionSelected = Selector('[data-testid=view-type-selected-Plugin-redistimeseries__redistimeseries-chart]'); - runButtonToolTip = Selector('[data-testid=run-query-tooltip]'); - loadedCommand = Selector('[class=euiLoadingContent__singleLine]'); - runButtonSpinner = Selector('[data-testid=loading-spinner]'); - commandExecutionDateAndTime = Selector('[data-testid=command-execution-date-time]'); - executionCommandTime = Selector('[data-testid=command-execution-time-value]'); - executionCommandIcon = Selector('[data-testid=command-execution-time-icon]'); - executedCommandTitle = Selector('[data-testid=query-card-tooltip-anchor]', { timeout: 500 }); - queryResult = Selector('[data-testid=query-common-result]'); - //OPTIONS - selectViewType = Selector('[data-testid=select-view-type]'); - textViewTypeOption = Selector('[data-test-subj^=view-type-option-Text]'); + scriptsLines = Selector('[data-testid=query-input-container] .view-lines'); + queryJsonResult = Selector('[data-testid=json-view]'); jsonStringViewTypeOption = Selector('[data-test-subj=view-type-option-Plugin-client-list__json-string-view]'); - tableViewTypeOption = Selector('[data-test-subj^=view-type-option-Plugin]'); + graphViewTypeOption = Selector('[data-test-subj^=view-type-option-Plugin-graph]'); typeSelectedClientsList = Selector('[data-testid=view-type-selected-Plugin-client-list__clients-list]'); viewTypeOptionClientList = Selector('[data-test-subj=view-type-option-Plugin-client-list__clients-list]'); viewTypeOptionsText = Selector('[data-test-subj=view-type-option-Text-default__Text]'); - /** - * Get card container by command - * @param command The command - */ - async getCardContainerByCommand(command: string): Promise { - return this.queryCardCommand.withExactText(command).parent('[data-testid^="query-card-container-"]'); - } - // Select Text view option in Workbench results - async selectViewTypeText(): Promise { - await t - .click(this.selectViewType) - .click(this.textViewTypeOption); - } + // History containers + queryCardCommand = Selector('[data-testid=query-card-command]'); + fullScreenButton = Selector('[data-testid=toggle-full-screen]'); + rawModeBtn = Selector('[data-testid="btn-change-mode"]'); + queryCardContainer = Selector('[data-testid^=query-card-container]'); + reRunCommandButton = Selector('[data-testid=re-run-command]'); + copyBtn = Selector('[data-testid^=copy-btn-]'); + copyCommand = Selector('[data-testid=copy-command]'); - // Select Json view option in Workbench results - async selectViewTypeJson(): Promise { - await t - .click(this.selectViewType) - .click(this.jsonStringViewTypeOption); - } + //OPTIONS + selectViewType = Selector('[data-testid=select-view-type]'); + queryTableResult = Selector('[data-testid^=query-table-result-]'); + textViewTypeOption = Selector('[data-test-subj^=view-type-option-Text]'); + tableViewTypeOption = Selector('[data-test-subj^=view-type-option-Plugin]'); + + iframe = Selector('[data-testid=pluginIframe]'); + + queryTextResult = Selector(this.cssQueryTextResult); + + getTutorialLinkLocator = (tutorialName: string): Selector => + Selector(`[data-testid=query-tutorials-link_${tutorialName}]`, { timeout: 1000 } ); - // Select Table view option in Workbench results - async selectViewTypeTable(): Promise { - await t - .click(this.selectViewType) - .doubleClick(this.tableViewTypeOption); - } // Select view option in Workbench results async selectViewTypeGraph(): Promise { @@ -129,19 +105,6 @@ export class WorkbenchPage extends InstancePage { .click(this.graphViewTypeOption); } - /** - * Send a command in Workbench - * @param command The command - * @param speed The speed in seconds. Default is 1 - * @param paste - */ - async sendCommandInWorkbench(command: string, speed = 1, paste = true): Promise { - await t - .click(this.queryInput) - .typeText(this.queryInput, command, { replace: true, speed, paste }) - .click(this.submitCommandButton); - } - /** * Send multiple commands in Workbench * @param commands The commands @@ -150,6 +113,7 @@ export class WorkbenchPage extends InstancePage { for (const command of commands) { await t .typeText(this.queryInput, command, { replace: false, speed: 1, paste: true }) + .pressKey('esc') .pressKey('enter'); } await t.click(this.submitCommandButton); @@ -165,6 +129,33 @@ export class WorkbenchPage extends InstancePage { } } + // Select Json view option in Workbench results + async selectViewTypeJson(): Promise { + await t + .click(this.selectViewType) + .click(this.jsonStringViewTypeOption); + } + /** + * Get card container by command + * @param command The command + */ + async getCardContainerByCommand(command: string): Promise { + return this.queryCardCommand.withExactText(command).parent(this.cssQueryCardContainer); + } + + /** + * Send a command in Workbench + * @param command The command + * @param speed The speed in seconds. Default is 1 + * @param paste + */ + async sendCommandInWorkbench(command: string, speed = 1, paste = true): Promise { + await t + .click(this.queryInput) + .typeText(this.queryInput, command, { replace: true, speed, paste }) + .click(this.submitCommandButton); + } + /** * Check the last command and result in workbench * @param command The command to check @@ -180,27 +171,33 @@ export class WorkbenchPage extends InstancePage { await t.expect(actualCommandResult).contains(result, 'Actual command result is not equal to executed'); } - /** - * Get selector with tutorial name - * @param tutorialName name of the uploaded tutorial - */ - getAccordionButtonWithName(tutorialName: string): Selector { - return Selector(`[data-testid=accordion-button-${tutorialName}]`); + // Select Text view option in Workbench results + async selectViewTypeText(): Promise { + await t + .click(this.selectViewType) + .click(this.textViewTypeOption); } - /** - * Get internal tutorial link with .md name - * @param internalLink name of the .md file - */ - getInternalLinkWithManifest(internalLink: string): Selector { - return Selector(`[data-testid="internal-link-${internalLink}.md"]`); + // Select Table view option in Workbench results + async selectViewTypeTable(): Promise { + await t + .click(this.selectViewType) + .doubleClick(this.tableViewTypeOption); } /** - * Get internal tutorial link without .md name - * @param internalLink name of the label + * Select query using autosuggest + * @param query Value of query */ - getInternalLinkWithoutManifest(internalLink: string): Selector { - return Selector(`[data-testid="internal-link-${internalLink}"]`); + async selectFieldUsingAutosuggest(value: string): Promise { + await t.wait(200); + await t.typeText(this.queryInput, '@', { replace: false }); + await t.expect(this.MonacoEditor.monacoSuggestion.visible).ok('Suggestions not displayed'); + await t.typeText(this.queryInput, value, { replace: false }); + // Select query option into autosuggest and go out of quotes + await t.pressKey('tab'); + await t.pressKey('tab'); + await t.pressKey('right'); + await t.pressKey('space'); } } diff --git a/tests/e2e/tests/electron/critical-path/files-auto-update/enablement-area-autoupdate.e2e.ts b/tests/e2e/tests/electron/critical-path/files-auto-update/enablement-area-autoupdate.e2e.ts index e8baf805ab..499d5ddc8b 100644 --- a/tests/e2e/tests/electron/critical-path/files-auto-update/enablement-area-autoupdate.e2e.ts +++ b/tests/e2e/tests/electron/critical-path/files-auto-update/enablement-area-autoupdate.e2e.ts @@ -1,9 +1,7 @@ -// import { join } from 'path'; -// import * as os from 'os'; import * as fs from 'fs'; import editJsonFile from 'edit-json-file'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig, workingDirectory } from '../../../../helpers/conf'; import { ExploreTabs, rte } from '../../../../helpers/constants'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; @@ -12,6 +10,7 @@ const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); if (fs.existsSync(workingDirectory)) { @@ -46,7 +45,7 @@ if (fs.existsSync(workingDirectory)) { const tutorialsTimestampFileNew = editJsonFile(tutorialsTimestampPath); // Open Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); // Check Enablement area and validate that removed file is existed in Guides await workbenchPage.NavigationHeader.togglePanel(true); diff --git a/tests/e2e/tests/electron/critical-path/monitor/monitor.e2e.ts b/tests/e2e/tests/electron/critical-path/monitor/monitor.e2e.ts index b79adef488..2cdf3c82f9 100644 --- a/tests/e2e/tests/electron/critical-path/monitor/monitor.e2e.ts +++ b/tests/e2e/tests/electron/critical-path/monitor/monitor.e2e.ts @@ -65,7 +65,7 @@ test('Verify that user can see the list of all commands from all clients ran for await browserPage.addHashKey(keyName); await browserPage.Profiler.checkCommandInMonitorResults(browser_command); //Open Workbench page to create new client - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); //Send command in Workbench await workbenchPage.sendCommandInWorkbench(workbench_command); //Check that command from Workbench is displayed in monitor diff --git a/tests/e2e/tests/electron/critical-path/workbench/index-schema.e2e.ts b/tests/e2e/tests/electron/critical-path/workbench/index-schema.e2e.ts index a9dc8ae6bd..8e6065c04b 100644 --- a/tests/e2e/tests/electron/critical-path/workbench/index-schema.e2e.ts +++ b/tests/e2e/tests/electron/critical-path/workbench/index-schema.e2e.ts @@ -1,6 +1,6 @@ import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneRedisearch } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; @@ -9,6 +9,7 @@ const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); let indexName = Common.generateWord(5); @@ -18,7 +19,7 @@ fixture `Index Schema at Workbench` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneRedisearch); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async t => { // Drop index, documents and database diff --git a/tests/e2e/tests/electron/critical-path/workbench/json-workbench.e2e.ts b/tests/e2e/tests/electron/critical-path/workbench/json-workbench.e2e.ts index cfac929e85..cd3c0bea8a 100644 --- a/tests/e2e/tests/electron/critical-path/workbench/json-workbench.e2e.ts +++ b/tests/e2e/tests/electron/critical-path/workbench/json-workbench.e2e.ts @@ -1,6 +1,6 @@ import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneRedisearch } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; @@ -9,6 +9,7 @@ const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); let indexName = Common.generateWord(5); @@ -18,7 +19,7 @@ fixture `JSON verifications at Workbench` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneRedisearch); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async t => { // Drop index, documents and database diff --git a/tests/e2e/tests/electron/regression/workbench/redis-stack-commands.e2e.ts b/tests/e2e/tests/electron/regression/workbench/redis-stack-commands.e2e.ts index 4b46bb1f85..4e7ee6c634 100644 --- a/tests/e2e/tests/electron/regression/workbench/redis-stack-commands.e2e.ts +++ b/tests/e2e/tests/electron/regression/workbench/redis-stack-commands.e2e.ts @@ -1,6 +1,6 @@ import { t } from 'testcafe'; import { DatabaseHelper } from '../../../../helpers/database'; -import { WorkbenchPage, MyRedisDatabasePage } from '../../../../pageObjects'; +import { WorkbenchPage, MyRedisDatabasePage, BrowserPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { ExploreTabs, rte } from '../../../../helpers/constants'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; @@ -9,6 +9,7 @@ const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); const keyNameGraph = 'bikes_graph'; @@ -17,7 +18,7 @@ fixture `Redis Stack command in Workbench` .page(commonUrl) .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async() => { // Drop key and database diff --git a/tests/e2e/tests/electron/regression/workbench/workbench-re-cluster.e2e.ts b/tests/e2e/tests/electron/regression/workbench/workbench-re-cluster.e2e.ts index afb39c0ce2..985e935ad0 100644 --- a/tests/e2e/tests/electron/regression/workbench/workbench-re-cluster.e2e.ts +++ b/tests/e2e/tests/electron/regression/workbench/workbench-re-cluster.e2e.ts @@ -1,12 +1,13 @@ import { t } from 'testcafe'; import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, redisEnterpriseClusterConfig } from '../../../../helpers/conf'; const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); +const browserPage = new BrowserPage(); const commandForSend1 = 'info'; const commandForSend2 = 'FT._LIST'; @@ -17,7 +18,7 @@ const verifyCommandsInWorkbench = async(): Promise => { 'FT.SEARCH idx *' ]; - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); // Send commands await workbenchPage.sendCommandInWorkbench(commandForSend1); await workbenchPage.sendCommandInWorkbench(commandForSend2); diff --git a/tests/e2e/tests/web/critical-path/browser/bulk-delete.e2e.ts b/tests/e2e/tests/web/critical-path/browser/bulk-delete.e2e.ts index a6c48ced1b..b077e82397 100644 --- a/tests/e2e/tests/web/critical-path/browser/bulk-delete.e2e.ts +++ b/tests/e2e/tests/web/critical-path/browser/bulk-delete.e2e.ts @@ -99,9 +99,9 @@ test await t.click(browserPage.bulkActionsButton); await browserPage.BulkActions.startBulkDelete(); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); // Go to Browser Page - await t.click(myRedisDatabasePage.NavigationPanel.browserButton); + await t.click(browserPage.NavigationPanel.browserButton); await t.expect(browserPage.BulkActions.bulkStatusInProgress.exists).ok('Progress value not displayed', { timeout: 5000 }); }); test diff --git a/tests/e2e/tests/web/critical-path/browser/search-capabilities.e2e.ts b/tests/e2e/tests/web/critical-path/browser/search-capabilities.e2e.ts index 15810f2507..27db258c3e 100644 --- a/tests/e2e/tests/web/critical-path/browser/search-capabilities.e2e.ts +++ b/tests/e2e/tests/web/critical-path/browser/search-capabilities.e2e.ts @@ -280,9 +280,8 @@ test await t.click(browserPage.getKeySelectorByName(keyName)); // Verify that Redisearch context (inputs, key selected, scroll, key details) saved after switching between pages - await t - .click(myRedisDatabasePage.NavigationPanel.workbenchButton) - .click(myRedisDatabasePage.NavigationPanel.browserButton); + await t.click(browserPage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.browserButton); await verifyContext(); // Verify that Redisearch context saved when switching between browser/tree view diff --git a/tests/e2e/tests/web/critical-path/database-overview/database-index.e2e.ts b/tests/e2e/tests/web/critical-path/database-overview/database-index.e2e.ts index 419e1199f9..3a701cea09 100644 --- a/tests/e2e/tests/web/critical-path/database-overview/database-index.e2e.ts +++ b/tests/e2e/tests/web/critical-path/database-overview/database-index.e2e.ts @@ -105,13 +105,13 @@ test('Switching between indexed databases', async t => { await verifySearchFilterValue('Hall School'); // Open Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); await workbenchPage.sendCommandInWorkbench(command); // Verify that user can see the database index before the command name executed in Workbench await workbenchPage.checkWorkbenchCommandResult(`[db1] ${command}`, '8'); // Open Browser page - await t.click(myRedisDatabasePage.NavigationPanel.browserButton); + await t.click(browserPage.NavigationPanel.browserButton); // Clear filter await t.click(browserPage.clearFilterButton); // Verify that data changed for indexed db on Workbench page (on Search capability page) diff --git a/tests/e2e/tests/web/critical-path/memory-efficiency/memory-efficiency.e2e.ts b/tests/e2e/tests/web/critical-path/memory-efficiency/memory-efficiency.e2e.ts index 26b988f70d..0f3079beb6 100644 --- a/tests/e2e/tests/web/critical-path/memory-efficiency/memory-efficiency.e2e.ts +++ b/tests/e2e/tests/web/critical-path/memory-efficiency/memory-efficiency.e2e.ts @@ -278,7 +278,7 @@ test } // Verify that specific report is saved as context await t.click(memoryEfficiencyPage.reportItem.nth(3)); - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(myRedisDatabasePage.NavigationPanel.browserButton); await t.click(myRedisDatabasePage.NavigationPanel.analysisPageButton); await t.expect(memoryEfficiencyPage.donutTotalKeys.sibling(1).textContent).eql(numberOfKeys[2], 'Context is not saved'); // Verify that user can see top keys table saved as context diff --git a/tests/e2e/tests/web/critical-path/monitor/monitor.e2e.ts b/tests/e2e/tests/web/critical-path/monitor/monitor.e2e.ts index b79adef488..766baed5d1 100644 --- a/tests/e2e/tests/web/critical-path/monitor/monitor.e2e.ts +++ b/tests/e2e/tests/web/critical-path/monitor/monitor.e2e.ts @@ -10,7 +10,6 @@ import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; import { APIKeyRequests } from '../../../../helpers/api/api-keys'; -const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); const browserPage = new BrowserPage(); const databaseHelper = new DatabaseHelper(); @@ -65,7 +64,7 @@ test('Verify that user can see the list of all commands from all clients ran for await browserPage.addHashKey(keyName); await browserPage.Profiler.checkCommandInMonitorResults(browser_command); //Open Workbench page to create new client - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); //Send command in Workbench await workbenchPage.sendCommandInWorkbench(workbench_command); //Check that command from Workbench is displayed in monitor diff --git a/tests/e2e/tests/web/critical-path/pub-sub/subscribe-unsubscribe.e2e.ts b/tests/e2e/tests/web/critical-path/pub-sub/subscribe-unsubscribe.e2e.ts index f913c1b60f..450e698f1b 100644 --- a/tests/e2e/tests/web/critical-path/pub-sub/subscribe-unsubscribe.e2e.ts +++ b/tests/e2e/tests/web/critical-path/pub-sub/subscribe-unsubscribe.e2e.ts @@ -1,5 +1,5 @@ import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, PubSubPage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, MyRedisDatabasePage, PubSubPage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig, ossStandaloneV5Config } from '../../../../helpers/conf'; import { rte } from '../../../../helpers/constants'; import { verifyMessageDisplayingInPubSub } from '../../../../helpers/pub-sub'; @@ -10,6 +10,7 @@ const pubSubPage = new PubSubPage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); fixture `Subscribe/Unsubscribe from a channel` .meta({ rte: rte.standalone, type: 'critical_path' }) @@ -120,7 +121,7 @@ test('Verify that user can see a internal link to pubsub window under word “Pu await t.expect(pubSubPage.pubSubPageContainer.exists).ok('Pubsub page is opened'); // Verify that user can see a custom message when he tries to run SUBSCRIBE command in Workbench: “Use Pub/Sub tool to subscribe to channels.” - await t.click(pubSubPage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); await workbenchPage.sendCommandInWorkbench(commandSecond); await t.expect(await workbenchPage.queryResult.textContent).eql('Use Pub/Sub tool to subscribe to channels.', 'Message is not displayed', { timeout: 10000 }); diff --git a/tests/e2e/tests/web/critical-path/workbench/autocomplete.e2e.ts b/tests/e2e/tests/web/critical-path/workbench/autocomplete.e2e.ts index ca1076997c..6a50246372 100644 --- a/tests/e2e/tests/web/critical-path/workbench/autocomplete.e2e.ts +++ b/tests/e2e/tests/web/critical-path/workbench/autocomplete.e2e.ts @@ -1,6 +1,6 @@ import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; @@ -8,6 +8,7 @@ const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); fixture `Autocomplete for entered commands` .meta({ type: 'critical_path', rte: rte.standalone }) @@ -15,7 +16,7 @@ fixture `Autocomplete for entered commands` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async() => { // Delete database @@ -35,15 +36,15 @@ test('Verify that when user have selected a command (via “Enter” from the li await t.pressKey('enter'); const script = await workbenchPage.queryInputScriptArea.textContent; // Verify that user can select a command from the list with auto-suggestions when type in any character in the Editor - await t.expect(script.replace(/\s/g, ' ')).contains('LINDEX ', 'Result of sent command exists'); + await t.expect(script.replace(/\s/g, ' ')).eql('LINDEX ', 'Result of sent command not exists'); - // Check the required arguments inserted + // Check the required arguments suggested for (const argument of commandArguments) { - await t.expect(script).contains(argument, `The required argument ${argument} is inserted`); + await t.expect(workbenchPage.MonacoEditor.monacoHintWithArguments.textContent).contains(argument, `The required argument ${argument} is not suggested`); } }); test('Verify that user can change any required argument inserted', async t => { - const command = 'HMGET'; + const command = 'HMGE'; const commandArguments = [ 'key', 'field' @@ -53,7 +54,7 @@ test('Verify that user can change any required argument inserted', async t => { 'secondArgument' ]; - // Select command via Enter + // Select HMGET command via Enter await t.typeText(workbenchPage.queryInput, command, { replace: true }); await t.pressKey('enter'); // Change required arguments @@ -67,15 +68,18 @@ test('Verify that user can change any required argument inserted', async t => { await t.expect(scriptAfterEdit).notContains(commandArguments[0], `The argument ${commandArguments[0]} is not changed`); }); test('Verify that the list of optional arguments will not be inserted with autocomplete', async t => { - const command = 'ZPOPMAX'; + const command = 'ZPOPMA'; const commandRequiredArgument = 'key'; - const commandOptionalArgument = 'count'; + const commandOptionalArgument = '[count]'; - // Select command via Enter + // Select ZPOPMAX command via Enter await t.typeText(workbenchPage.queryInput, command, { replace: true }); await t.pressKey('enter'); // Verify the command arguments inserted const script = await workbenchPage.queryInputScriptArea.textContent; - await t.expect(script).contains(commandRequiredArgument, 'The required argument is inserted'); - await t.expect(script).notContains(commandOptionalArgument, 'The optional argument is not inserted'); + await t.expect(script.replace(/\s/g, ' ')).eql('ZPOPMAX ', 'Result of sent command not exists'); + + // Check the required and optional arguments suggested + await t.expect(workbenchPage.MonacoEditor.monacoHintWithArguments.textContent).contains(commandRequiredArgument, `The required argument is not suggested`); + await t.expect(workbenchPage.MonacoEditor.monacoHintWithArguments.textContent).contains(commandOptionalArgument, `The optional argument is not suggested in blocks`); }); diff --git a/tests/e2e/tests/web/critical-path/workbench/command-results.e2e.ts b/tests/e2e/tests/web/critical-path/workbench/command-results.e2e.ts index ac4e43621b..a7ccd3a7f1 100644 --- a/tests/e2e/tests/web/critical-path/workbench/command-results.e2e.ts +++ b/tests/e2e/tests/web/critical-path/workbench/command-results.e2e.ts @@ -1,6 +1,6 @@ import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; @@ -9,6 +9,7 @@ const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); const commandForSend1 = 'info'; const commandForSend2 = 'FT._LIST'; @@ -20,7 +21,7 @@ fixture `Command results at Workbench` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async t => { await t.switchToMainWindow(); @@ -174,7 +175,8 @@ test await t .click(workbenchPage.queryInput) .pressKey('ctrl+a') - .pressKey('delete'); + .pressKey('delete') + .pressKey('esc'); // Verify the quick access to command history by up button for (const command of commands.reverse()) { await t.pressKey('up'); diff --git a/tests/e2e/tests/web/critical-path/workbench/context.e2e.ts b/tests/e2e/tests/web/critical-path/workbench/context.e2e.ts index ba1892f705..5a638cb0e9 100644 --- a/tests/e2e/tests/web/critical-path/workbench/context.e2e.ts +++ b/tests/e2e/tests/web/critical-path/workbench/context.e2e.ts @@ -1,6 +1,6 @@ import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; @@ -9,6 +9,7 @@ const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); const speed = 0.4; let indexName = Common.generateWord(5); @@ -19,7 +20,7 @@ fixture `Workbench Context` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async() => { // Drop index, documents and database @@ -31,8 +32,8 @@ test('Verify that user can see saved input in Editor when navigates away to any const command = `FT.CREATE ${indexName} ON HASH PREFIX 1 product: SCHEMA name TEXT`; // Enter the command in the Workbench editor and navigate to Browser await t.typeText(workbenchPage.queryInput, command, { replace: true, speed: speed }); - await t.click(myRedisDatabasePage.NavigationPanel.browserButton); + await t.click(browserPage.NavigationPanel.browserButton); // Return back to Workbench and check input in editor - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); await t.expect((await workbenchPage.queryInputScriptArea.textContent).replace(/\s/g, ' ')).eql(command, 'Input in Editor is saved'); }); diff --git a/tests/e2e/tests/web/critical-path/workbench/cypher.e2e.ts b/tests/e2e/tests/web/critical-path/workbench/cypher.e2e.ts index 6e9a3ce63f..b572a05ed8 100644 --- a/tests/e2e/tests/web/critical-path/workbench/cypher.e2e.ts +++ b/tests/e2e/tests/web/critical-path/workbench/cypher.e2e.ts @@ -1,6 +1,6 @@ import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; @@ -8,6 +8,7 @@ const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); fixture `Cypher syntax at Workbench` .meta({ type: 'critical_path', rte: rte.standalone }) @@ -15,7 +16,7 @@ fixture `Cypher syntax at Workbench` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async() => { // Drop database diff --git a/tests/e2e/tests/web/critical-path/workbench/default-scripts-area.e2e.ts b/tests/e2e/tests/web/critical-path/workbench/default-scripts-area.e2e.ts index 1e6cc57cdd..89deed9dc1 100644 --- a/tests/e2e/tests/web/critical-path/workbench/default-scripts-area.e2e.ts +++ b/tests/e2e/tests/web/critical-path/workbench/default-scripts-area.e2e.ts @@ -1,6 +1,6 @@ import { Chance } from 'chance'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; import { ExploreTabs, rte } from '../../../../helpers/constants'; import { commonUrl, ossStandaloneRedisearch } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; @@ -10,6 +10,8 @@ const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); + const chance = new Chance(); const telemetry = new Telemetry(); @@ -29,7 +31,7 @@ fixture `Default scripts area at Workbench` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneRedisearch); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async t => { // Drop index, documents and database diff --git a/tests/e2e/tests/web/critical-path/workbench/no-indexes-suggestions.e2e.ts b/tests/e2e/tests/web/critical-path/workbench/no-indexes-suggestions.e2e.ts new file mode 100644 index 0000000000..b0a8d1cb23 --- /dev/null +++ b/tests/e2e/tests/web/critical-path/workbench/no-indexes-suggestions.e2e.ts @@ -0,0 +1,35 @@ +import { DatabaseHelper } from '../../../../helpers/database'; +import { BrowserPage, WorkbenchPage } from '../../../../pageObjects'; +import { rte } from '../../../../helpers/constants'; +import { commonUrl, ossClusterConfig } from '../../../../helpers/conf'; +import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; + +const browserPage = new BrowserPage(); +const databaseHelper = new DatabaseHelper(); +const databaseAPIRequests = new DatabaseAPIRequests(); +const workbenchPage = new WorkbenchPage(); + +fixture `Search and Query Raw mode` + .meta({ type: 'critical_path', rte: rte.standalone }) + .page(commonUrl); + +test + .before(async () => { + await databaseHelper.acceptLicenseTermsAndAddOSSClusterDatabase(ossClusterConfig); + await browserPage.Cli.sendCommandInCli('flushdb'); + }) + .after(async () => { + await databaseAPIRequests.deleteOSSClusterDatabaseApi(ossClusterConfig); + + })('Verify suggestions when there are no indexes', async t => { + + await t.click(browserPage.NavigationPanel.workbenchButton); + + await t.typeText(workbenchPage.queryInput, 'FT.SE', { replace: true }); + await t.pressKey('tab'); + + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('No indexes to display').exists).ok('info text is not displayed'); + + await t.pressKey('ctrl+space'); + await t.expect(await workbenchPage.MonacoEditor.monacoCommandDetails.find('a').exists).ok('no link in the details') + }); diff --git a/tests/e2e/tests/web/critical-path/workbench/scripting-area.e2e.ts b/tests/e2e/tests/web/critical-path/workbench/scripting-area.e2e.ts index 4fc4f744c6..afee921100 100644 --- a/tests/e2e/tests/web/critical-path/workbench/scripting-area.e2e.ts +++ b/tests/e2e/tests/web/critical-path/workbench/scripting-area.e2e.ts @@ -1,6 +1,6 @@ import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; @@ -9,6 +9,7 @@ const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); let indexName = Common.generateWord(5); let keyName = Common.generateWord(5); @@ -19,7 +20,7 @@ fixture `Scripting area at Workbench` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); //Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async t => { await t.switchToMainWindow(); @@ -111,9 +112,9 @@ test('Verify that user can run one command in multiple lines in Workbench page', 'ON HASH PREFIX 1 product:', 'SCHEMA price NUMERIC SORTABLE' ]; - //Send command in multiple lines + // Send command in multiple lines await workbenchPage.sendCommandInWorkbench(multipleLinesCommand.join('\n\t'), 0.5); - //Check the result + // Check the result const resultCommand = await workbenchPage.queryCardCommand.nth(0).textContent; for(const commandPart of multipleLinesCommand) { await t.expect(resultCommand).contains(commandPart, 'The multiple lines command is in the result'); @@ -125,12 +126,12 @@ test('Verify that user can use one indent to indicate command in several lines i `FT.CREATE ${indexName}`, 'ON HASH PREFIX 1 product: SCHEMA price NUMERIC SORTABLE' ]; - //Send command in multiple lines + // Send command in multiple lines await t.typeText(workbenchPage.queryInput, multipleLinesCommand[0]); - await t.pressKey('enter tab'); + await t.pressKey('enter esc tab'); await t.typeText(workbenchPage.queryInput, multipleLinesCommand[1]); await t.click(workbenchPage.submitCommandButton); - //Check the result + // Check the result const resultCommand = await workbenchPage.queryCardCommand.nth(0).textContent; for(const commandPart of multipleLinesCommand) { await t.expect(resultCommand).contains(commandPart, 'The multiple lines command is in the result'); diff --git a/tests/e2e/tests/web/critical-path/workbench/search-and-query-autocomplete.e2e.ts b/tests/e2e/tests/web/critical-path/workbench/search-and-query-autocomplete.e2e.ts new file mode 100644 index 0000000000..19012b5824 --- /dev/null +++ b/tests/e2e/tests/web/critical-path/workbench/search-and-query-autocomplete.e2e.ts @@ -0,0 +1,395 @@ +import { Common, DatabaseHelper } from '../../../../helpers'; +import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; +import { BrowserPage, WorkbenchPage } from '../../../../pageObjects'; +import { ExploreTabs, rte } from '../../../../helpers/constants'; +import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; +import { APIKeyRequests } from '../../../../helpers/api/api-keys'; + +const databaseHelper = new DatabaseHelper(); +const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); +const workbenchPage = new WorkbenchPage(); +const apiKeyRequests = new APIKeyRequests(); + +const keyName = Common.generateWord(10); +let keyNames: string[]; +let indexName1: string; +let indexName2: string; +let indexName3: string; + +fixture `Autocomplete for entered commands in search and query` + .meta({ type: 'critical_path', rte: rte.standalone }) + .page(commonUrl) + .beforeEach(async t => { + await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); + indexName1 = `idx1:${keyName}`; + indexName2 = `idx2:${keyName}`; + indexName3 = `idx3:${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 ${indexName1} 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`, + `FT.CREATE ${indexName2} 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 browserPage.Cli.sendCommandsInCli(commands); + await t.click(browserPage.NavigationPanel.workbenchButton); + }) + .afterEach(async() => { + // Clear and delete database + await apiKeyRequests.deleteKeyByNameApi(keyName, ossStandaloneConfig.databaseName); + await browserPage.Cli.sendCommandsInCli([`DEL ${keyNames.join(' ')}`, `FT.DROPINDEX ${indexName1}`]); + await browserPage.Cli.sendCommandsInCli([`DEL ${keyNames.join(' ')}`, `FT.DROPINDEX ${indexName2}`]); + await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); + }); +test('Verify that tutorials can be opened from Workbench', async t => { + await t.click(browserPage.NavigationPanel.workbenchButton); + await t.click(workbenchPage.getTutorialLinkLocator('sq-intro')); + await t.expect(workbenchPage.InsightsPanel.sidePanel.exists).ok('Insight panel is not opened'); + const tab = await browserPage.InsightsPanel.setActiveTab(ExploreTabs.Tutorials); + await t.expect(tab.preselectArea.textContent).contains('INTRODUCTION', 'the tutorial page is incorrect'); +}); +test('Verify that user can use show more to see command fully in 2nd tooltip', async t => { + const commandDetails = [ + 'index query [VERBATIM] [LOAD count field [field ...]]', + 'Run a search query on an index and perform aggregate transformations on the results', + 'Arguments:', + 'required index', + 'required query', + 'optional [verbatim]' + ]; + await t.typeText(workbenchPage.queryInput, 'FT.A', { replace: true }); + // Verify that user can use show more to see command fully in 2nd tooltip + await t.pressKey('ctrl+space'); + await t.expect(workbenchPage.MonacoEditor.monacoCommandDetails.exists).ok('The "read more" about the command is not opened'); + for(const detail of commandDetails) { + await t.expect(workbenchPage.MonacoEditor.monacoCommandDetails.textContent).contains(detail, `The ${detail} command detail is not displayed`); + } + // Verify that user can close show more tooltip by 'x' or 'Show less' + await t.pressKey('ctrl+space'); + await t.expect(workbenchPage.MonacoEditor.monacoCommandDetails.exists).notOk('The "read more" about the command is not closed'); +}); +test('Verify full commands suggestions with index and query for FT.AGGREGATE', async t => { + const groupByArgInfo = 'GROUPBY nargs property [property ...] [REDUCE '; + const indexFields = [ + 'address', + 'city', + 'class', + 'description', + 'location', + 'name', + 'students', + 'type' + ]; + const ftSortedCommands = ['FT.SEARCH', 'FT.CREATE', 'FT.EXPLAIN', 'FT.PROFILE']; + + // Verify basic commands suggestions FT.SEARCH and FT.AGGREGATE + await t.typeText(workbenchPage.queryInput, 'FT', { replace: true }); + // Verify custom sorting for FT. commands + await t.expect(await workbenchPage.MonacoEditor.getSuggestionsArrayFromMonaco(4)).eql(ftSortedCommands, 'Wrong order of FT commands'); + // Verify that the list with FT.SEARCH and FT.AGGREGATE auto-suggestions is displayed + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withText('FT._LIST').exists).ok('FT._LIST auto-suggestions are not displayed'); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withText('FT.AGGREGATE').exists).ok('FT.AGGREGATE auto-suggestions are not displayed'); + + // Select command and check result + await t.typeText(workbenchPage.queryInput, '.AG', { replace: false }); + await t.pressKey('enter'); + let script = await workbenchPage.queryInputScriptArea.textContent; + await t.expect(script.replace(/\s/g, ' ')).contains('FT.AGGREGATE ', 'Result of sent command exists'); + + // Verify that user can see the list of all the indexes in database when put a space after only FT.SEARCH and FT.AGGREGATE commands + await t.expect(script.replace(/\s/g, ' ')).contains(`'${indexName1}' 'query to search' `, 'Index not suggested into input'); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText(indexName1).exists).ok('Index not auto-suggested'); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText(indexName2).exists).ok('All indexes not auto-suggested'); + + await t.pressKey('tab'); + await t.wait(200); + await t.typeText(workbenchPage.queryInput, '@', { replace: false }); + script = await workbenchPage.queryInputScriptArea.textContent; + // Verify that user can see the list of fields from the index selected when type in “@” + await t.expect(script.replace(/\s/g, ' ')).contains('address', 'Index not suggested into input'); + for(const field of indexFields) { + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText(field).exists).ok(`${field} Index field not auto-suggested`); + } + // Verify that user can use autosuggestions by typing fields from index after "@" + await t.typeText(workbenchPage.queryInput, 'ci', { replace: false }); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('city').exists).ok('Index field not auto-suggested after starting typing'); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.count).eql(1, 'Wrong index fields suggested after typing first letter'); + + // Go out of index field + await t.pressKey('tab'); + await t.pressKey('tab'); + await t.pressKey('right'); + await t.pressKey('space'); + // Verify contextual suggestions after typing letters for commands + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('APPLY').exists).ok('FT.AGGREGATE arguments not suggested'); + await t.typeText(workbenchPage.queryInput, 'g', { replace: false }); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.nth(0).textContent).contains('GROUPBY', 'Argument not suggested after typing first letters'); + + await t.pressKey('tab'); + // Verify that user can see widget about entered argument + await t.expect(workbenchPage.MonacoEditor.monacoHintWithArguments.textContent).contains(groupByArgInfo, 'Widget with info about entered argument not displayed'); + + await t.typeText(workbenchPage.queryInput, '1 "London"', { replace: false }); + await t.pressKey('space'); + // Verify correct order of suggested arguments like LOAD, GROUPBY, SORTBY + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.nth(0).textContent).contains('REDUCE', 'Incorrect order of suggested arguments'); + await t.pressKey('tab'); + await t.typeText(workbenchPage.queryInput, 'SUM 1 @students', { replace: false }); + await t.pressKey('space'); + + // Verify expression and function suggestions like AS for APPLY/GROUPBY + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.nth(0).textContent).contains('AS', 'Incorrect order of suggested arguments'); + await t.pressKey('tab'); + await t.typeText(workbenchPage.queryInput, 'stud', { replace: false }); + + await t.pressKey('space'); + // Verify multiple argument option suggestions + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.nth(0).textContent).contains('REDUCE', 'Incorrect order of suggested arguments'); + // Verify complex command sequences like nargs and properties are suggested accurately for GROUPBY + const expectedText = `FT.AGGREGATE '${indexName1}' '@city:{tag} ' GROUPBY 1 "London" REDUCE SUM 1 @students AS stud REDUCE`.trim().replace(/\s+/g, ' '); + await t.expect((await workbenchPage.queryInputForText.innerText).trim().replace(/\s+/g, ' ')).contains(expectedText, 'Incorrect order of entered arguments'); +}); +test('Verify full commands suggestions with index and query for FT.SEARCH', async t => { + await t.typeText(workbenchPage.queryInput, 'FT.SEA', { replace: true }); + // Select command and check result + await t.pressKey('enter'); + const script = await workbenchPage.queryInputScriptArea.textContent; + await t.expect(script.replace(/\s/g, ' ')).contains('FT.SEARCH ', 'Result of sent command exists'); + + await t.pressKey('tab') + // Select '@city' field + await workbenchPage.selectFieldUsingAutosuggest('city') + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('DIALECT').exists).ok('FT.SEARCH arguments not suggested'); + await t.typeText(workbenchPage.queryInput, 'n', { replace: false }); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.nth(0).textContent).contains('NOCONTENT', 'Argument not suggested after typing first letters'); + await t.pressKey('tab'); + // Verify that FT.SEARCH and FT.AGGREGATE non-multiple arguments are suggested only once + await t.pressKey('space'); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withText('NOCONTENT').exists).notOk('Non-multiple arguments are suggested not only once'); + + // Verify that suggestions correct to closest valid commands or options for invalid typing like WRONGCOMMAND + await t.typeText(workbenchPage.queryInput, 'WRONGCOMMAND', { replace: false }); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('WITHSORTKEYS').exists).ok('Closest suggestions not displayed'); + + await t.pressKey('space'); + await t.pressKey('backspace'); + await t.pressKey('backspace'); + // Verify that 'No suggestions' tooltip is displayed when returning to invalid typing like WRONGCOMMAND + await t.expect(workbenchPage.MonacoEditor.monacoSuggestWidget.textContent).contains('No suggestions.', 'Index not auto-suggested'); +}); +test('Verify full commands suggestions with index and query for FT.PROFILE(SEARCH)', async t => { + await t.typeText(workbenchPage.queryInput, 'FT.PR', { replace: true }); + // Select command and check result + await t.pressKey('enter'); + const script = await workbenchPage.queryInputScriptArea.textContent; + await t.expect(script.replace(/\s/g, ' ')).contains('FT.PROFILE ', 'Result of sent command exists'); + + await t.pressKey('tab'); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('AGGREGATE').exists).ok('FT.PROFILE aggregate argument not suggested'); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('SEARCH').exists).ok('FT.PROFILE search argument not suggested'); + + // Select SEARCH command + await t.typeText(workbenchPage.queryInput, 'SEA', { replace: false }); + await t.pressKey('enter'); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('LIMITED').exists).ok('FT.PROFILE SEARCH arguments not suggested'); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('QUERY').exists).ok('FT.PROFILE SEARCH arguments not suggested'); + + // Select QUERY + await t.typeText(workbenchPage.queryInput, 'QUE', { replace: false }); + await t.pressKey('enter'); + await workbenchPage.selectFieldUsingAutosuggest('city'); + // Verify that there are no more suggestions + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.exists).notOk('Additional invalid commands suggested'); + const expectedText = `FT.PROFILE '${indexName1}' SEARCH QUERY '@city:{tag} '`.trim().replace(/\s+/g, ' '); + // Verify command entered correctly + await t.expect((await workbenchPage.queryInputForText.innerText).trim().replace(/\s+/g, ' ')).contains(expectedText, 'Incorrect order of entered arguments'); +}); +test('Verify full commands suggestions with index and query for FT.PROFILE(AGGREGATE)', async t => { + await t.typeText(workbenchPage.queryInput, 'FT.PR', { replace: true }); + // Select command and check result + await t.pressKey('enter'); + await t.pressKey('tab'); + // Select AGGREGATE command + await t.typeText(workbenchPage.queryInput, 'AGG', { replace: false }); + await t.pressKey('enter'); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('LIMITED').exists).ok('FT.PROFILE AGGREGATE arguments not suggested'); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('QUERY').exists).ok('FT.PROFILE AGGREGATE arguments not suggested'); + + // Select QUERY + await t.typeText(workbenchPage.queryInput, 'QUE', { replace: false }); + await t.pressKey('enter'); + await workbenchPage.selectFieldUsingAutosuggest('city'); + // Verify that there are no more suggestions + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.exists).notOk('Additional invalid commands suggested'); + const expectedText = `FT.PROFILE '${indexName1}' AGGREGATE QUERY '@city:{tag} '`.trim().replace(/\s+/g, ' '); + // Verify command entered correctly + await t.expect((await workbenchPage.queryInputForText.innerText).trim().replace(/\s+/g, ' ')).contains(expectedText, 'Incorrect order of entered arguments'); +}); +test('Verify full commands suggestions with index and query for FT.EXPLAIN', async t => { + await t.typeText(workbenchPage.queryInput, 'FT.EX', { replace: true }); + // Select command and check result + await t.pressKey('enter'); + await t.pressKey('tab'); + await workbenchPage.selectFieldUsingAutosuggest('city'); + + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('DIALECT').exists).ok('FT.EXPLAIN arguments not suggested'); + // Add DIALECT + await t.pressKey('enter'); + await t.typeText(workbenchPage.queryInput, 'dialectTest', { replace: false }); + // Verify that there are no more suggestions + await t.pressKey('space'); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.exists).notOk('Additional invalid commands suggested'); + const expectedText = `FT.EXPLAIN '${indexName1}' '@city:{tag} ' DIALECT dialectTest`.trim().replace(/\s+/g, ' '); + // Verify command entered correctly + await t.expect((await workbenchPage.queryInputForText.innerText).trim().replace(/\s+/g, ' ')).contains(expectedText, 'Incorrect order of entered arguments'); +}); +test('Verify commands suggestions for APPLY and FILTER', async t => { + await t.typeText(workbenchPage.queryInput, 'FT.AGGREGATE ', { replace: true }); + await t.pressKey('enter'); + + await t.typeText(workbenchPage.queryInput, '*'); + await t.pressKey('right'); + await t.pressKey('space'); + // Verify APPLY command + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('APPLY').exists).ok('Apply is not suggested'); + await t.pressKey('enter'); + + await t.typeText(workbenchPage.queryInput, 'g'); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.exists).ok('commands is not suggested'); + await t.pressKey('enter'); + await t.typeText(workbenchPage.queryInput, '@', { replace: false }); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.visible).ok('Suggestions not displayed'); + await t.typeText(workbenchPage.queryInput, 'location', { replace: false }); + await t.typeText(workbenchPage.queryInput, ', "40.7128,-74.0060"'); + for (let i = 0; i < 3; i++) { + await t.pressKey('right'); + } + await t.pressKey('space'); + await t.typeText(workbenchPage.queryInput, 'a'); + await t.pressKey('tab'); + await t.typeText(workbenchPage.queryInput, 'apply_key', { replace: false }); + + await t.pressKey('space'); + // Verify Filter command + await t.typeText(workbenchPage.queryInput, 'F'); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('FILTER').exists).ok('FILTER is not suggested'); + await t.pressKey('enter'); + await t.typeText(workbenchPage.queryInput, 'apply_key < 5000', { replace: false }); + await t.pressKey('right'); + await t.pressKey('space'); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('GROUPBY').exists).ok('query can not be prolong'); +}); +test('Verify REDUCE commands', async t => { + await t.typeText(workbenchPage.queryInput, `FT.AGGREGATE ${indexName1} "*" GROUPBY 1 @location`, { replace: true }); + await t.pressKey('space'); + // select Reduce + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('REDUCE').exists).ok('REDUCE is not suggested'); + await t.typeText(workbenchPage.queryInput, 'R'); + await t.pressKey('enter'); + + // set value of reduce + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.visible).ok('Suggestions not displayed'); + // Select COUNT + await t.typeText(workbenchPage.queryInput, 'CO'); + await t.pressKey('enter'); + await t.typeText(workbenchPage.queryInput, '0'); + + // verify that count of nargs is correct + await t.pressKey('space'); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('AS').exists).ok('AS is not suggested'); + await t.pressKey('enter'); + await t.typeText(workbenchPage.queryInput, 'item_count '); + + // add additional reduce + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('REDUCE').exists).ok('Apply is not suggested'); + await t.typeText(workbenchPage.queryInput, 'R'); + await t.pressKey('enter'); + await t.typeText(workbenchPage.queryInput, 'SUM'); + await t.pressKey('enter'); + await t.typeText(workbenchPage.queryInput, '1 '); + + await t.typeText(workbenchPage.queryInput, '@', { replace: false }); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.visible).ok('Suggestions not displayed'); + await t.typeText(workbenchPage.queryInput, 'students ', { replace: false }); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('AS').exists).ok('AS is not suggested'); + await t.pressKey('enter'); + await t.typeText(workbenchPage.queryInput, 'total_students'); +}); +test('Verify suggestions for fields', async t => { + await t.typeText(workbenchPage.queryInput, 'FT.AGGREGATE ', { replace: true }); + await t.typeText(workbenchPage.queryInput, 'idx1'); + await t.pressKey('enter'); + await t.wait(200); + + await t.typeText(workbenchPage.queryInput, '@'); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.visible).ok('Suggestions not displayed'); + + // verify suggestions for geo + await t.typeText(workbenchPage.queryInput, 'l'); + await t.pressKey('tab'); + await t.expect((await workbenchPage.MonacoEditor.getTextFromMonaco()).trim()).eql(`FT.AGGREGATE '${indexName1}' '@location:[lon lat radius unit] '`); + + // verify for numeric + await t.typeText(workbenchPage.queryInput, 'FT.AGGREGATE ', { replace: true }); + await t.typeText(workbenchPage.queryInput, 'idx1'); + await t.pressKey('enter'); + await t.wait(200); + + await t.typeText(workbenchPage.queryInput, '@'); + await t.typeText(workbenchPage.queryInput, 's'); + await t.pressKey('tab'); + await t.expect((await workbenchPage.MonacoEditor.getTextFromMonaco()).trim()).eql(`FT.AGGREGATE '${indexName1}' '@students:[range] '`); +}); +// Unskip after fixing https://redislabs.atlassian.net/browse/RI-6212 +test.skip + .after(async() => { + // Clear and delete database + await apiKeyRequests.deleteKeyByNameApi(keyName, ossStandaloneConfig.databaseName); + await browserPage.Cli.sendCommandsInCli([`DEL ${keyNames.join(' ')}`, `FT.DROPINDEX ${indexName1}`]); + await browserPage.Cli.sendCommandsInCli([`DEL ${keyNames.join(' ')}`, `FT.DROPINDEX ${indexName2}`]); + await browserPage.Cli.sendCommandsInCli([`DEL ${keyNames.join(' ')}`, `FT.DROPINDEX ${indexName3}`]); + await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); + })('Verify commands suggestions for CREATE', async t => { + await t.typeText(workbenchPage.queryInput, 'FT.CREATE ', { replace: true }); + // Verify that indexes are not suggested for FT.CREATE + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.exists).notOk('Existing index suggested'); + + // Enter index name + await t.typeText(workbenchPage.queryInput, indexName3); + await t.pressKey('space'); + + // Select FILTER keyword + await t.typeText(workbenchPage.queryInput, 'FI'); + await t.pressKey('tab'); + await t.typeText(workbenchPage.queryInput, 'filterNew', { replace: false }); + await t.pressKey('space'); + + // Select SCHEMA keyword + await t.typeText(workbenchPage.queryInput, 'SCH'); + await t.pressKey('tab'); + await t.typeText(workbenchPage.queryInput, 'field_name', { replace: false }); + await t.pressKey('space'); + + // Select TEXT keyword + await t.typeText(workbenchPage.queryInput, 'te', { replace: false }); + await t.pressKey('tab'); + + // Select SORTABLE + await t.typeText(workbenchPage.queryInput, 'so', { replace: false }); + await t.pressKey('tab'); + + // Enter second field to SCHEMA + await t.typeText(workbenchPage.queryInput, 'field2_num', { replace: false }); + await t.pressKey('space'); + await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.withExactText('NUMERIC').exists).ok('query can not be prolong'); + + // Select NUMERIC keyword + await t.typeText(workbenchPage.queryInput, 'so', { replace: false }); + await t.pressKey('tab'); + + await t.expect((await workbenchPage.MonacoEditor.getTextFromMonaco()).trim()).eql(`FT.CREATE ${indexName3} FILTER filterNew SCHEMA field_name TEXT SORTABLE field2_num NUMERIC`); + }); diff --git a/tests/e2e/tests/web/regression/browser/context.e2e.ts b/tests/e2e/tests/web/regression/browser/context.e2e.ts index c54087b6a4..84b983ae52 100644 --- a/tests/e2e/tests/web/regression/browser/context.e2e.ts +++ b/tests/e2e/tests/web/regression/browser/context.e2e.ts @@ -41,7 +41,6 @@ test('Verify that if user has saved context on Browser page and go to Settings p await t.click(myRedisDatabasePage.NavigationPanel.settingsButton); // Verify that Browser and Workbench icons are displayed await t.expect(myRedisDatabasePage.NavigationPanel.browserButton.visible).ok('Browser icon is not displayed'); - await t.expect(myRedisDatabasePage.NavigationPanel.workbenchButton.visible).ok('Workbench icon is not displayed'); // Open Browser page and verify context await t.click(myRedisDatabasePage.NavigationPanel.browserButton); await verifySearchFilterValue(keyName); @@ -54,13 +53,13 @@ test('Verify that when user reload the window with saved context(on any page), c // Create context modificaions and navigate to Workbench await browserPage.addStringKey(keyName); await browserPage.openKeyDetails(keyName); - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); // Open Browser page and verify context - await t.click(myRedisDatabasePage.NavigationPanel.browserButton); + await t.click(browserPage.NavigationPanel.browserButton); await verifySearchFilterValue(keyName); await t.expect(browserPage.keyNameFormDetails.withExactText(keyName).exists).ok('The key details is not selected'); // Navigate to Workbench and reload the window - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(myRedisDatabasePage.NavigationPanel.pubSubButton); await myRedisDatabasePage.reloadPage(); // Return back to Browser and check context is not saved await t.click(myRedisDatabasePage.NavigationPanel.browserButton); diff --git a/tests/e2e/tests/web/regression/browser/key-messages.e2e.ts b/tests/e2e/tests/web/regression/browser/key-messages.e2e.ts index f000bc28af..6ea552e431 100644 --- a/tests/e2e/tests/web/regression/browser/key-messages.e2e.ts +++ b/tests/e2e/tests/web/regression/browser/key-messages.e2e.ts @@ -62,6 +62,7 @@ test('Verify that user can see link to Workbench under word “Workbench” in t // Add key and verify Workbench link await browserPage.Cli.sendCommandInCli(commands[i]); + await t.click(browserPage.NavigationPanel.browserButton); await browserPage.searchByKeyName(keyName); await t.click(browserPage.keyNameInTheList); await t.click(browserPage.internalLinkToWorkbench); diff --git a/tests/e2e/tests/web/regression/browser/keys-all-databases.e2e.ts b/tests/e2e/tests/web/regression/browser/keys-all-databases.e2e.ts index a9b292375e..182f558bb8 100644 --- a/tests/e2e/tests/web/regression/browser/keys-all-databases.e2e.ts +++ b/tests/e2e/tests/web/regression/browser/keys-all-databases.e2e.ts @@ -108,9 +108,9 @@ test await browserActions.verifyAllRenderedKeysHasText(); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); // Go to Browser Page - await t.click(myRedisDatabasePage.NavigationPanel.browserButton); + await t.click(browserPage.NavigationPanel.browserButton); // Verify that keys info in row not empty after switching between pages await browserActions.verifyAllRenderedKeysHasText(); }); diff --git a/tests/e2e/tests/web/regression/browser/onboarding.e2e.ts b/tests/e2e/tests/web/regression/browser/onboarding.e2e.ts index e136ff7e65..3d1a270a48 100644 --- a/tests/e2e/tests/web/regression/browser/onboarding.e2e.ts +++ b/tests/e2e/tests/web/regression/browser/onboarding.e2e.ts @@ -137,6 +137,7 @@ test('Verify onboard new user skip tour', async(t) => { await t.expect(myRedisDatabasePage.NavigationPanel.HelpCenter.helpCenterPanel.visible).ok('help center panel is not opened'); await t.click(onboardingCardsDialog.resetOnboardingBtn); await t.click(myRedisDatabasePage.NavigationPanel.browserButton); + await t.click(browserPage.NavigationPanel.browserButton); // Verify that when user reset onboarding, user can see the onboarding triggered when user open the Browser page. await t.expect(onboardingCardsDialog.showMeAroundButton.visible).ok('onboarding starting is not visible'); // click skip tour @@ -158,7 +159,8 @@ test.requestHooks(logger)('Verify that the final onboarding step is closed when // Verify last step of onboarding process is visible await onboardingCardsDialog.verifyStepVisible('Great job!'); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(myRedisDatabasePage.NavigationPanel.browserButton); + await t.click(browserPage.NavigationPanel.workbenchButton); // Verify that “ONBOARDING_TOUR_FINISHED” event is sent when user opens another page (or close the app) await telemetry.verifyEventHasProperties(telemetryEvent, expectedProperties, logger); diff --git a/tests/e2e/tests/web/regression/browser/resize-columns.e2e.ts b/tests/e2e/tests/web/regression/browser/resize-columns.e2e.ts index 76a9780f4a..c476830bf6 100644 --- a/tests/e2e/tests/web/regression/browser/resize-columns.e2e.ts +++ b/tests/e2e/tests/web/regression/browser/resize-columns.e2e.ts @@ -80,8 +80,8 @@ test('Resize of columns in Hash, List, Zset Key details', async t => { } // Verify that resize saved when switching between pages - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); - await t.click(myRedisDatabasePage.NavigationPanel.browserButton); + await t.click(browserPage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.browserButton); await browserPage.openKeyDetails(keys[0].name); await t.expect(field.clientWidth).within(keys[0].fieldWidthEnd - 5, keys[0].fieldWidthEnd + 5, 'Resize context not saved for key when switching between pages'); diff --git a/tests/e2e/tests/web/regression/browser/survey-link.e2e.ts b/tests/e2e/tests/web/regression/browser/survey-link.e2e.ts index bf4a0debf7..c746761838 100644 --- a/tests/e2e/tests/web/regression/browser/survey-link.e2e.ts +++ b/tests/e2e/tests/web/regression/browser/survey-link.e2e.ts @@ -32,7 +32,7 @@ test('Verify that user can use survey link', async t => { await Common.checkURL(externalPageLink); await goBackHistory(); // Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); await t.expect(browserPage.userSurveyLink.visible).ok('Survey Link is not displayed'); // Slow Log page await t.click(myRedisDatabasePage.NavigationPanel.analysisPageButton); diff --git a/tests/e2e/tests/web/regression/browser/ttl-format.e2e.ts b/tests/e2e/tests/web/regression/browser/ttl-format.e2e.ts index ba39510384..8e91c20f0e 100644 --- a/tests/e2e/tests/web/regression/browser/ttl-format.e2e.ts +++ b/tests/e2e/tests/web/regression/browser/ttl-format.e2e.ts @@ -1,6 +1,6 @@ import { Selector } from 'testcafe'; import { DatabaseHelper } from '../../../../helpers/database'; -import { keyTypes } from '../../../../helpers/keys'; +import { deleteKeysViaCli, keyTypes } from '../../../../helpers/keys'; import { rte, COMMANDS_TO_CREATE_KEY, keyLength } from '../../../../helpers/constants'; import { BrowserPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; @@ -31,9 +31,7 @@ fixture `TTL values in Keys Table` }) .afterEach(async() => { // Clear and delete database - for (let i = 0; i < keysData.length; i++) { - await browserPage.deleteKey(); - } + await deleteKeysViaCli(keysData); await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); }); test('Verify that user can see TTL in the list of keys rounded down to the nearest unit', async t => { diff --git a/tests/e2e/tests/web/regression/cli/cli-promote-workbench.ts b/tests/e2e/tests/web/regression/cli/cli-promote-workbench.e2e.ts similarity index 96% rename from tests/e2e/tests/web/regression/cli/cli-promote-workbench.ts rename to tests/e2e/tests/web/regression/cli/cli-promote-workbench.e2e.ts index 17a48dc1a7..cc0b7a428b 100644 --- a/tests/e2e/tests/web/regression/cli/cli-promote-workbench.ts +++ b/tests/e2e/tests/web/regression/cli/cli-promote-workbench.e2e.ts @@ -22,7 +22,7 @@ fixture `Promote workbench in CLI` }); test('Verify that user can see saved workbench context after redirection from CLI to workbench', async t => { // Open Workbench - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); const command = 'INFO'; await t.typeText(workbenchPage.queryInput, command, { replace: true, speed: 1, paste: true }); await t.click(myRedisDatabasePage.NavigationPanel.browserButton); diff --git a/tests/e2e/tests/web/regression/database-overview/database-info.e2e.ts b/tests/e2e/tests/web/regression/database-overview/database-info.e2e.ts index 68aa9f9764..d691abc7b1 100644 --- a/tests/e2e/tests/web/regression/database-overview/database-info.e2e.ts +++ b/tests/e2e/tests/web/regression/database-overview/database-info.e2e.ts @@ -41,6 +41,6 @@ test('Verify that user can see DB name, endpoint, connection type, Redis version // Verify that user can see an (i) icon next to the database name on Browser and Workbench pages await t.expect(browserPage.OverviewPanel.databaseInfoIcon.visible).ok('User can not see (i) icon on Browser page', { timeout: 10000 }); // Move to the Workbench page and check icon - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); await t.expect(workbenchPage.OverviewPanel.overviewTotalMemory.visible).ok('User can not see (i) icon on Workbench page', { timeout: 10000 }); }); diff --git a/tests/e2e/tests/web/regression/database-overview/database-overview.e2e.ts b/tests/e2e/tests/web/regression/database-overview/database-overview.e2e.ts index 8741192414..919691ce06 100644 --- a/tests/e2e/tests/web/regression/database-overview/database-overview.e2e.ts +++ b/tests/e2e/tests/web/regression/database-overview/database-overview.e2e.ts @@ -35,7 +35,7 @@ test('Verify that user can connect to DB and see breadcrumbs at the top of the a // Verify that user can see breadcrumbs in Browser and Workbench views await t.expect(browserPage.breadcrumbsContainer.visible).ok('User can not see breadcrumbs in Browser page', { timeout: 10000 }); - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); await t.expect(browserPage.breadcrumbsContainer.visible).ok('User can not see breadcrumbs in Workbench page', { timeout: 10000 }); // Verify that user can see total memory and total number of keys updated in DB header in Workbench page diff --git a/tests/e2e/tests/web/regression/database/github.e2e.ts b/tests/e2e/tests/web/regression/database/github.e2e.ts index 997533ceee..a9bf329691 100644 --- a/tests/e2e/tests/web/regression/database/github.e2e.ts +++ b/tests/e2e/tests/web/regression/database/github.e2e.ts @@ -1,6 +1,6 @@ import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage } from '../../../../pageObjects'; +import { BrowserPage, MyRedisDatabasePage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; @@ -8,6 +8,7 @@ import { Common } from '../../../../helpers/common'; const myRedisDatabasePage = new MyRedisDatabasePage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); fixture `Github functionality` .meta({ type: 'regression', rte: rte.standalone }) @@ -29,7 +30,7 @@ test('Verify that user can work with Github link in the application', async t => await myRedisDatabasePage.clickOnDBByName(ossStandaloneConfig.databaseName); await t.expect(myRedisDatabasePage.NavigationPanel.githubButton.visible).ok('Github button not found'); // Verify that user can see the icon for GitHub reference at the bottom of the left side bar on the Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); await t.expect(myRedisDatabasePage.NavigationPanel.githubButton.visible).ok('Github button'); // Verify that when user clicks on Github icon he redirects to the URL: https://github.com/RedisInsight/RedisInsight await t.click(myRedisDatabasePage.NavigationPanel.githubButton); diff --git a/tests/e2e/tests/web/regression/insights/import-tutorials.e2e.ts b/tests/e2e/tests/web/regression/insights/import-tutorials.e2e.ts index d02297d47f..4a047986e9 100644 --- a/tests/e2e/tests/web/regression/insights/import-tutorials.e2e.ts +++ b/tests/e2e/tests/web/regression/insights/import-tutorials.e2e.ts @@ -41,7 +41,7 @@ fixture `Upload custom tutorials` .page(commonUrl) .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async() => { await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); @@ -52,6 +52,8 @@ https://redislabs.atlassian.net/browse/RI-4302, https://redislabs.atlassian.net/ test .before(async() => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); + await t.click(browserPage.NavigationPanel.workbenchButton); + tutorialName = `${zipFolderName}${Common.generateWord(5)}`; zipFilePath = path.join('..', 'test-data', 'upload-tutorials', `${tutorialName}.zip`); // Create zip file for uploading @@ -165,6 +167,8 @@ test test .before(async() => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneRedisearch); + await t.click(browserPage.NavigationPanel.workbenchButton); + tutorialName = `${zipFolderName}${Common.generateWord(5)}`; zipFilePath = path.join('..', 'test-data', 'upload-tutorials', `${tutorialName}.zip`); // Create zip file for uploading @@ -176,7 +180,8 @@ test await Common.deleteFileFromFolder(zipFilePath); await deleteAllKeysFromDB(ossStandaloneRedisearch.host, ossStandaloneRedisearch.port); // Clear and delete database - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); + await workbenchPage.NavigationHeader.togglePanel(true); const tutorials = await workbenchPage.InsightsPanel.setActiveTab(ExploreTabs.Tutorials); await tutorials.deleteTutorialByName(tutorialName); diff --git a/tests/e2e/tests/web/regression/insights/live-recommendations.e2e.ts b/tests/e2e/tests/web/regression/insights/live-recommendations.e2e.ts index 505e087a3c..ccf8a82447 100644 --- a/tests/e2e/tests/web/regression/insights/live-recommendations.e2e.ts +++ b/tests/e2e/tests/web/regression/insights/live-recommendations.e2e.ts @@ -246,7 +246,7 @@ test //Verify that user is navigated to DB Analysis page via Analyze button and new report is generated await t.click(memoryEfficiencyPage.selectedReport); await t.expect(memoryEfficiencyPage.reportItem.visible).ok('Database analysis page not opened'); - await t.click(memoryEfficiencyPage.NavigationPanel.workbenchButton); + await t.click(memoryEfficiencyPage.NavigationPanel.browserButton); await workbenchPage.NavigationHeader.togglePanel(true); tab = await browserPage.InsightsPanel.setActiveTab(ExploreTabs.Tips); await t.click(tab.analyzeDatabaseLink); @@ -263,6 +263,7 @@ test await databaseAPIRequests.deleteStandaloneDatabasesApi(databasesForAdding); })('Verify that key name is displayed for Insights and DA recommendations', async t => { const cliCommand = `JSON.SET ${keyName} $ '{ "model": "Hyperion", "brand": "Velorim"}'`; + await browserPage.Cli.sendCommandInCli('flushdb'); await browserPage.Cli.sendCommandInCli(cliCommand); await t.click(browserPage.refreshKeysButton); await browserPage.NavigationHeader.togglePanel(true); @@ -274,7 +275,6 @@ test await t.click(tab.analyzeDatabaseLink); await t.click(tab.analyzeTooltipButton); await t.click(memoryEfficiencyPage.recommendationsTab); - await memoryEfficiencyPage.getRecommendationButtonByName(RecommendationIds.searchJson); keyNameFromRecommendation = await tab.getRecommendationByName(RecommendationIds.searchJson) .find(tab.cssKeyName) .innerText; diff --git a/tests/e2e/tests/web/regression/pub-sub/pub-sub-oss-cluster-7.ts b/tests/e2e/tests/web/regression/pub-sub/pub-sub-oss-cluster-7.e2e.ts similarity index 100% rename from tests/e2e/tests/web/regression/pub-sub/pub-sub-oss-cluster-7.ts rename to tests/e2e/tests/web/regression/pub-sub/pub-sub-oss-cluster-7.e2e.ts diff --git a/tests/e2e/tests/web/regression/workbench/autocomplete.e2e.ts b/tests/e2e/tests/web/regression/workbench/autocomplete.e2e.ts index 20cd197b92..42fbe8fa00 100644 --- a/tests/e2e/tests/web/regression/workbench/autocomplete.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/autocomplete.e2e.ts @@ -1,13 +1,13 @@ import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; -const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); fixture `Autocomplete for entered commands` .meta({ type: 'regression', rte: rte.standalone }) @@ -15,14 +15,14 @@ fixture `Autocomplete for entered commands` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async() => { // Delete database await databaseAPIRequests.deleteStandaloneDatabaseApi(ossStandaloneConfig); }); test('Verify that user can open the "read more" about the command by clicking on the ">" icon or "ctrl+space"', async t => { - const command = 'HSET'; + const command = 'HSE'; const commandDetails = [ 'HSET key field value [field value ...]', 'Creates or modifies the value of a field in a hash.', @@ -66,7 +66,7 @@ test('Verify that user can see static list of arguments is displayed when he ent await t.expect(workbenchPage.MonacoEditor.monacoHintWithArguments.visible).notOk('Hints with arguments are still displayed'); }); test('Verify that user can see the static list of arguments when he uses “Ctrl+Shift+Space” combination for already entered command for Windows', async t => { - const command = 'JSON.ARRAPPEND'; + const command = 'JSON.ARRAPPEN'; await t.typeText(workbenchPage.queryInput, command, { replace: true }); // Verify that the list with auto-suggestions is displayed await t.expect(workbenchPage.MonacoEditor.monacoSuggestion.exists).ok('Auto-suggestions are not displayed'); @@ -74,9 +74,9 @@ test('Verify that user can see the static list of arguments when he uses “Ctrl await t.pressKey('enter'); // Check that the command is displayed in Editing area after selecting const script = await workbenchPage.queryInputScriptArea.textContent; - await t.expect(script.replace(/\s/g, ' ')).eql('JSON.ARRAPPEND key value', 'Result of sent command not exists'); + await t.expect(script.replace(/\s/g, ' ')).eql('JSON.ARRAPPEND ', 'Result of sent command not exists'); // Check that hint with arguments are displayed - await t.expect(workbenchPage.MonacoEditor.monacoHintWithArguments.visible).ok('Hints with arguments are not displayed'); + await t.expect(workbenchPage.MonacoEditor.monacoHintWithArguments.textContent).contains('JSON.ARRAPPEND key [path] value', `The required argument is not suggested`); // Remove hints with arguments await t.pressKey('esc'); // Check no hints are displayed diff --git a/tests/e2e/tests/web/regression/workbench/autoexecute-button.e2e.ts b/tests/e2e/tests/web/regression/workbench/autoexecute-button.e2e.ts index 22ef0a3508..938ffae5c5 100644 --- a/tests/e2e/tests/web/regression/workbench/autoexecute-button.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/autoexecute-button.e2e.ts @@ -1,20 +1,20 @@ import { DatabaseHelper } from '../../../../helpers/database'; -import { WorkbenchPage, MyRedisDatabasePage } from '../../../../pageObjects'; +import { WorkbenchPage, BrowserPage } from '../../../../pageObjects'; import { ExploreTabs, rte } from '../../../../helpers/constants'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; -const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); fixture `Workbench Auto-Execute button` .meta({ type: 'regression', rte: rte.standalone }) .page(commonUrl) .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async() => { // Clear and delete database diff --git a/tests/e2e/tests/web/regression/workbench/command-results.e2e.ts b/tests/e2e/tests/web/regression/workbench/command-results.e2e.ts index 77cc8c791e..4d75faf820 100644 --- a/tests/e2e/tests/web/regression/workbench/command-results.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/command-results.e2e.ts @@ -1,16 +1,16 @@ import { DatabaseHelper } from '../../../../helpers/database'; -import { WorkbenchPage, MyRedisDatabasePage } from '../../../../pageObjects'; +import { WorkbenchPage, BrowserPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneRedisearch } from '../../../../helpers/conf'; import { rte } from '../../../../helpers/constants'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; import { WorkbenchActions } from '../../../../common-actions/workbench-actions'; -const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); const workBenchActions = new WorkbenchActions(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); const indexName = Common.generateWord(5); const commandsForIndex = [ @@ -25,7 +25,7 @@ fixture `Command results at Workbench` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneRedisearch); // Add index and data - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); await workbenchPage.sendCommandsArrayInWorkbench(commandsForIndex); }) .afterEach(async t => { @@ -114,7 +114,7 @@ test('Big output in workbench is visible in virtualized table', async t => { test .before(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneRedisearch); - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .after(async t => { await t.switchToMainWindow(); diff --git a/tests/e2e/tests/web/regression/workbench/context.e2e.ts b/tests/e2e/tests/web/regression/workbench/context.e2e.ts index 5b504d2165..914efa16e4 100644 --- a/tests/e2e/tests/web/regression/workbench/context.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/context.e2e.ts @@ -18,7 +18,7 @@ fixture `Workbench Context` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async() => { // Delete database @@ -27,9 +27,7 @@ fixture `Workbench Context` test('Verify that user can see saved CLI state when navigates away to any other page', async t => { // Expand CLI and navigate to Browser await t.click(workbenchPage.Cli.cliExpandButton); - await t.click(myRedisDatabasePage.NavigationPanel.browserButton); - // Return back to Workbench and check CLI - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); await t.expect(workbenchPage.Cli.cliCollapseButton.exists).ok('CLI is not expanded'); }); // Update after resolving https://redislabs.atlassian.net/browse/RI-3299 @@ -53,9 +51,7 @@ test('Verify that user can see all the information removed when reloads the page // Create context modificaions and navigate to Browser await t.typeText(workbenchPage.queryInput, command, { replace: true, speed: speed }); await t.click(workbenchPage.Cli.cliExpandButton); - await t.click(myRedisDatabasePage.NavigationPanel.browserButton); - // Open Workbench page and verify context - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); await t.expect(workbenchPage.Cli.cliCollapseButton.exists).ok('CLI is not expanded'); await t.expect(workbenchPage.queryInputScriptArea.textContent).eql(command, 'Input in Editor is not saved'); // Reload the window and chek context diff --git a/tests/e2e/tests/web/regression/workbench/cypher.e2e.ts b/tests/e2e/tests/web/regression/workbench/cypher.e2e.ts index 044b546c91..2a383a2217 100644 --- a/tests/e2e/tests/web/regression/workbench/cypher.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/cypher.e2e.ts @@ -1,13 +1,13 @@ import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; -const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); const command = 'GRAPH.QUERY graph'; @@ -17,7 +17,7 @@ fixture `Cypher syntax at Workbench` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async() => { // Drop database diff --git a/tests/e2e/tests/web/regression/workbench/default-scripts-area.e2e.ts b/tests/e2e/tests/web/regression/workbench/default-scripts-area.e2e.ts index 85571aa5f0..7055117953 100644 --- a/tests/e2e/tests/web/regression/workbench/default-scripts-area.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/default-scripts-area.e2e.ts @@ -16,7 +16,7 @@ fixture `Default scripts area at Workbench` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async() => { // Delete database @@ -70,7 +70,7 @@ test('Verify that user can see saved article in Enablement area when he leaves W // Go to Browser page await t.click(myRedisDatabasePage.NavigationPanel.browserButton); // Go back to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); // Verify that the same article is opened in Enablement area selector = tutorials.getRunSelector('Create a hash'); await t.expect(selector.visible).ok('The end of the page is not visible'); diff --git a/tests/e2e/tests/web/regression/workbench/editor-cleanup.e2e.ts b/tests/e2e/tests/web/regression/workbench/editor-cleanup.e2e.ts index 2052959b77..e8f9709587 100644 --- a/tests/e2e/tests/web/regression/workbench/editor-cleanup.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/editor-cleanup.e2e.ts @@ -1,5 +1,5 @@ import { DatabaseHelper } from '../../../../helpers/database'; -import { WorkbenchPage, MyRedisDatabasePage, SettingsPage } from '../../../../pageObjects'; +import { WorkbenchPage, MyRedisDatabasePage, SettingsPage, BrowserPage } from '../../../../pageObjects'; import { rte } from '../../../../helpers/constants'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; @@ -9,6 +9,7 @@ const workbenchPage = new WorkbenchPage(); const settingsPage = new SettingsPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); const commandToSend = 'info server'; const databasesForAdding = [ @@ -35,7 +36,7 @@ test('Disabled Editor Cleanup toggle behavior', async t => { // Verify that user can see text "Clear the Editor after running commands" for Editor Cleanup In Settings await t.expect(settingsPage.switchEditorCleanupOption.sibling(0).withExactText('Clear the Editor after running commands').visible).ok('Cleanup text is not correct'); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); // Send commands await workbenchPage.sendCommandInWorkbench(commandToSend); await workbenchPage.sendCommandInWorkbench(commandToSend); @@ -44,11 +45,12 @@ test('Disabled Editor Cleanup toggle behavior', async t => { }); test('Enabled Editor Cleanup toggle behavior', async t => { // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); // Send commands await workbenchPage.sendCommandInWorkbench(commandToSend); await workbenchPage.sendCommandInWorkbench(commandToSend); // Verify that Editor input is cleared after running command + await t.pressKey('esc'); await t.expect(await workbenchPage.queryInputScriptArea.textContent).eql('', 'Input in Editor is saved'); }); test diff --git a/tests/e2e/tests/web/regression/workbench/empty-command-history.e2e.ts b/tests/e2e/tests/web/regression/workbench/empty-command-history.e2e.ts index fce78be20c..1dd753d9f3 100644 --- a/tests/e2e/tests/web/regression/workbench/empty-command-history.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/empty-command-history.e2e.ts @@ -1,11 +1,11 @@ import { Selector } from 'testcafe'; import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; -const myRedisDatabasePage = new MyRedisDatabasePage(); +const browserPage = new BrowserPage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); @@ -16,7 +16,7 @@ fixture `Empty command history in Workbench` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async() => { // Delete database diff --git a/tests/e2e/tests/web/regression/workbench/group-mode.e2e.ts b/tests/e2e/tests/web/regression/workbench/group-mode.e2e.ts index a115c0a95a..80f2e24fc5 100644 --- a/tests/e2e/tests/web/regression/workbench/group-mode.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/group-mode.e2e.ts @@ -1,11 +1,11 @@ import { Selector } from 'testcafe'; import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneBigConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; -const myRedisDatabasePage = new MyRedisDatabasePage(); +const browserPage = new BrowserPage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); @@ -23,7 +23,7 @@ fixture `Workbench Group Mode` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneBigConfig); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async() => { // Delete database diff --git a/tests/e2e/tests/web/regression/workbench/history-of-results.e2e.ts b/tests/e2e/tests/web/regression/workbench/history-of-results.e2e.ts index 79b3e04501..21303ac99e 100644 --- a/tests/e2e/tests/web/regression/workbench/history-of-results.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/history-of-results.e2e.ts @@ -1,12 +1,12 @@ import { getRandomParagraph } from '../../../../helpers/keys'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, WorkbenchPage } from '../../../../pageObjects'; import { rte } from '../../../../helpers/constants'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; -const myRedisDatabasePage = new MyRedisDatabasePage(); +const browserPage = new BrowserPage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); @@ -21,7 +21,7 @@ fixture `History of results at Workbench` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async() => { // Clear and delete database diff --git a/tests/e2e/tests/web/regression/workbench/raw-mode.e2e.ts b/tests/e2e/tests/web/regression/workbench/raw-mode.e2e.ts index 0b87cc4a19..efdec7bc15 100644 --- a/tests/e2e/tests/web/regression/workbench/raw-mode.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/raw-mode.e2e.ts @@ -1,5 +1,5 @@ import { DatabaseHelper } from '../../../../helpers/database'; -import { WorkbenchPage, MyRedisDatabasePage } from '../../../../pageObjects'; +import { WorkbenchPage, MyRedisDatabasePage, BrowserPage } from '../../../../pageObjects'; import { rte } from '../../../../helpers/constants'; import { commonUrl, ossStandaloneConfig, ossStandaloneRedisearch } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; @@ -8,6 +8,7 @@ import { APIKeyRequests } from '../../../../helpers/api/api-keys'; const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); +const browserPage = new BrowserPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); const apiKeyRequests = new APIKeyRequests(); @@ -31,7 +32,7 @@ fixture `Workbench Raw mode` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async t => { // Clear and delete database @@ -64,7 +65,7 @@ test await myRedisDatabasePage.reloadPage(); await myRedisDatabasePage.clickOnDBByName(databasesForAdding[0].databaseName); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .after(async() => { // Clear and delete database @@ -81,7 +82,7 @@ test await t.click(myRedisDatabasePage.NavigationPanel.myRedisDBButton); await myRedisDatabasePage.clickOnDBByName(databasesForAdding[1].databaseName); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); // Verify that user can see saved Raw mode state after re-connection to another DB await workbenchPage.sendCommandInWorkbench(commandsForSend[1]); await workbenchPage.checkWorkbenchCommandResult(commandsForSend[1], `"${unicodeValue}"`); @@ -93,7 +94,7 @@ test .before(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneRedisearch); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .after(async t => { // Drop index, documents and database diff --git a/tests/e2e/tests/web/regression/workbench/redis-stack-commands.e2e.ts b/tests/e2e/tests/web/regression/workbench/redis-stack-commands.e2e.ts index af95735a91..2e40dc6d5c 100644 --- a/tests/e2e/tests/web/regression/workbench/redis-stack-commands.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/redis-stack-commands.e2e.ts @@ -1,11 +1,11 @@ import { t } from 'testcafe'; import { DatabaseHelper } from '../../../../helpers/database'; -import { WorkbenchPage, MyRedisDatabasePage } from '../../../../pageObjects'; +import { WorkbenchPage, BrowserPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { ExploreTabs, rte } from '../../../../helpers/constants'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; -const myRedisDatabasePage = new MyRedisDatabasePage(); +const browserPage = new BrowserPage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); @@ -17,7 +17,7 @@ fixture `Redis Stack command in Workbench` .page(commonUrl) .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async() => { // Drop key and database diff --git a/tests/e2e/tests/web/regression/workbench/redisearch-module-not-available.e2e.ts b/tests/e2e/tests/web/regression/workbench/redisearch-module-not-available.e2e.ts index 3abbdfc524..6aff416aa2 100644 --- a/tests/e2e/tests/web/regression/workbench/redisearch-module-not-available.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/redisearch-module-not-available.e2e.ts @@ -1,11 +1,11 @@ import { ClientFunction } from 'testcafe'; -import { ExploreTabs, rte } from '../../../../helpers/constants'; +import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneV5Config } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; -const myRedisDatabasePage = new MyRedisDatabasePage(); +const browserPage = new BrowserPage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); @@ -18,7 +18,7 @@ fixture `Redisearch module not available` .page(commonUrl) .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneV5Config); - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async() => { // Delete database diff --git a/tests/e2e/tests/web/regression/workbench/scripting-area.e2e.ts b/tests/e2e/tests/web/regression/workbench/scripting-area.e2e.ts index 95480c69af..c15dbec54d 100644 --- a/tests/e2e/tests/web/regression/workbench/scripting-area.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/scripting-area.e2e.ts @@ -1,6 +1,6 @@ import { ExploreTabs, rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage, SettingsPage } from '../../../../pageObjects'; +import { MyRedisDatabasePage, WorkbenchPage, SettingsPage, BrowserPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; @@ -10,6 +10,7 @@ const workbenchPage = new WorkbenchPage(); const settingsPage = new SettingsPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); +const browserPage = new BrowserPage(); const indexName = Common.generateWord(5); let keyName = Common.generateWord(5); @@ -20,7 +21,7 @@ fixture `Scripting area at Workbench` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async() => { // Clear and delete database @@ -41,7 +42,7 @@ test('Verify that user can run multiple commands written in multiple lines in Wo await t.click(settingsPage.accordionWorkbenchSettings); await settingsPage.changeCommandsInPipeline('1'); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); // Send commands in multiple lines await workbenchPage.sendCommandInWorkbench(commandsForSend.join('\n'), 0.5); // Check the result @@ -68,7 +69,7 @@ test await t.click(settingsPage.accordionWorkbenchSettings); await settingsPage.changeCommandsInPipeline('1'); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(settingsPage.NavigationPanel.workbenchButton); // Send commands in multiple lines with double slashes (//) wrapped in double quotes await workbenchPage.sendCommandInWorkbench(commandsForSend.join('\n"//"'), 0.5); // Check that all commands are executed diff --git a/tests/e2e/tests/web/regression/workbench/workbench-all-db-types.e2e.ts b/tests/e2e/tests/web/regression/workbench/workbench-all-db-types.e2e.ts index 633deadb0d..b199d73987 100644 --- a/tests/e2e/tests/web/regression/workbench/workbench-all-db-types.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/workbench-all-db-types.e2e.ts @@ -1,12 +1,13 @@ import { t } from 'testcafe'; import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; import { cloudDatabaseConfig, commonUrl, ossClusterConfig, ossSentinelConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); +const browserPage = new BrowserPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); @@ -19,7 +20,8 @@ const verifyCommandsInWorkbench = async(): Promise => { 'FT.SEARCH idx *' ]; - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(myRedisDatabasePage.NavigationPanel.browserButton); + await t.click(browserPage.NavigationPanel.workbenchButton); // Send commands await workbenchPage.sendCommandInWorkbench(commandForSend1); await workbenchPage.sendCommandInWorkbench(commandForSend2); diff --git a/tests/e2e/tests/web/regression/workbench/workbench-non-auto-guides.e2e.ts b/tests/e2e/tests/web/regression/workbench/workbench-non-auto-guides.e2e.ts index 447c61115f..e5d8b550e6 100644 --- a/tests/e2e/tests/web/regression/workbench/workbench-non-auto-guides.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/workbench-non-auto-guides.e2e.ts @@ -1,5 +1,5 @@ import { DatabaseHelper } from '../../../../helpers/database'; -import { WorkbenchPage, MyRedisDatabasePage } from '../../../../pageObjects'; +import { WorkbenchPage, MyRedisDatabasePage, BrowserPage } from '../../../../pageObjects'; import { rte } from '../../../../helpers/constants'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; @@ -8,6 +8,7 @@ import { APIKeyRequests } from '../../../../helpers/api/api-keys'; const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); +const browserPage = new BrowserPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); const apiKeyRequests = new APIKeyRequests(); @@ -37,7 +38,7 @@ fixture `Workbench modes to non-auto guides` .page(commonUrl) .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async() => { // Delete database @@ -46,7 +47,7 @@ fixture `Workbench modes to non-auto guides` test .before(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); await workbenchPage.sendCommandInWorkbench(`set ${keyName} "${keyValue}"`); }) .after(async t => { diff --git a/tests/e2e/tests/web/regression/workbench/workbench-pipeline.e2e.ts b/tests/e2e/tests/web/regression/workbench/workbench-pipeline.e2e.ts index 6256bed78a..1c08e0479c 100644 --- a/tests/e2e/tests/web/regression/workbench/workbench-pipeline.e2e.ts +++ b/tests/e2e/tests/web/regression/workbench/workbench-pipeline.e2e.ts @@ -1,13 +1,13 @@ -// import { ClientFunction } from 'testcafe'; import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, SettingsPage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, MyRedisDatabasePage, SettingsPage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneBigConfig } from '../../../../helpers/conf'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; import { Common } from '../../../../helpers/common'; const myRedisDatabasePage = new MyRedisDatabasePage(); const workbenchPage = new WorkbenchPage(); +const browserPage = new BrowserPage(); const settingsPage = new SettingsPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); @@ -49,7 +49,8 @@ test('Verify that user can see the text in settings for pipeline with link', asy test.skip('Verify that only chosen in pipeline number of commands is loading at the same time in Workbench', async t => { await settingsPage.changeCommandsInPipeline(pipelineValues[1]); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(settingsPage.NavigationPanel.browserButton); + await t.click(browserPage.NavigationPanel.workbenchButton); await workbenchPage.sendCommandInWorkbench(commandForSend, 0.01); // Verify that only selected pipeline number of commands are loaded at the same time await t.expect(workbenchPage.loadedCommand.count).eql(Number(pipelineValues[1]), 'The number of sending commands is incorrect'); @@ -57,7 +58,8 @@ test.skip('Verify that only chosen in pipeline number of commands is loading at test.skip('Verify that user can see spinner over Run button and grey preloader for each command', async t => { await settingsPage.changeCommandsInPipeline(pipelineValues[3]); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(settingsPage.NavigationPanel.browserButton); + await t.click(browserPage.NavigationPanel.workbenchButton); await workbenchPage.sendCommandInWorkbench(commandForSend, 0.01); // Verify that user can`t start new commands from the Workbench while command(s) is executing await t.expect(workbenchPage.submitCommandButton.withAttribute('disabled').exists).ok('Run button is not disabled', { timeout: 5000 }); @@ -70,11 +72,11 @@ test('Verify that user can interact with the Editor while command(s) in progress await settingsPage.changeCommandsInPipeline(pipelineValues[2]); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); await workbenchPage.sendCommandInWorkbench(commandForSend); - await t.typeText(workbenchPage.queryInput, commandForSend, { replace: true, paste: true }); - await t.pressKey('enter'); - // 'Verify that user can interact with the Editor + await t.typeText(workbenchPage.queryInput, commandForSend, { replace: true }); + // await t.pressKey('enter'); + // Verify that user can interact with the Editor await t.expect(workbenchPage.queryInputScriptArea.textContent).contains(valueInEditor, { timeout: 5000 }); }); test('Verify that command results are added to history in order most recent - on top', async t => { @@ -89,7 +91,7 @@ test('Verify that command results are added to history in order most recent - on await settingsPage.changeCommandsInPipeline(pipelineValues[2]); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); await workbenchPage.sendCommandInWorkbench(multipleCommands.join('\n')); // Check that the results for all commands are displayed in workbench history in reverse order (most recent - on top) for (let i = 0; i < multipleCommands.length; i++) { diff --git a/tests/e2e/tests/web/smoke/workbench/json-workbench.e2e.ts b/tests/e2e/tests/web/smoke/workbench/json-workbench.e2e.ts index 7f8ebdd65a..f1f39d04cc 100644 --- a/tests/e2e/tests/web/smoke/workbench/json-workbench.e2e.ts +++ b/tests/e2e/tests/web/smoke/workbench/json-workbench.e2e.ts @@ -1,11 +1,11 @@ import { rte } from '../../../../helpers/constants'; import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneRedisearch } from '../../../../helpers/conf'; import { Common } from '../../../../helpers/common'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; -const myRedisDatabasePage = new MyRedisDatabasePage(); +const browserPage = new BrowserPage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); @@ -18,7 +18,7 @@ fixture `JSON verifications at Workbench` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneRedisearch); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async t => { // Clear and delete database diff --git a/tests/e2e/tests/web/smoke/workbench/scripting-area.e2e.ts b/tests/e2e/tests/web/smoke/workbench/scripting-area.e2e.ts index 64ce6479d0..8220170b74 100644 --- a/tests/e2e/tests/web/smoke/workbench/scripting-area.e2e.ts +++ b/tests/e2e/tests/web/smoke/workbench/scripting-area.e2e.ts @@ -1,10 +1,10 @@ import { DatabaseHelper } from '../../../../helpers/database'; -import { MyRedisDatabasePage, WorkbenchPage } from '../../../../pageObjects'; +import { BrowserPage, WorkbenchPage } from '../../../../pageObjects'; import { commonUrl, ossStandaloneConfig } from '../../../../helpers/conf'; -import { rte } from '../../../../helpers/constants'; +import { rte } from '../../../../helpers/constants'; import { DatabaseAPIRequests } from '../../../../helpers/api/api-database'; -const myRedisDatabasePage = new MyRedisDatabasePage(); +const browserPage = new BrowserPage(); const workbenchPage = new WorkbenchPage(); const databaseHelper = new DatabaseHelper(); const databaseAPIRequests = new DatabaseAPIRequests(); @@ -15,7 +15,7 @@ fixture `Scripting area at Workbench` .beforeEach(async t => { await databaseHelper.acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig); // Go to Workbench page - await t.click(myRedisDatabasePage.NavigationPanel.workbenchButton); + await t.click(browserPage.NavigationPanel.workbenchButton); }) .afterEach(async() => { // Delete database diff --git a/tests/e2e/web.runner.ci.ts b/tests/e2e/web.runner.ci.ts new file mode 100644 index 0000000000..cb090db3ae --- /dev/null +++ b/tests/e2e/web.runner.ci.ts @@ -0,0 +1,53 @@ +import testcafe from 'testcafe'; + +(async(): Promise => { + await testcafe() + .then(t => { + return t + .createRunner() + .compilerOptions({ + 'typescript': { + configPath: 'tsconfig.testcafe.json', + experimentalDecorators: true + } }) + .src((process.env.TEST_FILES || 'tests/web/**/*.e2e.ts').split('\n')) + .browsers(['chromium:headless --cache --allow-insecure-localhost --disable-search-engine-choice-screen --ignore-certificate-errors']) + .screenshots({ + path: 'report/screenshots/', + takeOnFails: true, + pathPattern: '${OS}_${BROWSER}/${DATE}_${TIME}/${FIXTURE}_${TEST}_${FILE_INDEX}.png' + }) + .reporter([ + 'spec', + { + name: 'xunit', + output: './results/results.xml' + }, + { + name: 'json', + output: './results/e2e.results.json' + }, + { + name: 'html', + output: './report/report.html' + } + ]) + .run({ + skipJsErrors: true, + browserInitTimeout: 60000, + selectorTimeout: 5000, + assertionTimeout: 5000, + speed: 1, + quarantineMode: { successThreshold: 1, attemptLimit: 3 }, + pageRequestTimeout: 8000, + disableMultipleWindows: true + }); + }) + .then((failedCount) => { + process.exit(failedCount); + }) + .catch((e) => { + console.error(e); + process.exit(1); + }); +})(); diff --git a/tests/e2e/web.runner.ts b/tests/e2e/web.runner.ts index e77ccc1030..a30406f2ce 100644 --- a/tests/e2e/web.runner.ts +++ b/tests/e2e/web.runner.ts @@ -11,7 +11,7 @@ import testcafe from 'testcafe'; experimentalDecorators: true } }) .src((process.env.TEST_FILES || 'tests/web/**/*.e2e.ts').split('\n')) - .browsers(['chromium:headless --cache --allow-insecure-localhost --ignore-certificate-errors']) + .browsers(['chrome --cache --allow-insecure-localhost --disable-search-engine-choice-screen --ignore-certificate-errors']) .screenshots({ path: 'report/screenshots/', takeOnFails: true, @@ -38,7 +38,6 @@ import testcafe from 'testcafe'; selectorTimeout: 5000, assertionTimeout: 5000, speed: 1, - quarantineMode: { successThreshold: 1, attemptLimit: 3 }, pageRequestTimeout: 8000, disableMultipleWindows: true });