diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index fa6cd05c04..e021c73c75 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -24,6 +24,7 @@ export default { logs: join(homedir, 'logs'), defaultPlugins: join(staticDir, 'plugins'), customPlugins: join(homedir, 'plugins'), + customTutorials: join(homedir, 'custom-tutorials'), pluginsAssets: join(staticDir, 'resources', 'plugins'), commands: join(homedir, 'commands'), defaultCommandsDir: join(defaultsDir, 'commands'), @@ -45,6 +46,7 @@ export default { staticUri: '/static', guidesUri: '/static/guides', tutorialsUri: '/static/tutorials', + customTutorialsUri: '/static/custom-tutorials', contentUri: '/static/content', defaultPluginsUri: '/static/plugins', pluginsAssetsUri: '/static/resources/plugins', @@ -95,6 +97,7 @@ export default { }, analytics: { writeKey: process.env.SEGMENT_WRITE_KEY || 'SOURCE_WRITE_KEY', + flushInterval: parseInt(process.env.ANALYTICS_FLUSH_INTERVAL, 10) || 3000, }, logger: { logLevel: process.env.LOG_LEVEL || 'info', // log level @@ -108,14 +111,14 @@ export default { }, guides: { updateUrl: process.env.GUIDES_UPDATE_URL - || 'https://github.com/RedisInsight/Guides/releases/download/release', + || 'https://github.com/RedisInsight/Guides/releases/download/2.x.x', zip: process.env.GUIDES_ZIP || dataZipFileName, buildInfo: process.env.GUIDES_CHECKSUM || buildInfoFileName, devMode: !!process.env.GUIDES_DEV_PATH, }, tutorials: { updateUrl: process.env.TUTORIALS_UPDATE_URL - || 'https://github.com/RedisInsight/Tutorials/releases/download/release', + || 'https://github.com/RedisInsight/Tutorials/releases/download/2.x.x', zip: process.env.TUTORIALS_ZIP || dataZipFileName, buildInfo: process.env.TUTORIALS_CHECKSUM || buildInfoFileName, devMode: !!process.env.TUTORIALS_DEV_PATH, diff --git a/redisinsight/api/config/ormconfig.ts b/redisinsight/api/config/ormconfig.ts index a2c97987a9..56b1dcbc4f 100644 --- a/redisinsight/api/config/ormconfig.ts +++ b/redisinsight/api/config/ormconfig.ts @@ -12,6 +12,7 @@ import { ClientCertificateEntity } from 'src/modules/certificate/entities/client import { DatabaseEntity } from 'src/modules/database/entities/database.entity'; import { SshOptionsEntity } from 'src/modules/ssh/entities/ssh-options.entity'; import { BrowserHistoryEntity } from 'src/modules/browser/entities/browser-history.entity'; +import { CustomTutorialEntity } from 'src/modules/custom-tutorial/entities/custom-tutorial.entity'; import migrations from '../migration'; import * as config from '../src/utils/config'; @@ -35,6 +36,7 @@ const ormConfig = { DatabaseAnalysisEntity, BrowserHistoryEntity, SshOptionsEntity, + CustomTutorialEntity, ], migrations, }; diff --git a/redisinsight/api/config/production.ts b/redisinsight/api/config/production.ts index 01cb28b7dd..6192962e12 100644 --- a/redisinsight/api/config/production.ts +++ b/redisinsight/api/config/production.ts @@ -12,6 +12,7 @@ export default { prevHomedir, logs: join(homedir, 'logs'), customPlugins: join(homedir, 'plugins'), + customTutorials: join(homedir, 'custom-tutorials'), commands: join(homedir, 'commands'), guides: process.env.GUIDES_DEV_PATH || join(homedir, 'guides'), tutorials: process.env.TUTORIALS_DEV_PATH || join(homedir, 'tutorials'), @@ -24,6 +25,7 @@ export default { }, analytics: { writeKey: process.env.SEGMENT_WRITE_KEY || 'lK5MNZgHbxj6vQwFgqZxygA0BiDQb32n', + flushInterval: parseInt(process.env.ANALYTICS_FLUSH_INTERVAL, 10) || 10000, }, db: { database: join(homedir, 'redisinsight.db'), diff --git a/redisinsight/api/config/staging.ts b/redisinsight/api/config/staging.ts index d23664a670..e28c0435ae 100644 --- a/redisinsight/api/config/staging.ts +++ b/redisinsight/api/config/staging.ts @@ -12,6 +12,7 @@ export default { prevHomedir, logs: join(homedir, 'logs'), customPlugins: join(homedir, 'plugins'), + customTutorials: join(homedir, 'custom-tutorials'), commands: join(homedir, 'commands'), guides: process.env.GUIDES_DEV_PATH || join(homedir, 'guides'), tutorials: process.env.TUTORIALS_DEV_PATH || join(homedir, 'tutorials'), diff --git a/redisinsight/api/migration/1677135091633-custom-tutorials.ts b/redisinsight/api/migration/1677135091633-custom-tutorials.ts new file mode 100644 index 0000000000..f71d279e87 --- /dev/null +++ b/redisinsight/api/migration/1677135091633-custom-tutorials.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class customTutorials1677135091633 implements MigrationInterface { + name = 'customTutorials1677135091633' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "custom_tutorials" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "link" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "custom_tutorials"`); + } + +} diff --git a/redisinsight/api/migration/index.ts b/redisinsight/api/migration/index.ts index 428eaf52f1..d163617117 100644 --- a/redisinsight/api/migration/index.ts +++ b/redisinsight/api/migration/index.ts @@ -28,6 +28,7 @@ import { browserHistory1674539211397 } from './1674539211397-browser-history'; import { databaseAnalysisRecommendations1674660306971 } from './1674660306971-database-analysis-recommendations'; import { databaseTimeout1675398140189 } from './1675398140189-database-timeout'; import { databaseCompressor1678182722874 } from './1678182722874-database-compressor'; +import { customTutorials1677135091633 } from './1677135091633-custom-tutorials'; export default [ initialMigration1614164490968, @@ -60,4 +61,5 @@ export default [ browserHistory1674539211397, databaseTimeout1675398140189, databaseCompressor1678182722874, + customTutorials1677135091633, ]; diff --git a/redisinsight/api/package.json b/redisinsight/api/package.json index 6672514c51..66eaf86201 100644 --- a/redisinsight/api/package.json +++ b/redisinsight/api/package.json @@ -65,6 +65,7 @@ "lodash": "^4.17.20", "nest-router": "^1.0.9", "nest-winston": "^1.4.0", + "nestjs-form-data": "^1.8.7", "node-version-compare": "^1.0.3", "reflect-metadata": "^0.1.13", "rxjs": "^7.5.6", @@ -83,6 +84,7 @@ "@nestjs/cli": "^9.1.2", "@nestjs/schematics": "^9.0.3", "@nestjs/testing": "^9.0.11", + "@types/adm-zip": "^0.5.0", "@types/axios": "^0.14.0", "@types/express": "^4.17.3", "@types/jest": "^26.0.15", @@ -108,6 +110,7 @@ "mocha": "^8.4.0", "mocha-junit-reporter": "^2.0.0", "mocha-multi-reporters": "^1.5.1", + "nock": "^13.3.0", "nyc": "^15.1.0", "object-diff": "^0.0.4", "rimraf": "^3.0.2", diff --git a/redisinsight/api/src/__mocks__/custom-tutorial.ts b/redisinsight/api/src/__mocks__/custom-tutorial.ts new file mode 100644 index 0000000000..404e455a5e --- /dev/null +++ b/redisinsight/api/src/__mocks__/custom-tutorial.ts @@ -0,0 +1,180 @@ +import { CustomTutorial, CustomTutorialActions } from 'src/modules/custom-tutorial/models/custom-tutorial'; +import { CustomTutorialEntity } from 'src/modules/custom-tutorial/entities/custom-tutorial.entity'; +import { CustomTutorialManifestType } from 'src/modules/custom-tutorial/models/custom-tutorial.manifest'; +import { MemoryStoredFile } from 'nestjs-form-data'; +import { UploadCustomTutorialDto } from 'src/modules/custom-tutorial/dto/upload.custom-tutorial.dto'; +import AdmZip from 'adm-zip'; + +export const mockCustomTutorialId = 'a77b23c1-7816-4ea4-b61f-d37795a0f805-ct-id'; + +export const mockCustomTutorialId2 = 'a77b23c1-7816-4ea4-b61f-d37795a0f805-ct-id-2'; + +export const mockCustomTutorialTmpPath = '/tmp/path'; + +export const mockCustomTutorialsHttpLink = 'https://somesime.com/archive.zip'; + +export const mockCustomTutorial = Object.assign(new CustomTutorial(), { + id: mockCustomTutorialId, + name: 'custom tutorial', + createdAt: new Date(), +}); + +export const mockCustomTutorialEntity = Object.assign(new CustomTutorialEntity(), { + ...mockCustomTutorial, +}); + +export const mockCustomTutorial2 = Object.assign(new CustomTutorial(), { + id: mockCustomTutorialId2, + name: 'custom tutorial 2', + link: mockCustomTutorialsHttpLink, + createdAt: new Date(), +}); + +export const mockCustomTutorialZipFile = Object.assign(new MemoryStoredFile(), { + size: 100, + buffer: Buffer.from('zip-content', 'utf8'), +}); + +export const mockCustomTutorialZipFileAxiosResponse = { + data: mockCustomTutorialZipFile.buffer, +}; + +export const mockCustomTutorialAdmZipEntry = { + entryName: 'somefolder/info.md', +} as AdmZip.IZipEntry; + +export const mockCustomTutorialMacosxAdmZipEntry = { + entryName: '__MACOSX/info.md', +} as AdmZip.IZipEntry; + +export const mockUploadCustomTutorialDto = Object.assign(new UploadCustomTutorialDto(), { + file: mockCustomTutorialZipFile, +}); + +export const mockUploadCustomTutorialExternalLinkDto = Object.assign(new UploadCustomTutorialDto(), { + link: mockCustomTutorialsHttpLink, +}); + +export const mockCustomTutorialManifestJson = { + type: CustomTutorialManifestType.Group, + id: mockCustomTutorialId, + label: mockCustomTutorial.name, + children: [ + { + type: 'group', + id: 'ct-folder-1', + label: 'ct-folder-1', + children: [ + { + type: CustomTutorialManifestType.Group, + id: 'ct-sub-folder-1', + label: 'ct-sub-folder-1', + children: [ + { + type: CustomTutorialManifestType.InternalLink, + id: 'introduction', + label: 'introduction', + args: { + path: '/ct-folder-1/ct-sub-folder-1/introduction.md', + }, + }, + { + type: CustomTutorialManifestType.InternalLink, + id: 'working-with-hashes', + label: 'working-with-hashes', + args: { + path: '/ct-folder-1/ct-sub-folder-1/working-with-hashes.md', + }, + }, + ], + }, + { + type: CustomTutorialManifestType.Group, + id: 'ct-sub-folder-2', + label: 'ct-sub-folder-2', + children: [ + { + type: CustomTutorialManifestType.InternalLink, + id: 'introduction', + label: 'introduction', + args: { + path: '/ct-folder-1/ct-sub-folder-2/introduction.md', + }, + }, + { + type: CustomTutorialManifestType.InternalLink, + id: 'working-with-graphs', + label: 'working-with-graphs', + args: { + path: '/ct-folder-1/ct-sub-folder-2/working-with-graphs.md', + }, + }, + ], + }, + ], + }, + ], +}; + +export const mockCustomTutorialManifest = { + ...mockCustomTutorialManifestJson, + type: CustomTutorialManifestType.Group, + id: mockCustomTutorialId, + label: mockCustomTutorial.name, + _actions: mockCustomTutorial.actions, + _path: mockCustomTutorial.path, +}; + +export const mockCustomTutorialManifest2 = { + type: CustomTutorialManifestType.Group, + id: mockCustomTutorialId2, + label: mockCustomTutorial2.name, + _actions: mockCustomTutorial2.actions, + _path: mockCustomTutorial2.path, + children: [mockCustomTutorialManifestJson], +}; + +export const globalCustomTutorialManifest = { + type: CustomTutorialManifestType.Group, + id: 'custom-tutorials', + label: 'MY TUTORIALS', + _actions: [CustomTutorialActions.CREATE], + args: { + withBorder: true, + initialIsOpen: true, + }, + children: [ + mockCustomTutorialManifest, + mockCustomTutorialManifest2, + ], +}; + +export const mockCustomTutorialFsProvider = jest.fn(() => ({ + unzipFromMemoryStoredFile: jest.fn().mockResolvedValue(mockCustomTutorialTmpPath), + unzipFromExternalLink: jest.fn().mockResolvedValue(mockCustomTutorialTmpPath), + unzipToTmpFolder: jest.fn().mockResolvedValue(mockCustomTutorialTmpPath), + moveFolder: jest.fn(), + removeFolder: jest.fn(), +})); + +export const mockCustomTutorialManifestProvider = jest.fn(() => ({ + getOriginalManifestJson: jest.fn().mockResolvedValue(mockCustomTutorialManifestJson), + getManifestJson: jest.fn().mockResolvedValue(mockCustomTutorialManifestJson), + generateTutorialManifest: jest.fn().mockResolvedValue(mockCustomTutorialManifest), + isOriginalManifestExists: jest.fn().mockResolvedValue(true), +})); + +export const mockCustomTutorialRepository = jest.fn(() => ({ + get: jest.fn().mockResolvedValue(mockCustomTutorial), + create: jest.fn().mockResolvedValue(mockCustomTutorial), + delete: jest.fn(), + list: jest.fn().mockResolvedValue([ + mockCustomTutorial, + mockCustomTutorial2, + ]), +})); + +export const mockCustomTutorialAnalytics = jest.fn(() => ({ + sendImportSucceeded: jest.fn(), + sendImportFailed: jest.fn(), +})); diff --git a/redisinsight/api/src/__mocks__/index.ts b/redisinsight/api/src/__mocks__/index.ts index 8ae591bd75..ab2d9d023d 100644 --- a/redisinsight/api/src/__mocks__/index.ts +++ b/redisinsight/api/src/__mocks__/index.ts @@ -11,6 +11,7 @@ export * from './analytics'; export * from './profiler'; export * from './user'; export * from './databases'; +export * from './custom-tutorial'; export * from './autodiscovery'; export * from './redis'; export * from './server'; diff --git a/redisinsight/api/src/app.module.ts b/redisinsight/api/src/app.module.ts index 402db8875c..4a4fd36a07 100644 --- a/redisinsight/api/src/app.module.ts +++ b/redisinsight/api/src/app.module.ts @@ -21,6 +21,7 @@ import { CoreModule } from 'src/core.module'; import { AutodiscoveryModule } from 'src/modules/autodiscovery/autodiscovery.module'; import { DatabaseImportModule } from 'src/modules/database-import/database-import.module'; import { DummyAuthMiddleware } from 'src/common/middlewares/dummy-auth.middleware'; +import { CustomTutorialModule } from 'src/modules/custom-tutorial/custom-tutorial.module'; import { BrowserModule } from './modules/browser/browser.module'; import { RedisEnterpriseModule } from './modules/redis-enterprise/redis-enterprise.module'; import { RedisSentinelModule } from './modules/redis-sentinel/redis-sentinel.module'; @@ -53,6 +54,7 @@ const PATH_CONFIG = config.get('dir_path'); NotificationModule, BulkActionsModule, ClusterMonitorModule, + CustomTutorialModule.register(), DatabaseAnalysisModule, DatabaseImportModule, ...(SERVER_CONFIG.staticContent diff --git a/redisinsight/api/src/common/utils/errors.util.ts b/redisinsight/api/src/common/utils/errors.util.ts new file mode 100644 index 0000000000..e9f7413d3c --- /dev/null +++ b/redisinsight/api/src/common/utils/errors.util.ts @@ -0,0 +1,9 @@ +import { HttpException, InternalServerErrorException } from '@nestjs/common'; + +export const wrapHttpError = (error: Error, message?: string) => { + if (error instanceof HttpException) { + return error; + } + + return new InternalServerErrorException(error.message || message); +}; diff --git a/redisinsight/api/src/common/utils/index.ts b/redisinsight/api/src/common/utils/index.ts index ee0efba08b..3aaf1016a1 100644 --- a/redisinsight/api/src/common/utils/index.ts +++ b/redisinsight/api/src/common/utils/index.ts @@ -1 +1,2 @@ export * from './certificate-import.util'; +export * from './errors.util'; diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index d48a79f23a..aa968c6ece 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -7,6 +7,8 @@ export default { PROFILER_LOG_FILE_NOT_FOUND: 'Profiler log file was not found.', CONSUMER_GROUP_NOT_FOUND: 'Consumer Group with such name was not found.', PLUGIN_STATE_NOT_FOUND: 'Plugin state was not found.', + CUSTOM_TUTORIAL_NOT_FOUND: 'Custom Tutorial was not found.', + CUSTOM_TUTORIAL_UNABLE_TO_FETCH_FROM_EXTERNAL: 'Unable fetch zip file from external source.', UNDEFINED_INSTANCE_ID: 'Undefined redis database instance id.', NO_CONNECTION_TO_REDIS_DB: 'No connection to the Redis Database.', WRONG_DATABASE_TYPE: 'Wrong database type.', diff --git a/redisinsight/api/src/constants/telemetry-events.ts b/redisinsight/api/src/constants/telemetry-events.ts index 15b36f1531..786911b245 100644 --- a/redisinsight/api/src/constants/telemetry-events.ts +++ b/redisinsight/api/src/constants/telemetry-events.ts @@ -44,6 +44,9 @@ export enum TelemetryEvents { WorkbenchCommandExecuted = 'WORKBENCH_COMMAND_EXECUTED', WorkbenchCommandErrorReceived = 'WORKBENCH_COMMAND_ERROR_RECEIVED', WorkbenchCommandDeleted = 'WORKBENCH_COMMAND_DELETE_COMMAND', + // Custom tutorials + WorkbenchEnablementAreaImportSucceeded = 'WORKBENCH_ENABLEMENT_AREA_IMPORT_SUCCEEDED', + WorkbenchEnablementAreaImportFailed = 'WORKBENCH_ENABLEMENT_AREA_IMPORT_FAILED', // Profiler ProfilerLogDownloaded = 'PROFILER_LOG_DOWNLOADED', diff --git a/redisinsight/api/src/core.module.ts b/redisinsight/api/src/core.module.ts index d549235f20..467acda836 100644 --- a/redisinsight/api/src/core.module.ts +++ b/redisinsight/api/src/core.module.ts @@ -7,6 +7,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; import { RedisModule } from 'src/modules/redis/redis.module'; import { AnalyticsModule } from 'src/modules/analytics/analytics.module'; import { SshModule } from 'src/modules/ssh/ssh.module'; +import { NestjsFormDataModule } from 'nestjs-form-data'; @Global() @Module({ @@ -19,6 +20,7 @@ import { SshModule } from 'src/modules/ssh/ssh.module'; DatabaseModule.register(), RedisModule, SshModule, + NestjsFormDataModule, ], exports: [ EncryptionModule, @@ -27,6 +29,7 @@ import { SshModule } from 'src/modules/ssh/ssh.module'; DatabaseModule, RedisModule, SshModule, + NestjsFormDataModule, ], }) export class CoreModule {} diff --git a/redisinsight/api/src/modules/analytics/analytics.service.ts b/redisinsight/api/src/modules/analytics/analytics.service.ts index 0b17a14074..83f2c16201 100644 --- a/redisinsight/api/src/modules/analytics/analytics.service.ts +++ b/redisinsight/api/src/modules/analytics/analytics.service.ts @@ -45,7 +45,9 @@ export class AnalyticsService { this.sessionId = sessionId; this.anonymousId = anonymousId; this.appType = appType; - this.analytics = new Analytics(ANALYTICS_CONFIG.writeKey); + this.analytics = new Analytics(ANALYTICS_CONFIG.writeKey, { + flushInterval: ANALYTICS_CONFIG.flushInterval, + }); } @OnEvent(AppAnalyticsEvents.Track) diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.analytics.spec.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.analytics.spec.ts new file mode 100644 index 0000000000..95676c2741 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.analytics.spec.ts @@ -0,0 +1,74 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TelemetryEvents } from 'src/constants'; +import { CustomTutorialAnalytics } from 'src/modules/custom-tutorial/custom-tutorial.analytics'; +import { BadRequestException } from '@nestjs/common'; + +describe('CustomTutorialAnalytics', () => { + let service: CustomTutorialAnalytics; + let sendEventSpy; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EventEmitter2, + CustomTutorialAnalytics, + ], + }).compile(); + + service = await module.get(CustomTutorialAnalytics); + sendEventSpy = jest.spyOn(service as any, 'sendEvent'); + }); + + describe('sendImportSucceeded', () => { + it('should emit succeed event with manifest "yes"', () => { + service.sendImportSucceeded({ manifest: true }); + + expect(sendEventSpy).toHaveBeenNthCalledWith( + 1, + TelemetryEvents.WorkbenchEnablementAreaImportSucceeded, + { + manifest: 'yes', + }, + ); + }); + it('should emit succeed event with manifest "no"', () => { + service.sendImportSucceeded({ manifest: false }); + + expect(sendEventSpy).toHaveBeenNthCalledWith( + 1, + TelemetryEvents.WorkbenchEnablementAreaImportSucceeded, + { + manifest: 'no', + }, + ); + }); + }); + + describe('sendImportFailed', () => { + it('should emit 1 event with "Error" cause', () => { + service.sendImportFailed(new Error()); + + expect(sendEventSpy).toHaveBeenNthCalledWith( + 1, + TelemetryEvents.WorkbenchEnablementAreaImportFailed, + { + error: 'Error', + }, + ); + }); + it('should emit 1 event with "BadRequestException" cause', () => { + service.sendImportFailed(new BadRequestException()); + + expect(sendEventSpy).toHaveBeenNthCalledWith( + 1, + TelemetryEvents.WorkbenchEnablementAreaImportFailed, + { + error: 'BadRequestException', + }, + ); + }); + }); +}); diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.analytics.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.analytics.ts new file mode 100644 index 0000000000..fb67f6ee25 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.analytics.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { TelemetryBaseService } from 'src/modules/analytics/telemetry.base.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TelemetryEvents } from 'src/constants'; + +@Injectable() +export class CustomTutorialAnalytics extends TelemetryBaseService { + constructor(protected eventEmitter: EventEmitter2) { + super(eventEmitter); + } + + sendImportSucceeded(data: any = {}): void { + this.sendEvent( + TelemetryEvents.WorkbenchEnablementAreaImportSucceeded, + { + manifest: data?.manifest ? 'yes' : 'no', + }, + ); + } + + sendImportFailed(e: Error): void { + this.sendEvent( + TelemetryEvents.WorkbenchEnablementAreaImportFailed, + { + error: e?.constructor?.name || 'UncaughtError', + }, + ); + } +} diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts new file mode 100644 index 0000000000..be8ec2d7d1 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts @@ -0,0 +1,77 @@ +import { + Body, + ClassSerializerInterceptor, + Controller, Delete, Get, HttpCode, Param, Post, + UseInterceptors, UsePipes, ValidationPipe, +} from '@nestjs/common'; +import { + ApiConsumes, ApiExtraModels, ApiTags, +} from '@nestjs/swagger'; +import { CustomTutorialService } from 'src/modules/custom-tutorial/custom-tutorial.service'; +import { UploadCustomTutorialDto } from 'src/modules/custom-tutorial/dto/upload.custom-tutorial.dto'; +import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; +import { FormDataRequest } from 'nestjs-form-data'; +import { CreateCaCertificateDto } from 'src/modules/certificate/dto/create.ca-certificate.dto'; +import { UseCaCertificateDto } from 'src/modules/certificate/dto/use.ca-certificate.dto'; +import { CreateClientCertificateDto } from 'src/modules/certificate/dto/create.client-certificate.dto'; +import { UseClientCertificateDto } from 'src/modules/certificate/dto/use.client-certificate.dto'; +import { CreateBasicSshOptionsDto } from 'src/modules/ssh/dto/create.basic-ssh-options.dto'; +import { CreateCertSshOptionsDto } from 'src/modules/ssh/dto/create.cert-ssh-options.dto'; +import { RootCustomTutorialManifest } from 'src/modules/custom-tutorial/models/custom-tutorial.manifest'; + +@ApiExtraModels( + CreateCaCertificateDto, UseCaCertificateDto, + CreateClientCertificateDto, UseClientCertificateDto, + CreateBasicSshOptionsDto, CreateCertSshOptionsDto, +) +@UsePipes(new ValidationPipe({ transform: true })) +@UseInterceptors(ClassSerializerInterceptor) +@ApiTags('Tutorials') +@Controller('/custom-tutorials') +export class CustomTutorialController { + constructor(private readonly service: CustomTutorialService) {} + + @Post('') + @HttpCode(201) + @ApiConsumes('multipart/form-data') + @FormDataRequest() + @ApiEndpoint({ + description: 'Create new tutorial', + statusCode: 201, + responses: [ + { + type: Object, + }, + ], + }) + async create( + @Body() dto: UploadCustomTutorialDto, + ): Promise { + return this.service.create(dto); + } + + @Get('manifest') + @ApiEndpoint({ + description: 'Get global manifest for custom tutorials', + statusCode: 200, + responses: [ + { + type: Object, + }, + ], + }) + async getGlobalManifest(): Promise { + return await this.service.getGlobalManifest(); + } + + @Delete('/:id') + @ApiEndpoint({ + statusCode: 200, + description: 'Delete custom tutorial and its files', + }) + async delete( + @Param('id') id: string, + ): Promise { + return this.service.delete(id); + } +} diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.module.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.module.ts new file mode 100644 index 0000000000..39f7dc273f --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.module.ts @@ -0,0 +1,34 @@ +import { Module, Type } from '@nestjs/common'; +import { CustomTutorialController } from 'src/modules/custom-tutorial/custom-tutorial.controller'; +import { CustomTutorialService } from 'src/modules/custom-tutorial/custom-tutorial.service'; +import { CustomTutorialFsProvider } from 'src/modules/custom-tutorial/providers/custom-tutorial.fs.provider'; +import { + CustomTutorialManifestProvider, +} from 'src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider'; +import { CustomTutorialRepository } from 'src/modules/custom-tutorial/repositories/custom-tutorial.repository'; +import { + LocalCustomTutorialRepository, +} from 'src/modules/custom-tutorial/repositories/local.custom-tutorial.repository'; +import { CustomTutorialAnalytics } from 'src/modules/custom-tutorial/custom-tutorial.analytics'; + +@Module({}) +export class CustomTutorialModule { + static register( + repository: Type = LocalCustomTutorialRepository, + ) { + return { + module: CustomTutorialModule, + controllers: [CustomTutorialController], + providers: [ + CustomTutorialService, + CustomTutorialFsProvider, + CustomTutorialManifestProvider, + CustomTutorialAnalytics, + { + provide: CustomTutorialRepository, + useClass: repository, + }, + ], + }; + } +} diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.spec.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.spec.ts new file mode 100644 index 0000000000..5aee5fa3f9 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.spec.ts @@ -0,0 +1,247 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + globalCustomTutorialManifest, + mockCustomTutorial, mockCustomTutorialAnalytics, + mockCustomTutorialFsProvider, + mockCustomTutorialId, mockCustomTutorialManifest, mockCustomTutorialManifest2, + mockCustomTutorialManifestProvider, + mockCustomTutorialRepository, + MockType, mockUploadCustomTutorialDto, mockUploadCustomTutorialExternalLinkDto, +} from 'src/__mocks__'; +import * as fs from 'fs-extra'; +import { CustomTutorialFsProvider } from 'src/modules/custom-tutorial/providers/custom-tutorial.fs.provider'; +import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { CustomTutorialService } from 'src/modules/custom-tutorial/custom-tutorial.service'; +import { CustomTutorialRepository } from 'src/modules/custom-tutorial/repositories/custom-tutorial.repository'; +import { + CustomTutorialManifestProvider, +} from 'src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { CustomTutorialAnalytics } from 'src/modules/custom-tutorial/custom-tutorial.analytics'; + +jest.mock('fs-extra'); +const mockedFs = fs as jest.Mocked; + +const mockedAdmZip = { + extractAllTo: jest.fn(), +}; +jest.mock('adm-zip', () => jest.fn().mockImplementation(() => mockedAdmZip)); + +describe('CustomTutorialService', () => { + let service: CustomTutorialService; + let customTutorialRepository: MockType; + let customTutorialFsProvider: MockType; + let customTutorialManifestProvider: MockType; + let analytics: MockType; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.mock('fs-extra', () => mockedFs); + jest.mock('adm-zip', () => jest.fn().mockImplementation(() => mockedAdmZip)); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CustomTutorialService, + { + provide: CustomTutorialRepository, + useFactory: mockCustomTutorialRepository, + }, + { + provide: CustomTutorialFsProvider, + useFactory: mockCustomTutorialFsProvider, + }, + { + provide: CustomTutorialManifestProvider, + useFactory: mockCustomTutorialManifestProvider, + }, + { + provide: CustomTutorialAnalytics, + useFactory: mockCustomTutorialAnalytics, + }, + ], + }).compile(); + + service = await module.get(CustomTutorialService); + customTutorialRepository = await module.get(CustomTutorialRepository); + customTutorialFsProvider = await module.get(CustomTutorialFsProvider); + customTutorialManifestProvider = await module.get(CustomTutorialManifestProvider); + analytics = await module.get(CustomTutorialAnalytics); + }); + + describe('determineTutorialName', () => { + const entries = [ + 'name.zip', + 'name', + 'https://some.com/name', + 'https://some.com/name?some=query&might=be&here', + 'https://some.com/name.zip', + 'https://some.com/name.zip?some=query&might=be&here', + 'file://some/folder/name', + 'file://some/folder/name.zip', + '/some/unix/path/name', + '/some/unix/path/name.zip', + 'C:\\\\Windows\\name', + 'C:\\\\Windows\\name.zip', + ]; + + it('Should generate proper tutorial name for all possible inputs', async () => { + customTutorialManifestProvider.getManifestJson.mockResolvedValue(null); + await Promise.all(entries.map(async (entry) => { + expect({ + entry, + name: await service['determineTutorialName']('/na', entry), + }).toEqual({ + entry, + name: 'name', + }); + })); + }); + }); + + describe('create', () => { + it('Should create custom tutorial from file', async () => { + const result = await service.create(mockUploadCustomTutorialDto); + + expect(result).toEqual(mockCustomTutorialManifest); + expect(analytics.sendImportSucceeded).toHaveBeenCalledWith({ manifest: true }); + }); + + it('Should create custom tutorial from external url (w/o manifest)', async () => { + customTutorialManifestProvider.getOriginalManifestJson.mockResolvedValue(null); + customTutorialManifestProvider.isOriginalManifestExists.mockResolvedValue(false); + + const result = await service.create(mockUploadCustomTutorialExternalLinkDto); + + expect(result).toEqual(mockCustomTutorialManifest); + expect(analytics.sendImportSucceeded).toHaveBeenCalledWith({ manifest: false }); + }); + + it('Should throw BadRequestException in case when either link or file was not provided', async () => { + try { + await service.create({} as any); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toEqual('File or external link should be provided'); + } + }); + + it('Should throw BadRequestException in case when manifest exists but unable to parse it', async () => { + customTutorialManifestProvider.getOriginalManifestJson.mockResolvedValueOnce(null); + customTutorialManifestProvider.isOriginalManifestExists.mockResolvedValueOnce(true); + + try { + await service.create(mockUploadCustomTutorialExternalLinkDto); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toEqual('Unable to parse manifest.json file'); + } + }); + + it('Should throw BadRequestException in case when manifest is not an object', async () => { + customTutorialManifestProvider.getOriginalManifestJson.mockResolvedValue([mockCustomTutorialManifest]); + + try { + await service.create(mockUploadCustomTutorialExternalLinkDto); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.message).toEqual('Manifest json should be an object'); + } + }); + + it('Should throw BadRequestException in case when manifest json has invalid schema', async () => { + customTutorialManifestProvider.getOriginalManifestJson.mockResolvedValue({ + ...mockCustomTutorialManifest, + id: undefined, + label: undefined, + }); + + try { + await service.create(mockUploadCustomTutorialExternalLinkDto); + } catch (e) { + expect(e).toBeInstanceOf(BadRequestException); + expect(e.response?.message).toEqual([ + 'id should not be empty', + 'label should not be empty', + ]); + } + }); + + it('Should throw InternalServerError in case of any non-HttpException error', async () => { + customTutorialRepository.create.mockRejectedValueOnce(new Error('Unable to create')); + + try { + await service.create(mockUploadCustomTutorialDto); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('Unable to create'); + } + }); + }); + + describe('getGlobalManifest', () => { + it('Should return global manifest with 2 tutorials', async () => { + customTutorialManifestProvider.generateTutorialManifest + .mockResolvedValueOnce(mockCustomTutorialManifest) + .mockResolvedValueOnce(mockCustomTutorialManifest2); + + const result = await service.getGlobalManifest(); + + expect(result).toEqual(globalCustomTutorialManifest); + }); + + it('Should return global manifest with 1 tutorials since 1 failed to fetch', async () => { + customTutorialManifestProvider.generateTutorialManifest + .mockResolvedValueOnce(null); + + const result = await service.getGlobalManifest(); + + expect(result).toEqual({ + ...globalCustomTutorialManifest, + children: [ + mockCustomTutorialManifest, + ], + }); + }); + + it('Should return global manifest without children in case of any error', async () => { + customTutorialRepository.list.mockRejectedValueOnce(new Error('Unable to get list of tutorials')); + + const result = await service.getGlobalManifest(); + + expect(result).toEqual({ + ...globalCustomTutorialManifest, + children: [], + }); + }); + }); + + describe('delete', () => { + it('Should successfully delete entity and remove related directory', async () => { + await service.delete(mockCustomTutorialId); + + expect(customTutorialFsProvider.removeFolder).toHaveBeenCalledWith(mockCustomTutorial.absolutePath); + }); + + it('Should throw NotFound error when try to delete not existing tutorial', async () => { + customTutorialRepository.get.mockResolvedValueOnce(null); + + try { + await service.delete(mockCustomTutorialId); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.CUSTOM_TUTORIAL_NOT_FOUND); + } + }); + + it('Should throw InternalServerError in case of any non-HttpException error', async () => { + customTutorialRepository.delete.mockRejectedValueOnce(new Error('Unable to delete')); + + try { + await service.delete(mockCustomTutorialId); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('Unable to delete'); + } + }); + }); +}); diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts new file mode 100644 index 0000000000..7b051e9b2b --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts @@ -0,0 +1,175 @@ +import { + BadRequestException, + Injectable, Logger, NotFoundException, ValidationPipe, +} from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; +import { CustomTutorialRepository } from 'src/modules/custom-tutorial/repositories/custom-tutorial.repository'; +import { CustomTutorial, CustomTutorialActions } from 'src/modules/custom-tutorial/models/custom-tutorial'; +import { UploadCustomTutorialDto } from 'src/modules/custom-tutorial/dto/upload.custom-tutorial.dto'; +import { plainToClass } from 'class-transformer'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { CustomTutorialFsProvider } from 'src/modules/custom-tutorial/providers/custom-tutorial.fs.provider'; +import { + CustomTutorialManifestProvider, +} from 'src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider'; +import { + CustomTutorialManifestType, + RootCustomTutorialManifest, +} from 'src/modules/custom-tutorial/models/custom-tutorial.manifest'; +import { wrapHttpError } from 'src/common/utils'; +import { parse } from 'path'; +import { isPlainObject } from 'lodash'; +import * as URL from 'url'; +import { Validator } from 'class-validator'; +import { CustomTutorialAnalytics } from 'src/modules/custom-tutorial/custom-tutorial.analytics'; + +@Injectable() +export class CustomTutorialService { + private logger = new Logger('CustomTutorialService'); + + private validator = new Validator(); + + private exceptionFactory = (new ValidationPipe()).createExceptionFactory(); + + constructor( + private readonly customTutorialRepository: CustomTutorialRepository, + private readonly customTutorialFsProvider: CustomTutorialFsProvider, + private readonly customTutorialManifestProvider: CustomTutorialManifestProvider, + private readonly analytics: CustomTutorialAnalytics, + ) {} + + private async validateManifestJson(path: string): Promise { + const manifest = await this.customTutorialManifestProvider.getOriginalManifestJson(path); + + if (!manifest && await this.customTutorialManifestProvider.isOriginalManifestExists(path)) { + throw new BadRequestException('Unable to parse manifest.json file'); + } + + if (manifest) { + if (!isPlainObject(manifest)) { + throw new BadRequestException('Manifest json should be an object'); + } + + const errors = await this.validator.validate( + plainToClass(RootCustomTutorialManifest, manifest), + { whitelist: true }, + ); + + if (errors?.length) { + throw this.exceptionFactory(errors); + } + } + } + + private async determineTutorialName(path: string, link: string) { + const manifest = await this.customTutorialManifestProvider.getManifestJson(path); + + if (!manifest?.label) { + return parse(URL.parse(link).pathname).name; + } + + return manifest.label; + } + + /** + * Create custom tutorial entity + static files based on input + * Currently from zip file only + * @param dto + */ + public async create(dto: UploadCustomTutorialDto): Promise { + try { + let tmpPath = ''; + + if (dto.file) { + tmpPath = await this.customTutorialFsProvider.unzipFromMemoryStoredFile(dto.file); + } else if (dto.link) { + tmpPath = await this.customTutorialFsProvider.unzipFromExternalLink(dto.link); + } else { + throw new BadRequestException('File or external link should be provided'); + } + + await this.validateManifestJson(tmpPath); + + // create tutorial model + const model = plainToClass(CustomTutorial, { + ...dto, + id: uuidv4(), + }); + + await this.customTutorialFsProvider.moveFolder(tmpPath, model.absolutePath); + + model.name = await this.determineTutorialName(model.absolutePath, dto?.file?.originalName || dto.link); + const tutorial = await this.customTutorialRepository.create(model); + + this.analytics.sendImportSucceeded({ + manifest: !!(await this.customTutorialManifestProvider.getOriginalManifestJson(tutorial.absolutePath)), + }); + + return await this.customTutorialManifestProvider.generateTutorialManifest(tutorial); + } catch (e) { + this.analytics.sendImportFailed(e); + this.logger.error('Unable to create custom tutorials', e); + throw wrapHttpError(e); + } + } + + /** + * Get global manifest for all custom tutorials + * In the future will be removed with some kind of partial load + */ + public async getGlobalManifest(): Promise { + const children = []; + + try { + const tutorials = await this.customTutorialRepository.list(); + + const manifests = await Promise.all( + tutorials.map( + this.customTutorialManifestProvider.generateTutorialManifest.bind(this.customTutorialManifestProvider), + ), + ) as Record[]; + + manifests.forEach((manifest) => { + if (manifest) { + children.push(manifest); + } + }); + } catch (e) { + this.logger.warn('Unable to generate entire custom tutorials manifest', e); + } + + return { + type: CustomTutorialManifestType.Group, + id: 'custom-tutorials', + label: 'MY TUTORIALS', + _actions: [CustomTutorialActions.CREATE], + args: { + withBorder: true, + initialIsOpen: true, + }, + children, + }; + } + + public async get(id: string): Promise { + const model = await this.customTutorialRepository.get(id); + + if (!model) { + this.logger.error(`Custom Tutorial with ${id} was not Found`); + throw new NotFoundException(ERROR_MESSAGES.CUSTOM_TUTORIAL_NOT_FOUND); + } + + return model; + } + + public async delete(id: string): Promise { + try { + const tutorial = await this.get(id); + await this.customTutorialRepository.delete(id); + await this.customTutorialFsProvider.removeFolder(tutorial.absolutePath); + } catch (e) { + this.logger.error('Unable to delete custom tutorial', e); + throw wrapHttpError(e); + } + } +} diff --git a/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts b/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts new file mode 100644 index 0000000000..bafe7d7ba6 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts @@ -0,0 +1,27 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { + HasMimeType, IsFile, MaxFileSize, MemoryStoredFile, +} from 'nestjs-form-data'; + +export class UploadCustomTutorialDto { + @ApiPropertyOptional({ + type: 'string', + format: 'binary', + description: 'ZIP archive with tutorial static files', + }) + @IsOptional() + @IsFile() + @HasMimeType(['application/zip']) + @MaxFileSize(10 * 1024 * 1024) + file?: MemoryStoredFile; + + @ApiPropertyOptional({ + type: 'string', + description: 'External link to zip archive', + }) + @IsOptional() + @IsString() + @IsNotEmpty() + link?: string; +} diff --git a/redisinsight/api/src/modules/custom-tutorial/entities/custom-tutorial.entity.ts b/redisinsight/api/src/modules/custom-tutorial/entities/custom-tutorial.entity.ts new file mode 100644 index 0000000000..b41cb1333a --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/entities/custom-tutorial.entity.ts @@ -0,0 +1,23 @@ +import { + Entity, PrimaryGeneratedColumn, CreateDateColumn, Column, +} from 'typeorm'; +import { Expose } from 'class-transformer'; + +@Entity('custom_tutorials') +export class CustomTutorialEntity { + @PrimaryGeneratedColumn('uuid') + @Expose() + id: string; + + @Column({ nullable: false }) + @Expose() + name: string; + + @Column({ nullable: true }) + @Expose() + link?: string; + + @CreateDateColumn() + @Expose() + createdAt: Date; +} diff --git a/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.manifest.ts b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.manifest.ts new file mode 100644 index 0000000000..23f05ba7a0 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.manifest.ts @@ -0,0 +1,128 @@ +import { CustomTutorialActions } from 'src/modules/custom-tutorial/models/custom-tutorial'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + IsArray, IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString, ValidateNested, +} from 'class-validator'; + +export enum CustomTutorialManifestType { + CodeButton = 'code-button', + Group = 'group', + InternalLink = 'internal-link', +} + +export interface ICustomTutorialManifest { + id: string, + type: CustomTutorialManifestType, + label: string, + children?: Record, + args?: Record, + _actions?: CustomTutorialActions[], + _path?: string, +} + +export class CustomTutorialManifestArgs { + @ApiPropertyOptional({ type: Boolean }) + @IsOptional() + @Expose() + @IsString() + @IsNotEmpty() + path?: string; + + @ApiPropertyOptional({ type: Boolean }) + @IsOptional() + @Expose() + @IsBoolean() + initialIsOpen?: boolean; + + @ApiPropertyOptional({ type: Boolean }) + @IsOptional() + @Expose() + @IsBoolean() + withBorder?: boolean; +} + +export class CustomTutorialManifest { + @ApiProperty({ type: String }) + @Expose() + @IsNotEmpty() + id: string; + + @ApiProperty({ enum: CustomTutorialManifestType }) + @Expose() + @IsEnum(CustomTutorialManifestType) + type: CustomTutorialManifestType; + + @ApiProperty({ type: String }) + @Expose() + @IsNotEmpty() + label: string; + + @ApiPropertyOptional({ type: CustomTutorialManifestArgs }) + @IsOptional() + @Expose() + @ValidateNested() + @Type(() => CustomTutorialManifestArgs) + args?: CustomTutorialManifestArgs; + + @ApiPropertyOptional({ type: CustomTutorialManifest }) + @IsOptional() + @Expose() + @ValidateNested({ each: true }) + @IsArray() + @Type(() => CustomTutorialManifest) + children?: CustomTutorialManifest[]; +} + +export class RootCustomTutorialManifest extends CustomTutorialManifest { + @ApiPropertyOptional({ enum: CustomTutorialActions }) + @IsOptional() + @Expose() + @IsArray() + @IsEnum(CustomTutorialActions, { each: true }) + _actions?: CustomTutorialActions[]; + + @ApiPropertyOptional({ type: String }) + @IsOptional() + @Expose() + @IsString() + @IsNotEmpty() + _path?: string; + + @ApiPropertyOptional({ type: String, isArray: true }) + @IsOptional() + @Expose() + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + keywords?: string[]; + + @ApiPropertyOptional({ type: String }) + @IsOptional() + @Expose() + @IsString() + @IsNotEmpty() + author?: string; + + @ApiPropertyOptional({ type: String }) + @IsOptional() + @Expose() + @IsString() + @IsNotEmpty() + url?: string; + + @ApiPropertyOptional({ type: String, isArray: true }) + @IsOptional() + @Expose() + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + industry?: string[]; + + @ApiPropertyOptional({ type: String }) + @IsOptional() + @Expose() + @IsString() + @IsNotEmpty() + description?: string; +} diff --git a/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts new file mode 100644 index 0000000000..f1d25c16ca --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts @@ -0,0 +1,46 @@ +import { Expose } from 'class-transformer'; +import { join } from 'path'; +import config from 'src/utils/config'; + +const PATH_CONFIG = config.get('dir_path'); + +export enum CustomTutorialActions { + CREATE = 'create', + DELETE = 'delete', + SYNC = 'sync', +} + +export class CustomTutorial { + @Expose() + id: string; + + @Expose() + name: string; + + @Expose() + uri: string; + + @Expose() + link?: string; + + @Expose() + createdAt: Date; + + get actions(): CustomTutorialActions[] { + const actions = [CustomTutorialActions.DELETE]; + + if (this.link) { + actions.push(CustomTutorialActions.SYNC); + } + + return actions; + } + + get path(): string { + return `/${this.id}`; + } + + get absolutePath(): string { + return join(PATH_CONFIG.customTutorials, this.id); + } +} diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.spec.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.spec.ts new file mode 100644 index 0000000000..99ffb54564 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.spec.ts @@ -0,0 +1,233 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + mockCustomTutorial, + mockCustomTutorialAdmZipEntry, + mockCustomTutorialMacosxAdmZipEntry, mockCustomTutorialsHttpLink, + mockCustomTutorialTmpPath, + mockCustomTutorialZipFile, mockCustomTutorialZipFileAxiosResponse, +} from 'src/__mocks__'; +import * as fs from 'fs-extra'; +import axios from 'axios'; +import { CustomTutorialFsProvider } from 'src/modules/custom-tutorial/providers/custom-tutorial.fs.provider'; +import { InternalServerErrorException } from '@nestjs/common'; +import AdmZip from 'adm-zip'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import config from 'src/utils/config'; +import { Dirent, Stats } from 'fs'; + +const PATH_CONFIG = config.get('dir_path'); + +jest.mock('fs-extra'); +const mFs = fs as jest.Mocked; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +const mockedAdmZip = { + extractAllTo: jest.fn(), + getEntries: jest.fn(), + extractEntryTo: jest.fn(), +} as unknown as jest.Mocked; + +jest.mock('adm-zip', () => jest.fn().mockImplementation(() => mockedAdmZip)); + +describe('CustomTutorialFsProvider', () => { + let service: CustomTutorialFsProvider; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.mock('fs-extra', () => mFs); + jest.mock('adm-zip', () => jest.fn().mockImplementation(() => mockedAdmZip)); + jest.mock('axios', () => mockedAxios); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CustomTutorialFsProvider, + ], + }).compile(); + + service = await module.get(CustomTutorialFsProvider); + }); + + describe('unzipToTmpFolder', () => { + let prepareTmpFolderSpy; + + beforeEach(() => { + mockedAxios.get.mockResolvedValueOnce(mockCustomTutorialZipFileAxiosResponse); + mockedAdmZip.getEntries.mockReturnValue([]); + mFs.ensureDir.mockImplementationOnce(() => Promise.resolve()); + mFs.remove.mockImplementationOnce(() => Promise.resolve()); + mFs.readdir.mockResolvedValue([]); + prepareTmpFolderSpy = jest.spyOn(CustomTutorialFsProvider, 'prepareTmpFolder'); + prepareTmpFolderSpy.mockResolvedValueOnce(mockCustomTutorialTmpPath); + }); + + describe('unzipFromMemoryStoredFile', () => { + it('should unzip data', async () => { + const result = await service.unzipFromMemoryStoredFile(mockCustomTutorialZipFile); + expect(result).toEqual(mockCustomTutorialTmpPath); + expect(mFs.copy).not.toHaveBeenCalled(); + }); + it('should unzip data into just generated tmp folder', async () => { + mFs.lstat.mockResolvedValueOnce(({ isDirectory: () => true }) as Stats); + mFs.readdir.mockResolvedValue(['singleFolder'] as unknown as Dirent[]); + + prepareTmpFolderSpy.mockRestore(); + const result = await service.unzipFromMemoryStoredFile(mockCustomTutorialZipFile); + expect(result).toContain(`${PATH_CONFIG.tmpDir}/RedisInsight-v2/custom-tutorials`); + expect(mFs.copy).toHaveBeenCalled(); + }); + }); + + describe('unzipFromExternalLink', () => { + it('should unzip data from external link', async () => { + const result = await service.unzipFromExternalLink(mockCustomTutorialsHttpLink); + expect(result).toEqual(mockCustomTutorialTmpPath); + }); + + it('should throw InternalServerError when 4incorrect external link provided', async () => { + const responsePayload = { + response: { + status: 404, + data: { message: 'resource not found' }, + }, + }; + + mockedAxios.get.mockReset().mockRejectedValueOnce(responsePayload); + + try { + await service.unzipFromExternalLink(mockCustomTutorialsHttpLink); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual(ERROR_MESSAGES.CUSTOM_TUTORIAL_UNABLE_TO_FETCH_FROM_EXTERNAL); + } + }); + }); + + it('should unzip data to particular tmp folder', async () => { + mockedAdmZip.getEntries.mockReturnValueOnce([ + mockCustomTutorialAdmZipEntry, + mockCustomTutorialMacosxAdmZipEntry, + ]); + + const result = await service.unzipToTmpFolder(mockedAdmZip); + + expect(result).toEqual(mockCustomTutorialTmpPath); + expect(mockedAdmZip.extractEntryTo).toHaveBeenCalledTimes(1); + expect(mockedAdmZip.extractEntryTo).toHaveBeenCalledWith( + mockCustomTutorialAdmZipEntry, + mockCustomTutorialTmpPath, + true, + true, + false, + ); + }); + + it('should throw InternalServerError', async () => { + mockedAdmZip.getEntries.mockReturnValueOnce([ + mockCustomTutorialAdmZipEntry, + mockCustomTutorialMacosxAdmZipEntry, + ]); + mockedAdmZip.extractEntryTo.mockImplementationOnce(() => { throw new Error('Unable to extract file'); }); + + try { + await service.unzipToTmpFolder(mockedAdmZip); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('Unable to extract file'); + } + }); + }); + + describe('moveFolder', () => { + it('should move folder', async () => { + mFs.move.mockImplementationOnce(() => Promise.resolve()); + + await service.moveFolder( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + ); + + expect(mFs.pathExists).not.toHaveBeenCalled(); + expect(mFs.remove).not.toHaveBeenCalled(); + expect(mFs.move).toHaveBeenCalledWith( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + ); + }); + + it('should move folder when there is no such folder in the dest path', async () => { + mFs.pathExists.mockImplementationOnce(() => Promise.resolve(false)); + mFs.move.mockImplementationOnce(() => Promise.resolve()); + + await service.moveFolder( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + true, + ); + + expect(mFs.pathExists).toHaveBeenCalledWith(mockCustomTutorial.absolutePath); + expect(mFs.remove).not.toHaveBeenCalled(); + expect(mFs.move).toHaveBeenCalledWith( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + ); + }); + + it('should move folder when and remove existing one before', async () => { + mFs.pathExists.mockImplementationOnce(() => Promise.resolve(true)); + mFs.remove.mockImplementationOnce(() => Promise.resolve()); + mFs.move.mockImplementationOnce(() => Promise.resolve()); + + await service.moveFolder( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + true, + ); + + expect(mFs.pathExists).toHaveBeenCalledWith(mockCustomTutorial.absolutePath); + expect(mFs.remove).toHaveBeenCalledWith(mockCustomTutorial.absolutePath); + expect(mFs.move).toHaveBeenCalledWith( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + ); + }); + + it('should throw InternalServerError', async () => { + mFs.pathExists.mockImplementationOnce(() => Promise.resolve(true)); + mFs.remove.mockImplementationOnce(() => Promise.resolve()); + mFs.move.mockImplementationOnce(() => Promise.reject(new Error('dest folder exists'))); + + try { + await service.moveFolder( + mockCustomTutorialTmpPath, + mockCustomTutorial.absolutePath, + true, + ); + } catch (e) { + expect(e).toBeInstanceOf(InternalServerErrorException); + expect(e.message).toEqual('dest folder exists'); + } + }); + }); + + describe('removeFolder', () => { + it('should remove folder', async () => { + mFs.remove.mockResolvedValueOnce(); + + await service.removeFolder(mockCustomTutorial.absolutePath); + + expect(mFs.remove).toHaveBeenCalledWith(mockCustomTutorial.absolutePath); + }); + + it('should not fail in case of any error', async () => { + mFs.remove.mockReset().mockRejectedValueOnce(new Error('No file')); + + await service.removeFolder(mockCustomTutorial.absolutePath); + + expect(mFs.remove).toHaveBeenCalledWith(mockCustomTutorial.absolutePath); + }); + }); +}); diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts new file mode 100644 index 0000000000..4196c774fd --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts @@ -0,0 +1,150 @@ +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; +import { MemoryStoredFile } from 'nestjs-form-data'; +import { join } from 'path'; +import { v4 as uuidv4 } from 'uuid'; +import * as fs from 'fs-extra'; +import config from 'src/utils/config'; +import * as AdmZip from 'adm-zip'; +import axios from 'axios'; +import { wrapHttpError } from 'src/common/utils'; +import ERROR_MESSAGES from 'src/constants/error-messages'; + +const PATH_CONFIG = config.get('dir_path'); + +const TMP_FOLDER = `${PATH_CONFIG.tmpDir}/RedisInsight-v2/custom-tutorials`; + +@Injectable() +export class CustomTutorialFsProvider { + private logger = new Logger('CustomTutorialFsProvider'); + + /** + * Custom implementation of AdmZip.extractAllTo to ignore __MACOSX folder in the root of archive + * In some cases when we try to delete __MACOSX folder Electron app might crash + * As workaround we will never extract this folder to user's FS + * @param zip + * @param targetPath + * @param overwrite + * @param keepOriginalPermission + * @private + */ + private async extractAll(zip: AdmZip, targetPath, overwrite = true, keepOriginalPermission = false) { + zip.getEntries().forEach((entry) => { + if (!entry.entryName.includes('__MACOSX')) { + zip.extractEntryTo( + entry, + targetPath, + true, + overwrite, + keepOriginalPermission, + ); + } + }); + } + + /** + * Unzip custom tutorials archive to temporary folder + * @param zip + */ + public async unzipToTmpFolder(zip: AdmZip): Promise { + try { + const path = await CustomTutorialFsProvider.prepareTmpFolder(); + + await fs.remove(path); + await this.extractAll(zip, path, true); + + return CustomTutorialFsProvider.prepareTutorialFolder(path); + } catch (e) { + this.logger.error('Unable to unzip archive', e); + throw new InternalServerErrorException(e.message); + } + } + + /** + * Unzip archive from multipart/form-data file input + * @param file + */ + public async unzipFromMemoryStoredFile(file: MemoryStoredFile): Promise { + return this.unzipToTmpFolder(new AdmZip(file.buffer)); + } + + /** + * Download zip archive from external source and unzip it to temporary directory + * @param link + */ + public async unzipFromExternalLink(link: string): Promise { + try { + const { data } = await axios.get(link, { + responseType: 'arraybuffer', + }); + + return this.unzipToTmpFolder(new AdmZip(data)); + } catch (e) { + this.logger.error('Unable to fetch zip file from external source', e); + throw wrapHttpError(e, ERROR_MESSAGES.CUSTOM_TUTORIAL_UNABLE_TO_FETCH_FROM_EXTERNAL); + } + } + + /** + * Move custom tutorial from tmp folder to proper path to serve static files + * force - default false, will remove existing folder + * @param tmpPath + * @param dest + * @param force + */ + public async moveFolder(tmpPath: string, dest: string, force = false) { + try { + if (force && await fs.pathExists(dest)) { + await fs.remove(dest); + } + + await fs.move(tmpPath, dest); + } catch (e) { + this.logger.error('Unable to move tutorial to a folder', e); + throw new InternalServerErrorException(e.message); + } + } + + /** + * Delete Tutorial folder + * Will silently log an error if any + * @param path + */ + public async removeFolder(path: string) { + try { + await fs.remove(path); + } catch (e) { + this.logger.warn('Unable to delete tutorial folder', e); + } + } + + /** + * Create tmp folder in user's temporary directory and return path to it + */ + static async prepareTmpFolder(): Promise { + const path = join(TMP_FOLDER, uuidv4()); + await fs.ensureDir(path); + + return path; + } + + /** + * Check for data structure + * in case when and a single folder presented on the root level + * we will ignore it and work with everything inside it + * @private + */ + static async prepareTutorialFolder(path: string): Promise { + const entries = await fs.readdir(path); + const firstEntryPath = join(path, entries[0] || ''); + + if (entries?.length === 1 && (await fs.lstat(firstEntryPath)).isDirectory()) { + const newPath = await CustomTutorialFsProvider.prepareTmpFolder(); + + await fs.copy(firstEntryPath, newPath); + + return newPath; + } + + return path; + } +} diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.spec.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.spec.ts new file mode 100644 index 0000000000..97b4caba6f --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.spec.ts @@ -0,0 +1,210 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CustomTutorialManifestProvider } from 'src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider'; +import * as fs from 'fs-extra'; +import { Dirent, Stats } from 'fs'; +import { join } from 'path'; +import { + mockCustomTutorial, + mockCustomTutorialManifest, mockCustomTutorialManifestJson, +} from 'src/__mocks__'; + +jest.mock('fs-extra'); +const mFs = fs as jest.Mocked; + +describe('CustomTutorialManifestProvider', () => { + let service: CustomTutorialManifestProvider; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.mock('fs-extra', () => mFs); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CustomTutorialManifestProvider, + ], + }).compile(); + + service = await module.get(CustomTutorialManifestProvider); + }); + + describe('generateManifestFile', () => { + it('should return empty manifest for empty folder', async () => { + mFs.readdir.mockResolvedValueOnce([]); + mFs.writeFile.mockImplementationOnce(() => Promise.resolve()); + + await service['generateManifestFile'](mockCustomTutorial.absolutePath); + + expect(mFs.writeFile).toHaveBeenCalledWith( + join(mockCustomTutorial.absolutePath, '_manifest.json'), + JSON.stringify({ children: [] }), + 'utf8', + ); + }); + }); + + describe('getManifestJson', () => { + it('should return null in case of an error', async () => { + jest.spyOn(service as any, 'getManifestJsonFile').mockRejectedValueOnce(new Error('any error')); + + const result = await service.getManifestJson(mockCustomTutorial.absolutePath); + + expect(result).toEqual(null); + }); + }); + + describe('generateManifestEntry', () => { + it('should return empty array for empty folder', async () => { + mFs.readdir.mockResolvedValueOnce([]); + + const result = await service['generateManifestEntry'](mockCustomTutorial.absolutePath); + + expect(result).toEqual([]); + }); + it('should return empty array for empty folder', async () => { + // root level entries + const mockRootLevelEntries = [ + 'intro.md', + '.idea', // should be ignored since starts with . + 'subfolder', + 'manifest.json', // should be ignored since not md file + '_manifest.json', // should be ignored since starts with _ + '_some.md', // should be ignored since starts with _ + ] as unknown as Dirent[]; + + // subfolder entries + const mockSubFolderEntries = [ + 'file.md', + 'file2.md', + 'subsubfolder', + '.idea', // should be ignored since starts with . + '_some.md', // should be ignored since starts with _ + ] as unknown as Dirent[]; + + const mockSubSubFolderEntries = [ + 'file.md', + 'file2.md', + '.idea', // should be ignored since starts with . + '_some.md', // should be ignored since starts with _ + ] as unknown as Dirent[]; + + mFs.readdir + .mockResolvedValueOnce(mockRootLevelEntries) + .mockResolvedValueOnce(mockSubFolderEntries) + .mockResolvedValueOnce(mockSubSubFolderEntries); + + mFs.lstat + .mockResolvedValueOnce(({ isDirectory: () => false }) as Stats) // intro.md + .mockResolvedValueOnce(({ isDirectory: () => true }) as Stats) // subfolder/ + .mockResolvedValueOnce(({ isDirectory: () => false }) as Stats) // subfolder/file.md + .mockResolvedValueOnce(({ isDirectory: () => false }) as Stats) // subfolder/file2.md + .mockResolvedValueOnce(({ isDirectory: () => true }) as Stats) // subfolder/subsubfolder/ + .mockResolvedValueOnce(({ isDirectory: () => false }) as Stats) // subfolder/subsubfolder/file.md + .mockResolvedValueOnce(({ isDirectory: () => false }) as Stats) // subfolder/subsubfolder/file2.md + .mockResolvedValueOnce(({ isDirectory: () => false }) as Stats); // manifest.json + + const result = await service['generateManifestEntry'](mockCustomTutorial.absolutePath); + + expect(result).toEqual([ + { + args: { + path: '/intro.md', + }, + id: 'intro.md', + label: 'intro', + type: 'internal-link', + }, + { + children: [ + { + args: { + path: '/subfolder/file.md', + }, + id: 'file.md', + label: 'file', + type: 'internal-link', + }, + { + args: { + path: '/subfolder/file2.md', + }, + id: 'file2.md', + label: 'file2', + type: 'internal-link', + }, + { + children: [ + { + args: { + path: '/subfolder/subsubfolder/file.md', + }, + id: 'file.md', + label: 'file', + type: 'internal-link', + }, + { + args: { + path: '/subfolder/subsubfolder/file2.md', + }, + id: 'file2.md', + label: 'file2', + type: 'internal-link', + }, + ], + id: 'subsubfolder', + label: 'subsubfolder', + type: 'group', + }, + ], + id: 'subfolder', + label: 'subfolder', + type: 'group', + }, + ]); + }); + }); + + describe('getManifest', () => { + it('should successfully get manifest', async () => { + mFs.readFile.mockResolvedValueOnce(Buffer.from(JSON.stringify(mockCustomTutorialManifestJson))); + + const result = await service.getManifestJson(mockCustomTutorial.absolutePath); + + expect(result).toEqual(mockCustomTutorialManifestJson); + }); + + it('should return null when no manifest found', async () => { + mFs.readFile.mockRejectedValueOnce(new Error('No file')); + + const result = await service.getManifestJson(mockCustomTutorial.absolutePath); + + expect(result).toEqual(null); + }); + }); + + describe('generateTutorialManifest', () => { + it('should successfully generate manifest', async () => { + mFs.readFile.mockResolvedValueOnce(Buffer.from(JSON.stringify(mockCustomTutorialManifestJson))); + + const result = await service.generateTutorialManifest(mockCustomTutorial); + + expect(result).toEqual(mockCustomTutorialManifest); + }); + + it('should generate manifest without children', async () => { + mFs.readFile.mockRejectedValueOnce(new Error('No file')); + + const result = await service.generateTutorialManifest(mockCustomTutorial); + + expect(result).toEqual({ + ...mockCustomTutorialManifest, + children: [], + }); + }); + + it('should return null in case of any error', async () => { + const result = await service.generateTutorialManifest(null); + + expect(result).toEqual(null); + }); + }); +}); diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts new file mode 100644 index 0000000000..1ed1f96f19 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts @@ -0,0 +1,166 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { join, parse } from 'path'; +import { isPlainObject } from 'lodash'; +import * as fs from 'fs-extra'; +import { CustomTutorial } from 'src/modules/custom-tutorial/models/custom-tutorial'; +import { + CustomTutorialManifest, + CustomTutorialManifestType, + RootCustomTutorialManifest, +} from 'src/modules/custom-tutorial/models/custom-tutorial.manifest'; +import { plainToClass } from 'class-transformer'; + +const MANIFEST_FILE = 'manifest.json'; +const SYS_MANIFEST_FILE = '_manifest.json'; + +@Injectable() +export class CustomTutorialManifestProvider { + private logger = new Logger('CustomTutorialManifestProvider'); + + /** + * Auto generate system manifest json (_manifest.json) + * @param path + * @private + */ + private async generateManifestFile(path: string): Promise> { + try { + const manifest = { + children: await this.generateManifestEntry(path, '/'), + }; + + await fs.writeFile(join(path, SYS_MANIFEST_FILE), JSON.stringify(manifest), 'utf8'); + + return manifest; + } catch (e) { + this.logger.warn('Unable to automatically generate manifest file', e); + return null; + } + } + + /** + * Discover all .md files and folders and generate manifest based on it + * Manifest labels will be created based on files and folders names + * For files [.md] will be excluded + * All folders and files which starts from "_" and "." will be excluded also + * @param path + * @param relativePath + * @private + */ + private async generateManifestEntry(path: string, relativePath: string = '/'): Promise { + const manifest = []; + const entries = await fs.readdir(path); + + for (let i = 0; i < entries.length; i += 1) { + const entry = entries[i]; + + if (entry.startsWith('.') || entry.startsWith('_')) { + // eslint-disable-next-line no-continue + continue; + } + + const isDirectory = (await fs.lstat(join(path, entry))).isDirectory(); + + const { name, ext } = parse(entry); + + if (isDirectory) { + manifest.push({ + id: entry, + label: name, + type: CustomTutorialManifestType.Group, + children: await this.generateManifestEntry(join(path, entry), join(relativePath, entry)), + }); + } else if (ext === '.md') { + manifest.push({ + id: entry, + label: name, + type: CustomTutorialManifestType.InternalLink, + args: { + path: join(relativePath, entry), + }, + }); + } + } + + return manifest; + } + + public async isOriginalManifestExists(path: string): Promise { + return fs.existsSync(join(path, MANIFEST_FILE)); + } + + public async getOriginalManifestJson(path: string): Promise { + try { + return JSON.parse( + await fs.readFile(join(path, MANIFEST_FILE), 'utf8'), + ); + } catch (e) { + this.logger.warn('Unable to find original manifest.json'); + } + + return null; + } + + private async getManifestJsonFile(path): Promise> { + const manifest = await this.getOriginalManifestJson(path); + + if (manifest) { + return manifest; + } + + try { + return JSON.parse( + await fs.readFile(join(path, SYS_MANIFEST_FILE), 'utf8'), + ); + } catch (e) { + this.logger.warn('Unable to get _manifest for tutorial'); + } + + return await this.generateManifestFile(path); + } + + /** + * Try to get and parse manifest.json + * In case of any error will not throw an error but return null + * In this case tutorial will be displayed but without anything inside + * So user will be able to fix (re-import) tutorial or remove it + * @param path + */ + public async getManifestJson(path: string): Promise { + try { + const manifestJson = await this.getManifestJsonFile(path); + + if (!isPlainObject(manifestJson)) { + return null; + } + + return plainToClass(RootCustomTutorialManifest, manifestJson, { excludeExtraneousValues: true }); + } catch (e) { + this.logger.warn('Unable to get manifest for tutorial'); + return null; + } + } + + /** + * Generate custom manifest based on manifest.json inside tutorial folder and + * additional data from local database + * @param tutorial + */ + public async generateTutorialManifest(tutorial: CustomTutorial): Promise { + try { + const manifest = await this.getManifestJson(tutorial.absolutePath) || {} as RootCustomTutorialManifest; + + return { + ...manifest, + _actions: tutorial.actions, + _path: tutorial.path, + type: CustomTutorialManifestType.Group, + id: tutorial.id, + label: tutorial.name || manifest?.label, + children: manifest?.children || [], + }; + } catch (e) { + this.logger.warn('Unable to generate manifest for tutorial', e); + return null; + } + } +} diff --git a/redisinsight/api/src/modules/custom-tutorial/repositories/custom-tutorial.repository.ts b/redisinsight/api/src/modules/custom-tutorial/repositories/custom-tutorial.repository.ts new file mode 100644 index 0000000000..93307e6fbd --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/repositories/custom-tutorial.repository.ts @@ -0,0 +1,27 @@ +import { CustomTutorial } from 'src/modules/custom-tutorial/models/custom-tutorial'; + +export abstract class CustomTutorialRepository { + /** + * Create custom tutorial entity + * @param model + * @return CustomTutorial + */ + abstract create(model: CustomTutorial): Promise; + + /** + * Create custom tutorial entity + * @param id + * @return CustomTutorial + */ + abstract get(id: string): Promise; + + /** + * Get list of custom tutorials + */ + abstract list(): Promise; + + /** + * Delete custom tutorial by id + */ + abstract delete(id: string): Promise; +} diff --git a/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.spec.ts b/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.spec.ts new file mode 100644 index 0000000000..926f6c5857 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.spec.ts @@ -0,0 +1,84 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + mockCustomTutorial, mockCustomTutorialEntity, mockCustomTutorialId, + mockRepository, + MockType, +} from 'src/__mocks__'; +import { + LocalCustomTutorialRepository, +} from 'src/modules/custom-tutorial/repositories/local.custom-tutorial.repository'; +import { CustomTutorialEntity } from 'src/modules/custom-tutorial/entities/custom-tutorial.entity'; + +describe('LocalCustomTutorialRepository', () => { + let service: LocalCustomTutorialRepository; + let repository: MockType>; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LocalCustomTutorialRepository, + { + provide: getRepositoryToken(CustomTutorialEntity), + useFactory: mockRepository, + }, + ], + }).compile(); + + repository = await module.get(getRepositoryToken(CustomTutorialEntity)); + service = await module.get(LocalCustomTutorialRepository); + + repository.findOneBy.mockResolvedValue(mockCustomTutorialEntity); + repository.createQueryBuilder().getMany.mockResolvedValue([ + mockCustomTutorialEntity, + mockCustomTutorialEntity, + ]); + repository.save.mockResolvedValue(mockCustomTutorialEntity); + }); + + describe('get', () => { + it('should return custom tutorial model', async () => { + const result = await service.get(mockCustomTutorialId); + + expect(result).toEqual(mockCustomTutorial); + }); + + it('should return null when custom tutorial was not found', async () => { + repository.findOneBy.mockResolvedValue(undefined); + + const result = await service.get(mockCustomTutorialId); + + expect(result).toEqual(undefined); + }); + }); + + describe('list', () => { + it('should return list of custom tutorials', async () => { + expect(await service.list()).toEqual([ + mockCustomTutorial, + mockCustomTutorial, + ]); + }); + }); + + describe('create', () => { + it('should create custom tutorial', async () => { + const result = await service.create(mockCustomTutorial); + + expect(result).toEqual(mockCustomTutorial); + expect(repository.save).toHaveBeenCalledWith({ + ...mockCustomTutorialEntity, + createdAt: jasmine.anything(), + }); + }); + }); + + describe('delete', () => { + it('should delete custom tutorial by id', async () => { + expect(await service.delete(mockCustomTutorialId)).toEqual(undefined); + }); + }); +}); diff --git a/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts b/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts new file mode 100644 index 0000000000..3864d49b49 --- /dev/null +++ b/redisinsight/api/src/modules/custom-tutorial/repositories/local.custom-tutorial.repository.ts @@ -0,0 +1,52 @@ +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { classToClass } from 'src/utils'; +import { CustomTutorialEntity } from 'src/modules/custom-tutorial/entities/custom-tutorial.entity'; +import { CustomTutorial } from 'src/modules/custom-tutorial/models/custom-tutorial'; +import { CustomTutorialRepository } from 'src/modules/custom-tutorial/repositories/custom-tutorial.repository'; + +export class LocalCustomTutorialRepository extends CustomTutorialRepository { + constructor( + @InjectRepository(CustomTutorialEntity) + private readonly repository: Repository, + ) { + super(); + } + + /** + * @inheritDoc + */ + public async create(model: CustomTutorial): Promise { + const entity = classToClass(CustomTutorialEntity, model); + + entity.createdAt = new Date(); + + return classToClass(CustomTutorial, await this.repository.save(entity)); + } + + /** + * @inheritDoc + */ + public async list(): Promise { + const entities = await this.repository + .createQueryBuilder('t') + .orderBy('t.createdAt', 'DESC') + .getMany(); + + return entities.map((entity) => classToClass(CustomTutorial, entity)); + } + + /** + * @inheritDoc + */ + public async get(id: string): Promise { + return classToClass(CustomTutorial, await this.repository.findOneBy({ id })); + } + + /** + * @inheritDoc + */ + public async delete(id: string): Promise { + await this.repository.delete({ id }); + } +} diff --git a/redisinsight/api/src/modules/statics-management/providers/auto-updated-statics.provider.ts b/redisinsight/api/src/modules/statics-management/providers/auto-updated-statics.provider.ts index 427d8a5b02..0f16595d34 100644 --- a/redisinsight/api/src/modules/statics-management/providers/auto-updated-statics.provider.ts +++ b/redisinsight/api/src/modules/statics-management/providers/auto-updated-statics.provider.ts @@ -22,9 +22,11 @@ export class AutoUpdatedStaticsProvider implements OnModuleInit { /** * Updates latest json on startup */ - onModuleInit() { + async onModuleInit() { + // wait for populating default data (should take milliseconds) + await this.initDefaults().catch((e) => this.logger.warn('Unable to populate default data', e)); // async operation to not wait for it and not block user in case when no internet connection - this.initDefaults().finally(this.autoUpdate.bind(this)); + this.autoUpdate(); } /** @@ -65,7 +67,7 @@ export class AutoUpdatedStaticsProvider implements OnModuleInit { const latestArchive = await this.getLatestArchive(); if (latestArchive) { - const zip = new AdmZip(latestArchive); + const zip = new AdmZip(latestArchive as Buffer); await fs.remove(this.options.destinationPath); await zip.extractAllTo(this.options.destinationPath, true); await fs.writeFile( diff --git a/redisinsight/api/src/modules/statics-management/statics-management.module.ts b/redisinsight/api/src/modules/statics-management/statics-management.module.ts index 86326e2b6c..97bc86d076 100644 --- a/redisinsight/api/src/modules/statics-management/statics-management.module.ts +++ b/redisinsight/api/src/modules/statics-management/statics-management.module.ts @@ -27,6 +27,13 @@ const CONTENT_CONFIG = config.get('content'); fallthrough: false, }, }), + ServeStaticModule.forRoot({ + serveRoot: SERVER_CONFIG.customTutorialsUri, + rootPath: join(PATH_CONFIG.customTutorials), + serveStaticOptions: { + fallthrough: false, + }, + }), ServeStaticModule.forRoot({ serveRoot: SERVER_CONFIG.contentUri, rootPath: join(PATH_CONFIG.content), diff --git a/redisinsight/api/test/api/analytics/analytics.test.ts b/redisinsight/api/test/api/analytics/analytics.test.ts new file mode 100644 index 0000000000..05ef06d346 --- /dev/null +++ b/redisinsight/api/test/api/analytics/analytics.test.ts @@ -0,0 +1,34 @@ +import { + expect, + describe, + it, + deps, + requirements, +} from '../deps'; +const { analytics } = deps; + + +describe('Analytics', () => { + requirements('rte.serverType=local'); + + it('APPLICATION_STARTED', () => { + const appStarted = analytics.findEvent({ + event: 'APPLICATION_STARTED', + }) + + const appFirstStarted = analytics.findEvent({ + event: 'APPLICATION_FIRST_START', + }) + + const found = appStarted || appFirstStarted; + + if (!found) { + fail('APPLICATION_STARTED or APPLICATION_FIRST_START events were not found'); + } + + expect(found?.properties).to.have.all.keys('appVersion', 'osPlatform', 'buildType'); + expect(found?.properties?.appVersion).to.be.a('string'); + expect(found?.properties?.osPlatform).to.be.a('string'); + expect(found?.properties?.buildType).to.be.a('string'); + }); +}); diff --git a/redisinsight/api/test/api/cli/POST-databases-id-cli-uuid-send_command.test.ts b/redisinsight/api/test/api/cli/POST-databases-id-cli-uuid-send_command.test.ts index 235fd902ce..79aeef190a 100644 --- a/redisinsight/api/test/api/cli/POST-databases-id-cli-uuid-send_command.test.ts +++ b/redisinsight/api/test/api/cli/POST-databases-id-cli-uuid-send_command.test.ts @@ -9,9 +9,9 @@ import { generateInvalidDataTestCases, validateInvalidDataTestCase, validateApiCall, - requirements, + requirements, serverConfig } from '../deps'; -const { server, request, constants, rte } = deps; +const { server, request, constants, rte, analytics } = deps; // endpoint to test const endpoint = (instanceId = constants.TEST_INSTANCE_ID, uuid = constants.TEST_CLI_UUID_1) => @@ -87,6 +87,18 @@ describe('POST /databases/:instanceId/cli/:uuid/send-command', () => { }, after: async () => { expect(await rte.client.get(constants.TEST_STRING_KEY_1)).to.eql(constants.TEST_STRING_VALUE_1); + await analytics.waitForEvent({ + event: 'CLI_COMMAND_EXECUTED', + properties: { + databaseId: constants.TEST_INSTANCE_ID, + commandType: 'core', + moduleName: 'n/a', + capability: 'string', + command: 'SET', + outputFormat: 'TEXT', + buildType: serverConfig.get('server').buildType, + }, + }); } }, { diff --git a/redisinsight/api/test/api/custom-tutorials/POST-custom-tutorials.test.ts b/redisinsight/api/test/api/custom-tutorials/POST-custom-tutorials.test.ts new file mode 100644 index 0000000000..983bd5499a --- /dev/null +++ b/redisinsight/api/test/api/custom-tutorials/POST-custom-tutorials.test.ts @@ -0,0 +1,303 @@ +import { + expect, + describe, + it, + deps, + validateApiCall, + AdmZip, + fsExtra, + path, + serverConfig, requirements, + before, + _, +} from '../deps'; +import { getBaseURL } from '../../helpers/server'; +const { server, request } = deps; + +// create endpoint +const creatEndpoint = () => request(server).post(`/custom-tutorials`); +const manifestEndpoint = () => request(server).get(`/custom-tutorials/manifest`); +const deleteEndpoint = (id: string) => () => request(server).delete(`/custom-tutorials/${id}`); + +const customTutorialsFolder = serverConfig.get('dir_path').customTutorials; +const staticsFolder = serverConfig.get('dir_path').staticDir; + + +const getZipArchive = () => { + const zipArchive = new AdmZip(); + + zipArchive.addFile('info.md', Buffer.from('# info.md', 'utf8')); + zipArchive.addFile('info.json', Buffer.from('# info.json', 'utf8')); + zipArchive.addFile('info.tar', Buffer.from('# info.tar', 'utf8')); + zipArchive.addFile('_info.tar', Buffer.from('# info.tar', 'utf8')); + zipArchive.addFile('folder/file.md', Buffer.from('# folder/file.md', 'utf8')); + zipArchive.addFile('.folder/file.md', Buffer.from('# .folder/file.md', 'utf8')); + zipArchive.addFile('.folder/file2.md', Buffer.from('# .folder/file2.md', 'utf8')); + zipArchive.addFile('_folder/file.md', Buffer.from('# _folder/file.md', 'utf8')); + zipArchive.addFile('__MACOSX/file.md', Buffer.from('# __MACOSX/file.md', 'utf8')); + + return zipArchive; +} + +const checkFilesUnarchivedFiles = (zip: AdmZip, tutorialFolder = '/') => { + zip.getEntries().forEach((entry) => { + expect(fsExtra.existsSync(path.join( + customTutorialsFolder, + tutorialFolder, + entry.entryName, + ))).eq(!entry.entryName.startsWith('__MACOSX')); + }); +} + +const autoGeneratedManifest = { + children: [ + { + id: 'folder', + type: 'group', + label: 'folder', + children: [ + { + id: 'file.md', + type: 'internal-link', + label: 'file', + args: { path: '/folder/file.md' } + } + ] + }, + { + id: 'info.md', + type: 'internal-link', + label: 'info', + args: { path: '/info.md' } + } + ], +}; + +const testManifest = { + id: 'id', + type: 'group', + label: 'my tutorial', + children: [ + { + id: 'main-page', + type: 'internal-link', + label: 'INFO', + args: { path: '/info.md' } + }, + { + id: 'some-file', + type: 'internal-link', + label: 'FILE', + args: { path: '/folder/file.md' } + } + ], +}; + +const globalManifest = { + id: 'custom-tutorials', + label: 'MY TUTORIALS', + type: 'group', + _actions: [ + 'create', + ], + args: { + initialIsOpen: true, + withBorder: true, + }, + children: [], +}; + +describe('POST /custom-tutorials', () => { + requirements('rte.serverType=local'); + + describe('Common', () => { + before(async () => { + await fsExtra.remove(customTutorialsFolder); + }); + + it('should import tutorial from file and generate _manifest.json', async () => { + const zip = getZipArchive(); + zip.writeZip(path.join(staticsFolder, 'test_no_manifest.zip')); + + // create tutorial + await validateApiCall({ + endpoint: creatEndpoint, + attach: ['file', zip.toBuffer(), 'a.zip'], + statusCode: 201, + checkFn: async ({ body }) => { + const tutorialRootManifest = { + ...autoGeneratedManifest, + type: 'group', + id: body.id, + label: 'a', + _actions: [ 'delete' ], + _path: `/${body.id}`, + }; + + globalManifest.children = [tutorialRootManifest].concat(globalManifest.children); + + expect(body).deep.eq(tutorialRootManifest); + checkFilesUnarchivedFiles(zip, body?._path); + expect(JSON.parse(await fsExtra.readFile(path.join(customTutorialsFolder, body._path, '_manifest.json'), 'utf8'))) + .deep.eq(_.omit(body, ['_actions', '_path', 'id', 'label', 'type'])); + expect((await fsExtra.readdir(customTutorialsFolder)).length).eq(1); + }, + }); + + // global manifest + await validateApiCall({ + endpoint: manifestEndpoint, + checkFn: async ({ body }) => { + expect(body).deep.eq(globalManifest); + }, + }); + }); + + it('should import tutorial from file with manifest', async () => { + const zip = getZipArchive(); + zip.addFile('manifest.json', Buffer.from(JSON.stringify(testManifest), 'utf8')); + zip.writeZip(path.join(staticsFolder, 'test.zip')); + + await validateApiCall({ + endpoint: creatEndpoint, + attach: ['file', zip.toBuffer(), 'a.zip'], + statusCode: 201, + checkFn: async ({ body }) => { + const tutorialRootManifest = { + ...testManifest, + type: 'group', + id: body.id, + _actions: [ 'delete' ], + _path: `/${body.id}`, + }; + + globalManifest.children = [tutorialRootManifest].concat(globalManifest.children); + + expect(body).deep.eq(tutorialRootManifest); + checkFilesUnarchivedFiles(zip, body?._path); + expect({ + ...JSON.parse(await fsExtra.readFile(path.join(customTutorialsFolder, body._path, 'manifest.json'), 'utf8')), + id: body.id, + }).deep.eq({ + ...(_.omit(body, ['_actions', '_path'])), + }); + expect((await fsExtra.readdir(customTutorialsFolder)).length).eq(2); + }, + }); + + // global manifest + await validateApiCall({ + endpoint: manifestEndpoint, + checkFn: async ({ body }) => { + expect(body).deep.eq(globalManifest); + }, + }); + }); + + it('should import tutorial from the external link with manifest', async () => { + const zip = new AdmZip(path.join(staticsFolder, 'test.zip')); + const link = `${getBaseURL()}/static/test.zip`; + + await validateApiCall({ + endpoint: creatEndpoint, + fields: [ + ['link', link], + ], + statusCode: 201, + checkFn: async ({ body }) => { + const tutorialRootManifest = { + ...testManifest, + type: 'group', + id: body.id, + _actions: [ 'delete', 'sync' ], + _path: `/${body.id}`, + }; + + globalManifest.children = [tutorialRootManifest].concat(globalManifest.children); + + expect(body).deep.eq(tutorialRootManifest); + checkFilesUnarchivedFiles(zip, body?._path); + expect({ + ...JSON.parse(await fsExtra.readFile(path.join(customTutorialsFolder, body._path, 'manifest.json'), 'utf8')), + id: body.id, + }).deep.eq({ + ...(_.omit(body, ['_actions', '_path'])), + }); + expect((await fsExtra.readdir(customTutorialsFolder)).length).eq(3); + }, + }); + + // global manifest + await validateApiCall({ + endpoint: manifestEndpoint, + checkFn: async ({ body }) => { + expect(body).deep.eq(globalManifest); + }, + }); + }); + + it('should delete tutorial', async () => { + expect((await fsExtra.readdir(customTutorialsFolder)).length).eq(3); + await validateApiCall({ + endpoint: manifestEndpoint, + checkFn: async ({ body }) => { + expect(body.children.length).eq(3); + }, + }); + + const toDelete = globalManifest.children.shift(); + await validateApiCall({ + endpoint: deleteEndpoint(toDelete.id), + }); + + expect((await fsExtra.readdir(customTutorialsFolder)).length).eq(2); + await validateApiCall({ + endpoint: manifestEndpoint, + checkFn: async ({ body }) => { + expect(body.children.length).eq(2); + expect(body).deep.eq(globalManifest); + }, + }); + }); + + it('should delete tutorial and not fail even if folder does not exist', async () => { + expect((await fsExtra.readdir(customTutorialsFolder)).length).eq(2); + await validateApiCall({ + endpoint: manifestEndpoint, + checkFn: async ({ body }) => { + expect(body.children.length).eq(2); + }, + }); + + const toDelete = globalManifest.children.shift(); + + await fsExtra.remove(path.join(customTutorialsFolder, toDelete.id)); + expect((await fsExtra.readdir(customTutorialsFolder)).length).eq(1); + + await validateApiCall({ + endpoint: deleteEndpoint(toDelete.id), + }); + + expect((await fsExtra.readdir(customTutorialsFolder)).length).eq(1); + await validateApiCall({ + endpoint: manifestEndpoint, + checkFn: async ({ body }) => { + expect(body.children.length).eq(1); + expect(body).deep.eq(globalManifest); + }, + }); + }); + + it('should fail when trying to delete not existing tutorial', async () => { + await validateApiCall({ + endpoint: deleteEndpoint('not existing'), + statusCode: 404, + responseBody: { + statusCode: 404, + message: 'Custom Tutorial was not found.', + error: 'Not Found', + } + }); + }); + }); +}); diff --git a/redisinsight/api/test/api/database/POST-databases.test.ts b/redisinsight/api/test/api/database/POST-databases.test.ts index eed5ab6328..f26673428a 100644 --- a/redisinsight/api/test/api/database/POST-databases.test.ts +++ b/redisinsight/api/test/api/database/POST-databases.test.ts @@ -7,10 +7,10 @@ import { requirements, validateApiCall, after, - generateInvalidDataTestCases, validateInvalidDataTestCase, getMainCheckFn + generateInvalidDataTestCases, validateInvalidDataTestCase, getMainCheckFn, serverConfig, } from '../deps'; import { databaseSchema } from './constants'; -const { rte, request, server, localDb, constants } = deps; +const { rte, request, server, localDb, constants, analytics } = deps; const endpoint = () => request(server).post(`/${constants.API.DATABASES}`); @@ -136,6 +136,38 @@ describe('POST /databases', () => { connectionType: constants.STANDALONE, new: true, }, + checkFn: async ({ body }) => { + // todo: find a way to test rest of the fields + await analytics.waitForEvent({ + event: 'CONFIG_DATABASES_DATABASE_ADDED', + properties: { + databaseId: body.id, + connectionType: body.connectionType, + provider: body.provider, + useTLS: 'disabled', + verifyTLSCertificate: 'disabled', + useTLSAuthClients: 'disabled', + useSNI: 'disabled', + useSSH: 'disabled', + version: rte.env.version, + // numberOfKeys: 8, + // numberOfKeysRange: '0 - 500 000', + // totalMemory: 881632, + // numberedDatabases: 16, + // numberOfModules: 0, + timeout: body.timeout / 1000, + // RediSearch: { loaded: false }, + // RedisAI: { loaded: false }, + // RedisGraph: { loaded: false }, + // RedisGears: { loaded: false }, + // RedisBloom: { loaded: false }, + // RedisJSON: { loaded: false }, + // RedisTimeSeries: { loaded: false }, + // customModules: [], + buildType: serverConfig.get('server').buildType, + }, + }); + }, }); }); describe('Enterprise', () => { diff --git a/redisinsight/api/test/api/deps.ts b/redisinsight/api/test/api/deps.ts index e756baf48b..158cdc5bce 100644 --- a/redisinsight/api/test/api/deps.ts +++ b/redisinsight/api/test/api/deps.ts @@ -1,3 +1,6 @@ +import { getAnalytics } from '../helpers/analytics'; +export { createAnalytics } from '../helpers/analytics'; + export * from '../helpers/test'; import * as request from 'supertest'; import * as chai from 'chai'; @@ -16,12 +19,23 @@ export async function depsInit () { if(constants.TEST_CLOUD_RTE) { await initCloudDatabase(); } + + // initialize analytics module + deps.analytics = await getAnalytics(); + // initializing backend server deps.server = await getServer(); // initializing Redis Test Environment deps.rte = await redis.initRTE(); - testEnv.rte = deps.rte.env; + + testEnv.rte = deps.rte.env; + + if (typeof deps.server === 'string') { + testEnv.rte.serverType = 'docker'; + } else { + testEnv.rte.serverType = 'local'; + } // initializing local database await localDb.initLocalDb(deps.rte, deps.server); @@ -33,6 +47,7 @@ export const deps = { request, expect: chai.expect, server: null, + analytics: null, getSocket, rte: null, testEnv, diff --git a/redisinsight/api/test/helpers/analytics.ts b/redisinsight/api/test/helpers/analytics.ts new file mode 100644 index 0000000000..872d82e3d4 --- /dev/null +++ b/redisinsight/api/test/helpers/analytics.ts @@ -0,0 +1,54 @@ +import { EventEmitter } from 'events'; +import * as nock from 'nock'; +import * as _ from 'lodash'; +import { isMatch } from 'lodash'; + +let analytics; + +export class Analytics extends EventEmitter { + public messages = []; + + constructor() { + super(); + const scope = nock('https://api.segment.io') + .post('/v1/batch', (body) => { + const batchMessages = body?.batch || []; + this.messages = this.messages.concat(batchMessages); + this.emit('batch', batchMessages); + return true; + }) + .reply(200, {}) + + scope.persist(); + } + + public findEvent(event: any, messages = this.messages) { + return _.find(messages, (message) => { + return isMatch(message, event); + }); + } + + public async waitForEvent(event) { + await new Promise((res, rej) => { + this.once('batch', (batch) => { + const exists = this.findEvent(event, batch); + + if (!exists) { + rej(new Error(`Unable to find event:\n${JSON.stringify(event)}\nin the events batch:\n${JSON.stringify(batch)}`)); + } + + res(exists); + }); + + setTimeout(() => rej(new Error(`No event ${JSON.stringify(event)} received in 10s`)), 10000); + }); + } +} + +export const getAnalytics = () => { + return analytics || createAnalytics(); +}; + +export const createAnalytics = () => { + return new Analytics(); +} diff --git a/redisinsight/api/test/helpers/local-db.ts b/redisinsight/api/test/helpers/local-db.ts index 8a5842535e..4bbc4f37e8 100644 --- a/redisinsight/api/test/helpers/local-db.ts +++ b/redisinsight/api/test/helpers/local-db.ts @@ -20,6 +20,7 @@ export const repositories = { NOTIFICATION: 'NotificationEntity', DATABASE_ANALYSIS: 'DatabaseAnalysisEntity', BROWSER_HISTORY: 'BrowserHistoryEntity', + CUSTOM_TUTORIAL: 'CustomTutorialEntity', } let localDbConnection; @@ -482,6 +483,7 @@ export const initAgreements = async () => { agreements.data = JSON.stringify({ eula: true, encryption: constants.TEST_ENCRYPTION_STRATEGY === 'KEYTAR', + analytics: true, }); await rep.save(agreements); @@ -519,6 +521,7 @@ const truncateAll = async () => { await (await getRepository(repositories.DATABASE)).clear(); await (await getRepository(repositories.CA_CERT_REPOSITORY)).clear(); await (await getRepository(repositories.CLIENT_CERT_REPOSITORY)).clear(); + await (await getRepository(repositories.CUSTOM_TUTORIAL)).clear(); await (await resetSettings()); } diff --git a/redisinsight/api/test/helpers/server.ts b/redisinsight/api/test/helpers/server.ts index 28e4a1e327..eade20c49d 100644 --- a/redisinsight/api/test/helpers/server.ts +++ b/redisinsight/api/test/helpers/server.ts @@ -3,6 +3,8 @@ import { AppModule } from 'src/app.module'; import * as bodyParser from 'body-parser'; import { constants } from './constants'; import { connect, Socket } from "socket.io-client"; +import * as express from 'express'; +import { serverConfig } from './test'; /** * TEST_BE_SERVER - url to already running API that we want to test @@ -38,6 +40,7 @@ export const getServer = async () => { const app = moduleFixture.createNestApplication(); app.use(bodyParser.json({ limit: '512mb' })); app.use(bodyParser.urlencoded({ limit: '512mb', extended: true })); + app.use('/static', express.static(serverConfig.get('dir_path').staticDir)) await app.init(); server = await app.getHttpServer(); @@ -49,6 +52,8 @@ export const getServer = async () => { return server; } +export const getBaseURL = (): string => baseUrl; + export const getSocket = async (namespace: string, options = {}): Promise => { return new Promise((resolve, reject) => { const base = new URL(baseUrl); diff --git a/redisinsight/api/test/helpers/test.ts b/redisinsight/api/test/helpers/test.ts index 7b73ca38e9..e1cd1c31c6 100644 --- a/redisinsight/api/test/helpers/test.ts +++ b/redisinsight/api/test/helpers/test.ts @@ -1,14 +1,18 @@ import { describe, it, before, after, beforeEach } from 'mocha'; import * as util from 'util'; import * as _ from 'lodash'; +import * as path from 'path'; import * as fs from 'fs'; +import * as fsExtra from 'fs-extra'; import * as chai from 'chai'; import * as Joi from 'joi'; +import * as AdmZip from 'adm-zip'; import * as diff from 'object-diff'; import { cloneDeep, isMatch, isObject, set, isArray } from 'lodash'; import { generateInvalidDataArray } from './test/dataGenerator'; +import serverConfig from 'src/utils/config'; -export { _, fs } +export { _, path, fs, fsExtra, AdmZip, serverConfig } export const expect = chai.expect; export const testEnv: Record = {}; export { Joi, describe, it, before, after, beforeEach }; @@ -20,6 +24,7 @@ interface ITestCaseInput { endpoint: Function; // function that returns prepared supertest with url data?: any; attach?: any[]; + fields?: [string, string][]; query?: any; statusCode?: number; responseSchema?: Joi.AnySchema; @@ -37,6 +42,7 @@ export const validateApiCall = async function ({ endpoint, data, attach, + fields, query, statusCode = 200, responseSchema, @@ -54,6 +60,12 @@ export const validateApiCall = async function ({ request.attach(...attach); } + if (fields?.length) { + fields.forEach((field) => { + request.field(...field); + }) + } + // data to send with url query string if (query) { request.query(query); diff --git a/redisinsight/api/yarn.lock b/redisinsight/api/yarn.lock index 1ca7fdaab1..868f506ff1 100644 --- a/redisinsight/api/yarn.lock +++ b/redisinsight/api/yarn.lock @@ -653,6 +653,11 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@lukeed/csprng@^1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@lukeed/csprng/-/csprng-1.0.1.tgz#625e93a0edb2c830e3c52ce2d67b9d53377c6a66" + integrity sha512-uSvJdwQU5nK+Vdf6zxcWAY2A8r7uqe+gePwLWzJ+fsQehq18pc0I2hJKwypZ2aLM90+Er9u1xn4iLJPZ+xlL4g== + "@mapbox/node-pre-gyp@^1.0.0": version "1.0.9" resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.9.tgz#09a8781a3a036151cdebbe8719d6f8b25d4058bc" @@ -906,11 +911,23 @@ resolved "https://registry.yarnpkg.com/@sqltools/formatter/-/formatter-1.2.3.tgz#1185726610acc37317ddab11c3c7f9066966bd20" integrity sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg== +"@tokenizer/token@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" + integrity sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A== + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== +"@types/adm-zip@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@types/adm-zip/-/adm-zip-0.5.0.tgz#94c90a837ce02e256c7c665a6a1eb295906333c1" + integrity sha512-FCJBJq9ODsQZUNURo5ILAQueuA8WJhRvuihS3ke2iI25mJlfV2LK8jG2Qj2z2AWg8U0FtWWqBHVRetceLskSaw== + dependencies: + "@types/node" "*" + "@types/axios@^0.14.0": version "0.14.0" resolved "https://registry.yarnpkg.com/@types/axios/-/axios-0.14.0.tgz#ec2300fbe7d7dddd7eb9d3abf87999964cafce46" @@ -2026,7 +2043,7 @@ buildcheck@0.0.3: resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.3.tgz#70451897a95d80f7807e68fc412eb2e7e35ff4d5" integrity sha512-pziaA+p/wdVImfcbsZLNF32EiWyujlQLwolMqUQE8xpKNOH7KmZQaY8sXN7DGOEzPAElo9QTaeNRfGnf3iOJbA== -busboy@^1.0.0: +busboy@^1.0.0, busboy@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== @@ -2478,6 +2495,16 @@ concat-stream@^1.5.2: readable-stream "^2.2.2" typedarray "^0.0.6" +concat-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1" + integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.0.2" + typedarray "^0.0.6" + concurrently@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-5.3.0.tgz#7500de6410d043c912b2da27de3202cb489b1e7b" @@ -3695,6 +3722,15 @@ file-stream-rotator@^0.5.7: dependencies: moment "^2.11.2" +file-type@^16.5.4: + version "16.5.4" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-16.5.4.tgz#474fb4f704bee427681f98dd390058a172a6c2fd" + integrity sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw== + dependencies: + readable-web-to-node-stream "^3.0.0" + strtok3 "^6.2.4" + token-types "^4.1.1" + fill-range@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" @@ -5353,6 +5389,11 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= +json-stringify-safe@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== + json5@2.x, json5@^2.1.2: version "2.2.0" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3" @@ -6044,6 +6085,19 @@ nest-winston@^1.4.0: cli-color "^2.0.1" fast-safe-stringify "^2.1.1" +nestjs-form-data@^1.8.7: + version "1.8.7" + resolved "https://registry.yarnpkg.com/nestjs-form-data/-/nestjs-form-data-1.8.7.tgz#ccdbc2060849e34018841bfba557de37ae64abdb" + integrity sha512-mk17APNXELILClea2nwffRrD/NK5Q6zulTJCzNPxwMWfWucHO2HD7Ftjwg2BVnwO27QgqILDLuaO6LEpP6Ng4w== + dependencies: + uid "^2.0.0" + append-field "^1.0.0" + busboy "^1.6.0" + concat-stream "^2.0.0" + file-type "^16.5.4" + mkdirp "^1.0.4" + type-is "^1.6.18" + next-tick@1, next-tick@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" @@ -6059,6 +6113,16 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +nock@^13.3.0: + version "13.3.0" + resolved "https://registry.yarnpkg.com/nock/-/nock-13.3.0.tgz#b13069c1a03f1ad63120f994b04bfd2556925768" + integrity sha512-HHqYQ6mBeiMc+N038w8LkMpDCRquCHWeNmN3v6645P3NhN2+qXOBqvPqo7Rt1VyCMzKhJ733wZqw5B7cQVFNPg== + dependencies: + debug "^4.1.0" + json-stringify-safe "^5.0.1" + lodash "^4.17.21" + propagate "^2.0.0" + node-abi@^3.3.0: version "3.24.0" resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.24.0.tgz#b9d03393a49f2c7e147d0c99f180e680c27c1599" @@ -6624,6 +6688,11 @@ pathval@^1.1.1: resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== +peek-readable@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/peek-readable/-/peek-readable-4.1.0.tgz#4ece1111bf5c2ad8867c314c81356847e8a62e72" + integrity sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg== + picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -6744,6 +6813,11 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" +propagate@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45" + integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag== + proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -6887,7 +6961,7 @@ readable-stream@^2.0.1, readable-stream@^2.2.2, readable-stream@^2.3.5: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: +readable-stream@^3.0.2, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== @@ -6896,6 +6970,13 @@ readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-web-to-node-stream@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz#5d52bb5df7b54861fd48d015e93a2cb87b3ee0bb" + integrity sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw== + dependencies: + readable-stream "^3.6.0" + readdirp@~3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e" @@ -7787,6 +7868,14 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= +strtok3@^6.2.4: + version "6.3.0" + resolved "https://registry.yarnpkg.com/strtok3/-/strtok3-6.3.0.tgz#358b80ffe6d5d5620e19a073aa78ce947a90f9a0" + integrity sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw== + dependencies: + "@tokenizer/token" "^0.3.0" + peek-readable "^4.1.0" + superagent@^3.8.3: version "3.8.3" resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.8.3.tgz#460ea0dbdb7d5b11bc4f78deba565f86a178e128" @@ -8062,6 +8151,14 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +token-types@^4.1.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/token-types/-/token-types-4.2.1.tgz#0f897f03665846982806e138977dbe72d44df753" + integrity sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ== + dependencies: + "@tokenizer/token" "^0.3.0" + ieee754 "^1.2.1" + tough-cookie@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" @@ -8265,7 +8362,7 @@ type-fest@^0.8.0, type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== -type-is@^1.6.4, type-is@~1.6.18: +type-is@^1.6.18, type-is@^1.6.4, type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== @@ -8328,6 +8425,13 @@ typescript@^4.0.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.4.tgz#a17d3a0263bf5c8723b9c52f43c5084edf13c2e8" integrity sha512-VgYs2A2QIRuGphtzFV7aQJduJ2gyfTljngLzjpfW9FoYZF6xuw1W0vW9ghCKLfcWrCFxK81CSGRAvS1pn4fIUg== +uid@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/uid/-/uid-2.0.1.tgz#a3f57c962828ea65256cd622fc363028cdf4526b" + integrity sha512-PF+1AnZgycpAIEmNtjxGBVmKbZAQguaa4pBUq6KNaGEcpzZ2klCNZLM34tsjp76maN00TttiiUf6zkIBpJQm2A== + dependencies: + "@lukeed/csprng" "^1.0.0" + unbox-primitive@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471" diff --git a/redisinsight/ui/src/assets/img/workbench/my-tutorials.svg b/redisinsight/ui/src/assets/img/workbench/my-tutorials.svg new file mode 100644 index 0000000000..eb0d3a9b9b --- /dev/null +++ b/redisinsight/ui/src/assets/img/workbench/my-tutorials.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.spec.tsx b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.spec.tsx index ef6e712a3b..e63edba39c 100644 --- a/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.spec.tsx +++ b/redisinsight/ui/src/components/import-databases-dialog/ImportDatabasesDialog.spec.tsx @@ -1,9 +1,8 @@ -import { waitFor } from '@testing-library/react' import { cloneDeep } from 'lodash' import React from 'react' import { importInstancesFromFile, importInstancesSelector } from 'uiSrc/slices/instances/instances' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { render, screen, fireEvent, mockedStore, cleanup } from 'uiSrc/utils/test-utils' +import { render, screen, fireEvent, mockedStore, cleanup, act } from 'uiSrc/utils/test-utils' import ImportDatabasesDialog from './ImportDatabasesDialog' @@ -60,7 +59,7 @@ describe('ImportDatabasesDialog', () => { type: 'application/JSON', }) - await waitFor(() => { + await act(() => { fireEvent.change( screen.getByTestId('import-databases-input-file'), { diff --git a/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss b/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss index cb918d12c3..ea931cdfec 100644 --- a/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss +++ b/redisinsight/ui/src/components/import-databases-dialog/styles.module.scss @@ -31,6 +31,7 @@ .euiButtonEmpty.euiButtonEmpty--primary.euiFilePicker__clearButton, .euiButtonEmpty.euiButtonEmpty--primary.euiFilePicker__clearButton .euiButtonEmpty__text { color: var(--externalLinkColor) !important; + text-transform: lowercase; } .euiModalFooter { diff --git a/redisinsight/ui/src/components/upload-file/UploadFile.tsx b/redisinsight/ui/src/components/upload-file/UploadFile.tsx index 38d5786b6b..211e0787e2 100644 --- a/redisinsight/ui/src/components/upload-file/UploadFile.tsx +++ b/redisinsight/ui/src/components/upload-file/UploadFile.tsx @@ -4,14 +4,15 @@ import { EuiButtonEmpty, EuiText, EuiIcon } from '@elastic/eui' import styles from './styles.module.scss' export interface Props { - onFileChange: ({ target: { files } }: { target: { files: FileList | null } }) => void - onClick: () => void + onFileChange: (event: React.ChangeEvent) => void + onClick?: () => void + accept?: string } -const UploadFile = ({ onFileChange, onClick }: Props) => ( +const UploadFile = ({ onFileChange, onClick, accept }: Props) => ( onClick?.()} >