diff --git a/redisinsight/api/src/modules/database-analysis/database-analysis.controller.ts b/redisinsight/api/src/modules/database-analysis/database-analysis.controller.ts index fa9e8d675d..fdc79fe1ce 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.controller.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.controller.ts @@ -1,6 +1,6 @@ import { Body, - Controller, Get, Param, Post, UseInterceptors, UsePipes, ValidationPipe, + Controller, Get, Param, Post, Patch, UseInterceptors, UsePipes, ValidationPipe, } from '@nestjs/common'; import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; import { ApiTags } from '@nestjs/swagger'; @@ -8,7 +8,7 @@ import { DatabaseAnalysisService } from 'src/modules/database-analysis/database- import { DatabaseAnalysis, ShortDatabaseAnalysis } from 'src/modules/database-analysis/models'; import { BrowserSerializeInterceptor } from 'src/common/interceptors'; import { ApiQueryRedisStringEncoding, ClientMetadataParam } from 'src/common/decorators'; -import { CreateDatabaseAnalysisDto } from 'src/modules/database-analysis/dto'; +import { CreateDatabaseAnalysisDto, RecommendationVoteDto } from 'src/modules/database-analysis/dto'; import { ClientMetadata } from 'src/common/models'; @UseInterceptors(BrowserSerializeInterceptor) @@ -72,4 +72,30 @@ export class DatabaseAnalysisController { ): Promise { return this.service.list(databaseId); } + + @Patch(':id') + @ApiEndpoint({ + description: 'Update database instance by id', + statusCode: 200, + responses: [ + { + status: 200, + description: 'Updated database instance\' response', + type: DatabaseAnalysis, + }, + ], + }) + @UsePipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + }), + ) + async modify( + @Param('id') id: string, + @Body() dto: RecommendationVoteDto, + ): Promise { + return await this.service.vote(id, dto); + } } diff --git a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts index 7b2918d26f..4d04c4a8b4 100644 --- a/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts +++ b/redisinsight/api/src/modules/database-analysis/database-analysis.service.ts @@ -7,7 +7,7 @@ import { DatabaseAnalyzer } from 'src/modules/database-analysis/providers/databa import { plainToClass } from 'class-transformer'; import { DatabaseAnalysis, ShortDatabaseAnalysis } from 'src/modules/database-analysis/models'; import { DatabaseAnalysisProvider } from 'src/modules/database-analysis/providers/database-analysis.provider'; -import { CreateDatabaseAnalysisDto } from 'src/modules/database-analysis/dto'; +import { CreateDatabaseAnalysisDto, RecommendationVoteDto } from 'src/modules/database-analysis/dto'; import { KeysScanner } from 'src/modules/database-analysis/scanner/keys-scanner'; import { DatabaseConnectionService } from 'src/modules/database/database-connection.service'; import { ClientMetadata } from 'src/common/models'; @@ -112,4 +112,13 @@ export class DatabaseAnalysisService { async list(databaseId: string): Promise { return this.databaseAnalysisProvider.list(databaseId); } + + /** + * Set user vote for recommendation + * @param id + * @param recommendation + */ + async vote(id: string, recommendation: RecommendationVoteDto): Promise { + return this.databaseAnalysisProvider.recommendationVote(id, recommendation); + } } diff --git a/redisinsight/api/src/modules/database-analysis/dto/index.ts b/redisinsight/api/src/modules/database-analysis/dto/index.ts index b7e8392483..70aa99912a 100644 --- a/redisinsight/api/src/modules/database-analysis/dto/index.ts +++ b/redisinsight/api/src/modules/database-analysis/dto/index.ts @@ -1 +1,2 @@ export * from './create-database-analysis.dto'; +export * from './recommendation-vote.dto'; diff --git a/redisinsight/api/src/modules/database-analysis/dto/recommendation-vote.dto.ts b/redisinsight/api/src/modules/database-analysis/dto/recommendation-vote.dto.ts new file mode 100644 index 0000000000..11f2bce39e --- /dev/null +++ b/redisinsight/api/src/modules/database-analysis/dto/recommendation-vote.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class RecommendationVoteDto { + @ApiProperty({ + description: 'Recommendation name', + type: String, + }) + @IsString() + name: string; + + @ApiProperty({ + description: 'User vote', + type: String, + }) + @IsString() + vote: string; +} diff --git a/redisinsight/api/src/modules/database-analysis/models/recommendation.ts b/redisinsight/api/src/modules/database-analysis/models/recommendation.ts index 9cd1b8ab3f..e98007998f 100644 --- a/redisinsight/api/src/modules/database-analysis/models/recommendation.ts +++ b/redisinsight/api/src/modules/database-analysis/models/recommendation.ts @@ -16,4 +16,11 @@ export class Recommendation { }) @Expose() params?: any; + + @ApiPropertyOptional({ + description: 'User vote', + example: 'useful', + }) + @Expose() + vote?: string; } diff --git a/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.spec.ts b/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.spec.ts index eb06385afa..4cfe3bd067 100644 --- a/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.spec.ts +++ b/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.spec.ts @@ -13,7 +13,7 @@ import { Repository } from 'typeorm'; import { getRepositoryToken } from '@nestjs/typeorm'; import { DatabaseAnalysisProvider } from 'src/modules/database-analysis/providers/database-analysis.provider'; import { DatabaseAnalysis } from 'src/modules/database-analysis/models'; -import { CreateDatabaseAnalysisDto } from 'src/modules/database-analysis/dto'; +import { CreateDatabaseAnalysisDto, RecommendationVoteDto } from 'src/modules/database-analysis/dto'; import { RedisDataType } from 'src/modules/browser/dto'; import { plainToClass } from 'class-transformer'; import { ScanFilter } from 'src/modules/database-analysis/models/scan-filter'; @@ -150,6 +150,16 @@ const mockDatabaseAnalysis = { recommendations: [{ name: 'luaScript' }], } as DatabaseAnalysis; +const mockDatabaseAnalysisWithVote = { + ...mockDatabaseAnalysis, + recommendations: [{ name: 'luaScript', vote: 'useful' }], +} as DatabaseAnalysis; + +const mockRecommendationVoteDto: RecommendationVoteDto = { + name: 'luaScript', + vote: 'useful', +}; + describe('DatabaseAnalysisProvider', () => { let service: DatabaseAnalysisProvider; let repository: MockType>; @@ -254,4 +264,27 @@ describe('DatabaseAnalysisProvider', () => { ); }); }); + + describe('recommendationVote', () => { + it('should return updated database analysis', async () => { + repository.findOneBy.mockReturnValueOnce(mockDatabaseAnalysisEntity); + repository.update.mockReturnValueOnce(true); + await encryptionService.encrypt.mockReturnValue(mockEncryptResult); + + expect(await service.recommendationVote(mockDatabaseAnalysis.id, mockRecommendationVoteDto)) + .toEqual(mockDatabaseAnalysisWithVote); + }); + + it('should throw an error', async () => { + repository.findOneBy.mockReturnValueOnce(null); + + try { + await service.recommendationVote(mockDatabaseAnalysis.id, mockRecommendationVoteDto); + fail(); + } catch (e) { + expect(e).toBeInstanceOf(NotFoundException); + expect(e.message).toEqual(ERROR_MESSAGES.DATABASE_ANALYSIS_NOT_FOUND); + } + }); + }); }); diff --git a/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts b/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts index 66d51e701b..695e4e88de 100644 --- a/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts +++ b/redisinsight/api/src/modules/database-analysis/providers/database-analysis.provider.ts @@ -6,6 +6,7 @@ import { Repository } from 'typeorm'; import { EncryptionService } from 'src/modules/encryption/encryption.service'; import { plainToClass } from 'class-transformer'; import { DatabaseAnalysis, ShortDatabaseAnalysis } from 'src/modules/database-analysis/models'; +import { RecommendationVoteDto } from 'src/modules/database-analysis/dto'; import { classToClass } from 'src/utils'; import config from 'src/utils/config'; import ERROR_MESSAGES from 'src/constants/error-messages'; @@ -70,6 +71,31 @@ export class DatabaseAnalysisProvider { return classToClass(DatabaseAnalysis, await this.decryptEntity(entity, true)); } + /** + * Fetches entity, decrypt, update and return updated DatabaseAnalysis model + * @param id + * @param dto + */ + async recommendationVote(id: string, dto: RecommendationVoteDto): Promise { + this.logger.log('Updating database analysis with recommendation vote'); + const { name, vote } = dto; + const oldDatabaseAnalysis = await this.repository.findOneBy({ id }); + + if (!oldDatabaseAnalysis) { + this.logger.error(`Database analysis with id:${id} was not Found`); + throw new NotFoundException(ERROR_MESSAGES.DATABASE_ANALYSIS_NOT_FOUND); + } + + const entity = classToClass(DatabaseAnalysis, await this.decryptEntity(oldDatabaseAnalysis, true)); + + entity.recommendations = entity.recommendations.map((recommendation) => ( + recommendation.name === name ? { ...recommendation, vote } : recommendation)); + + await this.repository.update(id, await this.encryptEntity(plainToClass(DatabaseAnalysisEntity, entity))); + + return entity; + } + /** * Return list of database analysis with several fields only * @param databaseId diff --git a/redisinsight/api/test/api/database-analysis/PATCH-databases-id-analysis.test.ts b/redisinsight/api/test/api/database-analysis/PATCH-databases-id-analysis.test.ts new file mode 100644 index 0000000000..2da67f5ff3 --- /dev/null +++ b/redisinsight/api/test/api/database-analysis/PATCH-databases-id-analysis.test.ts @@ -0,0 +1,69 @@ +import { + expect, + describe, + deps, + before, + getMainCheckFn, + Joi, + generateInvalidDataTestCases, + validateInvalidDataTestCase, +} from '../deps'; +import { analysisSchema } from './constants'; +const { localDb, request, server, constants, rte } = deps; + +const endpoint = ( + instanceId = constants.TEST_INSTANCE_ID, + id = constants.TEST_DATABASE_ANALYSIS_ID_1, +) => + request(server).patch(`/${constants.API.DATABASES}/${instanceId}/analysis/${id}`); + + // input data schema +const dataSchema = Joi.object({ + name: Joi.string(), + vote: Joi.string(), +}).strict(); + +const validInputData = { + name: constants.getRandomString(), + vote: constants.getRandomString(), +}; + +const responseSchema = analysisSchema; +const mainCheckFn = getMainCheckFn(endpoint); +let repository; + +describe('PATCH /databases/:instanceId/analysis/:id', () => { + before(async () => await localDb.generateNDatabaseAnalysis({ + databaseId: constants.TEST_INSTANCE_ID, + id: constants.TEST_DATABASE_ANALYSIS_ID_1, + createdAt: constants.TEST_DATABASE_ANALYSIS_CREATED_AT_1, + }, 1, true), + ); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('recommendations', () => { + describe('recommendation vote', () => { + [ + { + name: 'Should add vote for RTS recommendation', + data: { + name: 'luaScript', + vote: 'useful', + }, + statusCode: 200, + responseSchema, + checkFn: async ({ body }) => { + expect(body.recommendations).to.include.deep.members([ + constants.TEST_LUA_SCRIPT_VOTE_RECOMMENDATION + ]); + }, + }, + ].map(mainCheckFn); + }); + }); +}); diff --git a/redisinsight/api/test/api/database-analysis/constants.ts b/redisinsight/api/test/api/database-analysis/constants.ts index 563a66a7d0..22048a0d21 100644 --- a/redisinsight/api/test/api/database-analysis/constants.ts +++ b/redisinsight/api/test/api/database-analysis/constants.ts @@ -2,6 +2,8 @@ import { Joi } from '../../helpers/test'; export const typedRecommendationSchema = Joi.object({ name: Joi.string().required(), + vote: Joi.string(), + params: Joi.any(), }); export const typedTotalSchema = Joi.object({ diff --git a/redisinsight/api/test/helpers/constants.ts b/redisinsight/api/test/helpers/constants.ts index fb1a1ab585..e38535b145 100644 --- a/redisinsight/api/test/helpers/constants.ts +++ b/redisinsight/api/test/helpers/constants.ts @@ -513,5 +513,10 @@ export const constants = { TEST_REDISEARCH_RECOMMENDATION: { name: RECOMMENDATION_NAMES.REDIS_SEARCH, }, + + TEST_LUA_SCRIPT_VOTE_RECOMMENDATION: { + name: RECOMMENDATION_NAMES.LUA_SCRIPT, + vote: 'useful', + }, // etc... } diff --git a/redisinsight/ui/src/assets/img/icons/dislike.svg b/redisinsight/ui/src/assets/img/icons/dislike.svg new file mode 100644 index 0000000000..9bc0db8362 --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/dislike.svg @@ -0,0 +1,3 @@ + + + diff --git a/redisinsight/ui/src/assets/img/icons/double_like.svg b/redisinsight/ui/src/assets/img/icons/double_like.svg new file mode 100644 index 0000000000..336288eb9e --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/double_like.svg @@ -0,0 +1,4 @@ + + + + diff --git a/redisinsight/ui/src/assets/img/icons/like.svg b/redisinsight/ui/src/assets/img/icons/like.svg new file mode 100644 index 0000000000..4eea43fc0d --- /dev/null +++ b/redisinsight/ui/src/assets/img/icons/like.svg @@ -0,0 +1,3 @@ + + + diff --git a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json index c06f56181d..4097052fc1 100644 --- a/redisinsight/ui/src/constants/dbAnalysisRecommendations.json +++ b/redisinsight/ui/src/constants/dbAnalysisRecommendations.json @@ -593,7 +593,7 @@ { "id": "2", "type": "span", - "value": "was designed to help address your query needs and support a better development experience when dealing with complex data scenarios. Take a look at the " + "value": " was designed to help address your query needs and support a better development experience when dealing with complex data scenarios. Take a look at the " }, { "id": "3", diff --git a/redisinsight/ui/src/constants/links.ts b/redisinsight/ui/src/constants/links.ts index 506be9f976..1e22edb2b2 100644 --- a/redisinsight/ui/src/constants/links.ts +++ b/redisinsight/ui/src/constants/links.ts @@ -3,4 +3,5 @@ export const EXTERNAL_LINKS = { githubIssues: 'https://github.com/RedisInsight/RedisInsight/issues', releaseNotes: 'https://github.com/RedisInsight/RedisInsight/releases', userSurvey: 'https://www.surveymonkey.com/r/redisinsight', + recommendationFeedback: 'https://github.com/RedisInsight/RedisInsight/issues/new/choose', } diff --git a/redisinsight/ui/src/constants/recommendations.ts b/redisinsight/ui/src/constants/recommendations.ts new file mode 100644 index 0000000000..2ed717788b --- /dev/null +++ b/redisinsight/ui/src/constants/recommendations.ts @@ -0,0 +1,5 @@ +export enum Vote { + DoubleLike = 'very useful', + Like = 'useful', + Dislike = 'not useful' +} diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/index.ts b/redisinsight/ui/src/pages/databaseAnalysis/components/index.ts index 368b008a47..1221f0ccf1 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/index.ts +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/index.ts @@ -2,6 +2,7 @@ import AnalysisDataView from './analysis-data-view' import ExpirationGroupsView from './analysis-ttl-view' import EmptyAnalysisMessage from './empty-analysis-message' import Header from './header' +import RecommendationVoting from './recommendation-voting' import SummaryPerData from './summary-per-data' import TableLoader from './table-loader' import TopKeys from './top-keys' @@ -12,6 +13,7 @@ export { ExpirationGroupsView, EmptyAnalysisMessage, Header, + RecommendationVoting, SummaryPerData, TableLoader, TopKeys, diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/RecommendationVoting.spec.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/RecommendationVoting.spec.tsx new file mode 100644 index 0000000000..9c479d6175 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/RecommendationVoting.spec.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { cloneDeep } from 'lodash' +import { instance, mock } from 'ts-mockito' +import { setRecommendationVote } from 'uiSrc/slices/analytics/dbAnalysis' + +import { + cleanup, + mockedStore, + fireEvent, + render, + screen, + waitForEuiPopoverVisible, +} from 'uiSrc/utils/test-utils' + +import RecommendationVoting, { Props } from './RecommendationVoting' + +const mockedProps = mock() + +let store: typeof mockedStore + +beforeEach(() => { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +jest.mock('uiSrc/telemetry', () => ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})) + +describe('RecommendationVoting', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call "setRecommendationVote" action be called after click "very-useful-vote-btn"', () => { + render() + fireEvent.click(screen.getByTestId('very-useful-vote-btn')) + + const expectedActions = [setRecommendationVote()] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should call "setRecommendationVote" action be called after click "useful-vote-btn"', () => { + render() + fireEvent.click(screen.getByTestId('useful-vote-btn')) + + const expectedActions = [setRecommendationVote()] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should call "setRecommendationVote" action be called after click "not-useful-vote-btn"', () => { + render() + fireEvent.click(screen.getByTestId('not-useful-vote-btn')) + + const expectedActions = [setRecommendationVote()] + expect(store.getActions()).toEqual(expectedActions) + }) + + it('should render popover after click "not-useful-vote-btn"', async () => { + render() + + expect(document.querySelector('[data-test-subj="github-repo-link"]')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('not-useful-vote-btn')) + await waitForEuiPopoverVisible() + + expect(document.querySelector('[data-test-subj="github-repo-link"]')).toHaveAttribute('href', 'https://github.com/RedisInsight/RedisInsight/issues/new/choose') + }) + + it('should render component where all buttons are disabled"', async () => { + render() + + expect(screen.getByTestId('very-useful-vote-btn')).toBeDisabled() + expect(screen.getByTestId('useful-vote-btn')).toBeDisabled() + expect(screen.getByTestId('not-useful-vote-btn')).toBeDisabled() + }) +}) diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/RecommendationVoting.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/RecommendationVoting.tsx new file mode 100644 index 0000000000..4b46364b70 --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/RecommendationVoting.tsx @@ -0,0 +1,150 @@ +import React, { useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import cx from 'classnames' +import { + EuiButton, + EuiButtonIcon, + EuiPopover, + EuiText, + EuiToolTip, + EuiFlexGroup, + EuiIcon, + EuiLink, +} from '@elastic/eui' +import { userSettingsConfigSelector } from 'uiSrc/slices/user/user-settings' +import { putRecommendationVote } from 'uiSrc/slices/analytics/dbAnalysis' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' +import { EXTERNAL_LINKS } from 'uiSrc/constants/links' +import { Vote } from 'uiSrc/constants/recommendations' + +import { ReactComponent as LikeIcon } from 'uiSrc/assets/img/icons/like.svg' +import { ReactComponent as DoubleLikeIcon } from 'uiSrc/assets/img/icons/double_like.svg' +import { ReactComponent as DislikeIcon } from 'uiSrc/assets/img/icons/dislike.svg' +import GithubSVG from 'uiSrc/assets/img/sidebar/github.svg' +import styles from './styles.module.scss' + +export interface Props { vote?: Vote, name: string } + +const RecommendationVoting = ({ vote, name }: Props) => { + const config = useSelector(userSettingsConfigSelector) + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + const dispatch = useDispatch() + + const onSuccessVoted = (instanceId: string, name: string, vote: Vote) => { + sendEventTelemetry({ + event: TelemetryEvent.DATABASE_ANALYSIS_RECOMMENDATIONS_VOTED, + eventData: { + databaseId: instanceId, + name, + vote, + } + }) + } + + const handleClick = (name: string, vote: Vote) => { + if (vote === Vote.Dislike) { + setIsPopoverOpen(true) + } + dispatch(putRecommendationVote(name, vote, onSuccessVoted)) + } + + const getTooltipContent = (content: string) => (config?.agreements?.analytics + ? content + : 'Enable Analytics on the Settings page to vote for a recommendation') + + return ( + + Rate Recommendation +
+ + handleClick(name, Vote.DoubleLike)} + /> + + + handleClick(name, Vote.Like)} + /> + + + setIsPopoverOpen(false)} + anchorClassName={styles.popoverAnchor} + panelClassName={cx('euiToolTip', 'popoverLikeTooltip', styles.popover)} + button={( + handleClick(name, Vote.Dislike)} + /> + )} + > +
+ Thank you for your feedback, Tell us how we can improve + + + + To Github + + + setIsPopoverOpen(false)} + /> +
+
+
+
+
+ ) +} + +export default RecommendationVoting diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/index.ts b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/index.ts new file mode 100644 index 0000000000..7b77eec7fb --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/index.ts @@ -0,0 +1,3 @@ +import RecommendationVoting from './RecommendationVoting' + +export default RecommendationVoting diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/styles.module.scss new file mode 100644 index 0000000000..b0fc89120f --- /dev/null +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendation-voting/styles.module.scss @@ -0,0 +1,64 @@ +.votingContainer { + padding-top: 15px; + margin-top: 15px !important; + border-top: 1px solid var(--separatorColor); + height: 49px; + + .vote { + margin-left: 10px; + } + + .vote :global(.euiIcon) { + width: 34px; + height: 34px; + fill: none; + + path { + stroke: var(--buttonSecondaryTextColor); + } + } + + .vote .voteBtn { + width: 34px; + height: 34px; + border-radius: 50%; + + &:hover, + &:focus, + &.selected { + transform: none; + background-color: var(--separatorColor); + } + } +} + +:global(.euiPanel).popover { + max-width: none !important; + box-shadow: none !important; + padding: 10px 15px !important; + color: var(--buttonSecondaryTextColor) !important; + + .feedbackBtn { + padding: 4px 8px 4px 4px; + margin: 0 10px; + height: 22px !important; + + :global(.euiButtonContent.euiButton__content) { + padding: 0; + } + + .link { + display: flex; + align-items: center; + color: var(--euiColorPrimaryText) !important; + text-decoration: none !important; + font: normal normal normal 12px/14px Graphik, sans-serif; + } + + .link .githubIcon { + width: 12px; + height: 12px; + margin-right: 2px; + } + } +} diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx index 7363b4a538..b056b6d446 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/Recommendations.tsx @@ -13,6 +13,7 @@ import { EuiLink, } from '@elastic/eui' import { ThemeContext } from 'uiSrc/contexts/themeContext' +import { RecommendationVoting } from 'uiSrc/pages/databaseAnalysis/components' import { dbAnalysisSelector } from 'uiSrc/slices/analytics/dbAnalysis' import recommendationsContent from 'uiSrc/constants/dbAnalysisRecommendations.json' import { Theme } from 'uiSrc/constants' @@ -21,7 +22,6 @@ import RediStackDarkMin from 'uiSrc/assets/img/modules/redistack/RediStackDark-m import RediStackLightMin from 'uiSrc/assets/img/modules/redistack/RediStackLight-min.svg' import NoRecommendationsDark from 'uiSrc/assets/img/icons/recommendations_dark.svg' import NoRecommendationsLight from 'uiSrc/assets/img/icons/recommendations_light.svg' - import { renderContent, renderBadges, renderBadgesLegend } from './utils' import styles from './styles.module.scss' @@ -112,7 +112,7 @@ const Recommendations = () => { {renderBadgesLegend()}
- {sortedRecommendations.map(({ name, params }) => { + {sortedRecommendations.map(({ name, params, vote }) => { const { id = '', title = '', @@ -138,6 +138,7 @@ const Recommendations = () => { {renderContent(content, params)} +
) })} diff --git a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss index f63ba897fa..88a4cf30c8 100644 --- a/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss +++ b/redisinsight/ui/src/pages/databaseAnalysis/components/recommendations-view/styles.module.scss @@ -94,7 +94,7 @@ border: 1px solid var(--recommendationBorderColor); background-color: var(--euiColorLightestShade); margin-bottom: 6px; - padding: 30px 18px; + padding: 30px 18px 11px; ul { list-style: initial; diff --git a/redisinsight/ui/src/slices/analytics/dbAnalysis.ts b/redisinsight/ui/src/slices/analytics/dbAnalysis.ts index 3e05801c0f..b569213bc9 100644 --- a/redisinsight/ui/src/slices/analytics/dbAnalysis.ts +++ b/redisinsight/ui/src/slices/analytics/dbAnalysis.ts @@ -1,6 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { AxiosError } from 'axios' import { ApiEndpoints } from 'uiSrc/constants' +import { Vote } from 'uiSrc/constants/recommendations' import { apiService, } from 'uiSrc/services' import { addErrorNotification } from 'uiSrc/slices/app/notifications' import { StateDatabaseAnalysis, DatabaseAnalysisViewTab } from 'uiSrc/slices/interfaces/analytics' @@ -39,6 +40,15 @@ const databaseAnalysisSlice = createSlice({ state.loading = false state.error = payload }, + setRecommendationVote: () => { + // we don't have any loading here + }, + setRecommendationVoteSuccess: (state, { payload }: PayloadAction) => { + state.data = payload + }, + setRecommendationVoteError: (state, { payload }) => { + state.error = payload + }, loadDBAnalysisReports: (state) => { state.history.loading = true }, @@ -77,6 +87,9 @@ export const { setSelectedAnalysisId, setShowNoExpiryGroup, setDatabaseAnalysisViewTab, + setRecommendationVote, + setRecommendationVoteSuccess, + setRecommendationVoteError, } = databaseAnalysisSlice.actions // The reducer @@ -116,6 +129,43 @@ export function fetchDBAnalysisAction( } } +// Asynchronous thunk action +export function putRecommendationVote( + recommendationName: string, + vote: Vote, + onSuccessAction?: (instanceId: string, name: string, vote: Vote) => void, + onFailAction?: () => void, +) { + return async (dispatch: AppDispatch, stateInit: () => RootState) => { + try { + dispatch(setRecommendationVote()) + const state = stateInit() + const instanceId = state.connections.instances.connectedInstance?.id + + const { data, status } = await apiService.patch( + getUrl( + instanceId, + ApiEndpoints.DATABASE_ANALYSIS, + state.analytics.databaseAnalysis.history.selectedAnalysis ?? '', + ), + { name: recommendationName, vote }, + ) + + if (isStatusSuccessful(status)) { + dispatch(setRecommendationVoteSuccess(data)) + + onSuccessAction?.(instanceId, recommendationName, vote) + } + } catch (_err) { + const error = _err as AxiosError + const errorMessage = getApiErrorMessage(error) + dispatch(addErrorNotification(error)) + dispatch(setRecommendationVoteError(errorMessage)) + onFailAction?.() + } + } +} + export function fetchDBAnalysisReportsHistory( instanceId: string, onSuccessAction?: (data: ShortDatabaseAnalysis[]) => void, diff --git a/redisinsight/ui/src/slices/tests/analytics/dbAnalysis.spec.ts b/redisinsight/ui/src/slices/tests/analytics/dbAnalysis.spec.ts index 122f66975c..0ea90411cb 100644 --- a/redisinsight/ui/src/slices/tests/analytics/dbAnalysis.spec.ts +++ b/redisinsight/ui/src/slices/tests/analytics/dbAnalysis.spec.ts @@ -18,7 +18,12 @@ import reducer, { dbAnalysisReportsSelector, dbAnalysisSelector, setShowNoExpiryGroup, + setRecommendationVote, + setRecommendationVoteSuccess, + setRecommendationVoteError, + putRecommendationVote, } from 'uiSrc/slices/analytics/dbAnalysis' +import { Vote } from 'uiSrc/constants/recommendations' import { cleanup, initialStateDefault, mockedStore } from 'uiSrc/utils/test-utils' let store: typeof mockedStore @@ -159,6 +164,26 @@ describe('db analysis slice', () => { expect(dbAnalysisSelector(rootState)).toEqual(state) }) }) + describe('setRecommendationVoteError', () => { + it('should properly set error', () => { + // Arrange + const error = 'Some error' + const state = { + ...initialState, + error, + loading: false, + } + + // Act + const nextState = reducer(initialState, setRecommendationVoteError(error)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + analytics: { databaseAnalysis: nextState }, + }) + expect(dbAnalysisSelector(rootState)).toEqual(state) + }) + }) describe('getDBAnalysis', () => { it('should properly set loading: true', () => { // Arrange @@ -218,6 +243,26 @@ describe('db analysis slice', () => { expect(dbAnalysisSelector(rootState)).toEqual(state) }) }) + describe('setRecommendationVoteSuccess', () => { + it('should properly set data', () => { + const payload = mockAnalysis + // Arrange + const state = { + ...initialState, + loading: false, + data: mockAnalysis + } + + // Act + const nextState = reducer(initialState, setRecommendationVoteSuccess(payload)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + analytics: { databaseAnalysis: nextState }, + }) + expect(dbAnalysisSelector(rootState)).toEqual(state) + }) + }) describe('loadDBAnalysisReportsSuccess', () => { it('should properly set data to history', () => { const payload = [mockHistoryReport] @@ -408,6 +453,53 @@ describe('db analysis slice', () => { loadDBAnalysisReportsError(errorMessage) ] + expect(store.getActions()).toEqual(expectedActions) + }) + }) + describe('putRecommendationVote', () => { + it('succeed to put recommendation vote', async () => { + const data = mockAnalysis + const responsePayload = { data, status: 200 } + + apiService.patch = jest.fn().mockResolvedValue(responsePayload) + + // Act + await store.dispatch( + putRecommendationVote('name', Vote.Like) + ) + + // Assert + const expectedActions = [ + setRecommendationVote(), + setRecommendationVoteSuccess(data), + ] + + expect(store.getActions()).toEqual(expectedActions) + }) + + it('failed to put recommendation vote', async () => { + const errorMessage = 'Something was wrong!' + const responsePayload = { + response: { + status: 500, + data: { message: errorMessage }, + }, + } + + apiService.patch = jest.fn().mockRejectedValue(responsePayload) + + // Act + await store.dispatch( + putRecommendationVote('name', Vote.Like) + ) + + // Assert + const expectedActions = [ + setRecommendationVote(), + addErrorNotification(responsePayload as AxiosError), + setRecommendationVoteError(errorMessage) + ] + expect(store.getActions()).toEqual(expectedActions) }) }) diff --git a/redisinsight/ui/src/telemetry/events.ts b/redisinsight/ui/src/telemetry/events.ts index 1099682ff4..35acaeffdb 100644 --- a/redisinsight/ui/src/telemetry/events.ts +++ b/redisinsight/ui/src/telemetry/events.ts @@ -72,7 +72,7 @@ export enum TelemetryEvent { BROWSER_KEY_FIELD_VALUE_COLLAPSED = 'BROWSER_KEY_FIELD_VALUE_COLLAPSED', BROWSER_KEY_DETAILS_FORMATTER_CHANGED = 'BROWSER_KEY_DETAILS_FORMATTER_CHANGED', BROWSER_WORKBENCH_LINK_CLICKED = 'BROWSER_WORKBENCH_LINK_CLICKED', - BROWSER_DATABASE_INDEX_CHANGED= 'BROWSER_DATABASE_INDEX_CHANGED', + BROWSER_DATABASE_INDEX_CHANGED = 'BROWSER_DATABASE_INDEX_CHANGED', CLI_OPENED = 'CLI_OPENED', CLI_CLOSED = 'CLI_CLOSED', @@ -194,6 +194,7 @@ export enum TelemetryEvent { DATABASE_ANALYSIS_DATA_SUMMARY_CLICKED = 'DATABASE_ANALYSIS_DATA_SUMMARY_CLICKED', DATABASE_ANALYSIS_RECOMMENDATIONS_EXPANDED = 'DATABASE_ANALYSIS_RECOMMENDATIONS_EXPANDED', DATABASE_ANALYSIS_RECOMMENDATIONS_COLLAPSED = 'DATABASE_ANALYSIS_RECOMMENDATIONS_COLLAPSED', + DATABASE_ANALYSIS_RECOMMENDATIONS_VOTED = 'DATABASE_ANALYSIS_RECOMMENDATIONS_VOTED', USER_SURVEY_LINK_CLICKED = 'USER_SURVEY_LINK_CLICKED', diff --git a/tests/e2e/common-actions/memory-efficiency-actions.ts b/tests/e2e/common-actions/memory-efficiency-actions.ts new file mode 100644 index 0000000000..edb67920ee --- /dev/null +++ b/tests/e2e/common-actions/memory-efficiency-actions.ts @@ -0,0 +1,36 @@ +import {t} from 'testcafe'; +import { MemoryEfficiencyPage } from '../pageObjects'; + +const memoryEfficiencyPage = new MemoryEfficiencyPage(); +export class MemoryEfficiencyActions { + /* + vote for very useful and verify others are disabled + */ + async voteForVeryUsefulAndVerifyDisabled(): Promise { + await t.click(memoryEfficiencyPage.veryUsefulVoteBtn); + await this.verifyVoteDisabled(); + } + /* + vote for useful and verify others are disabled + */ + async voteForUsefulAndVerifyDisabled(): Promise { + await t.click(memoryEfficiencyPage.usefulVoteBtn); + await this.verifyVoteDisabled(); + } + /* + vote for not useful and verify others are disabled + */ + async voteForNotUsefulAndVerifyDisabled(): Promise { + await t.click(memoryEfficiencyPage.notUsefulVoteBtn); + await this.verifyVoteDisabled(); + } + async verifyVoteDisabled(): Promise{ + // Verify that user can rate recommendations with one of 3 existing types at the same time + await t.expect(memoryEfficiencyPage.veryUsefulVoteBtn + .hasAttribute('disabled')).ok('very useful vote button is not disabled'); + await t.expect(memoryEfficiencyPage.usefulVoteBtn + .hasAttribute('disabled')).ok('useful vote button is not disabled'); + await t.expect(memoryEfficiencyPage.notUsefulVoteBtn + .hasAttribute('disabled')).ok('not useful vote button is not disabled'); + } +} diff --git a/tests/e2e/pageObjects/memory-efficiency-page.ts b/tests/e2e/pageObjects/memory-efficiency-page.ts index 376172ca8c..6a70527dbf 100644 --- a/tests/e2e/pageObjects/memory-efficiency-page.ts +++ b/tests/e2e/pageObjects/memory-efficiency-page.ts @@ -1,4 +1,4 @@ -import { Selector } from 'testcafe'; +import {Selector} from 'testcafe'; export class MemoryEfficiencyPage { //------------------------------------------------------------------------------------------- @@ -64,4 +64,8 @@ export class MemoryEfficiencyPage { avoidLogicalDbAccordion = Selector('[data-testid=avoidLogicalDatabases-accordion]'); convertHashToZipAccordion = Selector('[data-testid=convertHashtableToZiplist-accordion]'); compressHashAccordion = Selector('[data-testid=compressHashFieldNames-accordion]'); + veryUsefulVoteBtn = Selector('[data-testid=very-useful-vote-btn]').nth(0); + usefulVoteBtn = Selector('[data-testid=useful-vote-btn]').nth(0); + notUsefulVoteBtn = Selector('[data-testid=not-useful-vote-btn]').nth(0); + recommendationsFeedbackBtn = Selector('[data-testid=recommendation-feedback-btn]'); } diff --git a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts index 9ea4f2c7cd..4b010ed580 100644 --- a/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts +++ b/tests/e2e/tests/critical-path/memory-efficiency/recommendations.e2e.ts @@ -4,6 +4,7 @@ import { acceptLicenseTermsAndAddDatabaseApi, deleteCustomDatabase } from '../.. import { commonUrl, ossStandaloneBigConfig, ossStandaloneConfig } from '../../../helpers/conf'; import { deleteStandaloneDatabaseApi } from '../../../helpers/api/api-database'; import { CliActions } from '../../../common-actions/cli-actions'; +import { MemoryEfficiencyActions } from '../../../common-actions/memory-efficiency-actions'; import { Common } from '../../../helpers/common'; const memoryEfficiencyPage = new MemoryEfficiencyPage(); @@ -13,6 +14,7 @@ const common = new Common(); const browserPage = new BrowserPage(); const cliPage = new CliPage(); const addRedisDatabasePage = new AddRedisDatabasePage(); +const memoryEfficiencyActions = new MemoryEfficiencyActions(); const externalPageLink = 'https://docs.redis.com/latest/ri/memory-optimizations/'; let keyName = `recomKey-${common.generateWord(10)}`; @@ -119,3 +121,27 @@ test await t.expect(memoryEfficiencyPage.avoidLogicalDbAccordion.exists).ok('Avoid using logical databases recommendation not displayed'); await t.expect(memoryEfficiencyPage.codeChangesLabel.exists).ok('Avoid using logical databases recommendation not have Code Changes label'); }); +test + .before(async t => { + await acceptLicenseTermsAndAddDatabaseApi(ossStandaloneConfig, ossStandaloneConfig.databaseName); + // Go to Analysis Tools page and create new report and open recommendations + await t.click(myRedisDatabasePage.analysisPageButton); + await t.click(memoryEfficiencyPage.newReportBtn); + await t.click(memoryEfficiencyPage.recommendationsTab); + }).after(async() => { + await deleteStandaloneDatabaseApi(ossStandaloneConfig); + })('Verify that user can upvote recommendations', async t => { + await memoryEfficiencyActions.voteForVeryUsefulAndVerifyDisabled(); + // Verify that user can see previous votes when reload the page + await common.reloadPage(); + await t.click(memoryEfficiencyPage.recommendationsTab); + await memoryEfficiencyActions.verifyVoteDisabled(); + + await t.click(memoryEfficiencyPage.newReportBtn); + await memoryEfficiencyActions.voteForUsefulAndVerifyDisabled(); + + await t.click(memoryEfficiencyPage.newReportBtn); + await memoryEfficiencyActions.voteForNotUsefulAndVerifyDisabled(); + // Verify that user can see the popup with link when he votes for “Not useful” + await t.expect(memoryEfficiencyPage.recommendationsFeedbackBtn.visible).ok('popup did not appear after voting for not useful'); + });