diff --git a/redisinsight/ui/src/assets/img/alarm.svg b/redisinsight/ui/src/assets/img/alarm.svg new file mode 100644 index 0000000000..edce553e5b --- /dev/null +++ b/redisinsight/ui/src/assets/img/alarm.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx b/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx index 5ede92771e..f7c7111cdd 100644 --- a/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx +++ b/redisinsight/ui/src/pages/home/components/database-list-component/DatabasesListWrapper.tsx @@ -55,6 +55,9 @@ import { setSocialDialogState } from 'uiSrc/slices/oauth/cloud' import { appFeatureFlagsFeaturesSelector } from 'uiSrc/slices/app/features' import { getUtmExternalLink } from 'uiSrc/utils/links' import { CREATE_CLOUD_DB_ID, HELP_LINKS } from 'uiSrc/pages/home/constants' + +import DbStatus from '../db-status' + import styles from './styles.module.scss' export interface Props { @@ -295,16 +298,18 @@ const DatabasesListWrapper = (props: Props) => { ) } - const { id, db, new: newStatus = false } = instance + const { id, db, new: newStatus = false, lastConnection, createdAt, cloudDetails } = instance const cellContent = replaceSpaces(name.substring(0, 200)) return (
- {newStatus && ( - -
- - )} + ({ + ...jest.requireActual('uiSrc/telemetry'), + sendEventTelemetry: jest.fn(), +})) + +const mockedProps = mock() +const daysToMs = (days: number) => days * 60 * 60 * 24 * 1000 + +describe('DbStatus', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should not render any status', () => { + render() + + expect(screen.queryByTestId('database-status-new-1')).not.toBeInTheDocument() + expect(screen.queryByTestId(`database-status-${WarningTypes.TryDatabase}-1`)).not.toBeInTheDocument() + expect(screen.queryByTestId(`database-status-${WarningTypes.CheckIfDeleted}-1`)).not.toBeInTheDocument() + }) + + it('should render TryDatabase status', () => { + const lastConnection = new Date(Date.now() - daysToMs(3)) + render() + + expect(screen.getByTestId(`database-status-${WarningTypes.TryDatabase}-1`)).toBeInTheDocument() + expect(screen.queryByTestId('database-status-new-1')).not.toBeInTheDocument() + expect(screen.queryByTestId(`database-status-${WarningTypes.CheckIfDeleted}-1`)).not.toBeInTheDocument() + }) + + it('should render CheckIfDeleted status', () => { + const lastConnection = new Date(Date.now() - daysToMs(16)) + render() + + expect(screen.getByTestId(`database-status-${WarningTypes.CheckIfDeleted}-1`)).toBeInTheDocument() + + expect(screen.queryByTestId('database-status-new-1')).not.toBeInTheDocument() + expect(screen.queryByTestId(`database-status-${WarningTypes.TryDatabase}-1`)).not.toBeInTheDocument() + }) + + it('should render new status', async () => { + const sendEventTelemetryMock = jest.fn(); + (sendEventTelemetry as jest.Mock).mockImplementation(() => sendEventTelemetryMock) + + const lastConnection = new Date(Date.now() - daysToMs(3)) + render() + + await act(async () => { + fireEvent.mouseOver(screen.getByTestId(`database-status-${WarningTypes.TryDatabase}-1`)) + }) + + await waitForEuiToolTipVisible(1_000) + + expect(sendEventTelemetry).toBeCalledWith({ + event: TelemetryEvent.CLOUD_NOT_USED_DB_NOTIFICATION_VIEWED, + eventData: { + capability: expect.any(String), + databaseId: '1', + type: WarningTypes.TryDatabase + } + }) + }) +}) diff --git a/redisinsight/ui/src/pages/home/components/db-status/DbStatus.tsx b/redisinsight/ui/src/pages/home/components/db-status/DbStatus.tsx new file mode 100644 index 0000000000..f093a0a223 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/db-status/DbStatus.tsx @@ -0,0 +1,119 @@ +import React from 'react' +import { EuiIcon, EuiToolTip } from '@elastic/eui' +import cx from 'classnames' +import { differenceInDays } from 'date-fns' + +import { useSelector } from 'react-redux' +import { getTutorialCapability, Maybe } from 'uiSrc/utils' + +import { appContextCapability } from 'uiSrc/slices/app/context' + +import AlarmIcon from 'uiSrc/assets/img/alarm.svg' +import { isShowCapabilityTutorialPopover } from 'uiSrc/services' +import { sendEventTelemetry, TELEMETRY_EMPTY_VALUE, TelemetryEvent } from 'uiSrc/telemetry' +import { CHECK_CLOUD_DATABASE, WARNING_WITH_CAPABILITY, WARNING_WITHOUT_CAPABILITY } from './texts' +import styles from './styles.module.scss' + +export interface Props { + id: string + lastConnection: Maybe + createdAt: Maybe + isNew: boolean + isFree?: boolean +} + +export enum WarningTypes { + CheckIfDeleted = 'checkIfDeleted', + TryDatabase = 'tryDatabase', +} + +interface WarningTooltipProps { + id: string + content : React.ReactNode + capabilityTelemetry?: string + type?: string + isCapabilityNotShown?: boolean +} + +const LAST_CONNECTION_SM = 3 +const LAST_CONNECTION_L = 16 + +const DbStatus = (props: Props) => { + const { id, lastConnection, createdAt, isNew, isFree } = props + + const { source } = useSelector(appContextCapability) + const capability = getTutorialCapability(source!) + const isCapabilityNotShown = Boolean(isShowCapabilityTutorialPopover(isFree)) + let daysDiff = 0 + + try { + daysDiff = lastConnection + ? differenceInDays(new Date(), new Date(lastConnection)) + : createdAt ? differenceInDays(new Date(), new Date(createdAt)) : 0 + } catch { + // nothing to do + } + + const renderWarningTooltip = (content: React.ReactNode, type?: string) => ( + + )} + position="right" + className={styles.tooltip} + anchorClassName={cx(styles.statusAnchor, styles.warning)} + > +
!
+
+ ) + + if (isFree && daysDiff >= LAST_CONNECTION_L) { + return renderWarningTooltip(CHECK_CLOUD_DATABASE, 'checkIfDeleted') + } + + if (isFree && daysDiff >= LAST_CONNECTION_SM) { + return renderWarningTooltip( + isCapabilityNotShown && capability.name ? WARNING_WITH_CAPABILITY(capability.name) : WARNING_WITHOUT_CAPABILITY, + 'tryDatabase' + ) + } + + if (isNew) { + return ( + +
+ + ) + } + + return null +} + +// separated to send event when content is displayed +const WarningTooltipContent = (props: WarningTooltipProps) => { + const { id, content, capabilityTelemetry, type, isCapabilityNotShown } = props + + sendEventTelemetry({ + event: TelemetryEvent.CLOUD_NOT_USED_DB_NOTIFICATION_VIEWED, + eventData: { + databaseId: id, + capability: isCapabilityNotShown ? capabilityTelemetry : TELEMETRY_EMPTY_VALUE, + type + } + }) + + return ( +
+ +
{content}
+
+ ) +} + +export default DbStatus diff --git a/redisinsight/ui/src/pages/home/components/db-status/index.ts b/redisinsight/ui/src/pages/home/components/db-status/index.ts new file mode 100644 index 0000000000..b777907a83 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/db-status/index.ts @@ -0,0 +1,3 @@ +import DbStatus from './DbStatus' + +export default DbStatus diff --git a/redisinsight/ui/src/pages/home/components/db-status/styles.module.scss b/redisinsight/ui/src/pages/home/components/db-status/styles.module.scss new file mode 100644 index 0000000000..617fe08eb7 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/db-status/styles.module.scss @@ -0,0 +1,47 @@ +.status { + cursor: pointer; + width: 11px !important; + min-width: 11px !important; + height: 11px !important; + border-radius: 50%; + + &.new { + background-color: var(--euiColorPrimary) !important; + } + + &.warning { + width: 14px !important; + height: 14px !important; + background-color: var(--euiColorWarningLight) !important; + + line-height: 14px !important; + text-align: center; + color: var(--euiColorEmptyShade); + font-size: 11px; + } +} + +.statusAnchor { + margin-top: 20px; + margin-left: -19px; + position: absolute; + + &.warning { + margin-top: 17px; + margin-left: -21px; + } +} + +.tooltip { + min-width: 340px !important; +} + +.warningTooltipContent { + display: flex; + align-items: flex-start; + + :global(.euiIcon) { + margin-top: 4px; + margin-right: 12px; + } +} diff --git a/redisinsight/ui/src/pages/home/components/db-status/texts.tsx b/redisinsight/ui/src/pages/home/components/db-status/texts.tsx new file mode 100644 index 0000000000..466c15877e --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/db-status/texts.tsx @@ -0,0 +1,37 @@ +import { EuiSpacer, EuiTitle } from '@elastic/eui' +import React from 'react' + +export const CHECK_CLOUD_DATABASE = ( + <> + Check your Cloud database + +
+ Free Cloud databases are usually deleted after 15 days of inactivity. + + Check your Cloud database to proceed with learning more about Redis and its capabilities. +
+ +) + +export const WARNING_WITH_CAPABILITY = (capability: string) => ( + <> + {capability} + +
+ Hey, remember you expressed interest in {capability}? +
+ Try your Cloud database to get started. +
+ +
Notice: free Cloud databases will be deleted after 15 days of inactivity.
+ +) +export const WARNING_WITHOUT_CAPABILITY = ( + <> + Try your Cloud database + +
Hey, try your Cloud database to learn more about Redis.
+ +
Notice: free Cloud databases will be deleted after 15 days of inactivity.
+ +) diff --git a/redisinsight/ui/src/telemetry/events.ts b/redisinsight/ui/src/telemetry/events.ts index 89d23ae97e..63c240b3f0 100644 --- a/redisinsight/ui/src/telemetry/events.ts +++ b/redisinsight/ui/src/telemetry/events.ts @@ -294,6 +294,7 @@ export enum TelemetryEvent { CLOUD_ACCOUNT_SWITCHED = 'CLOUD_ACCOUNT_SWITCHED', CLOUD_CONSOLE_CLICKED = 'CLOUD_CONSOLE_CLICKED', CLOUD_SIGN_OUT_CLICKED = 'CLOUD_SIGN_OUT_CLICKED', + CLOUD_NOT_USED_DB_NOTIFICATION_VIEWED = 'CLOUD_NOT_USED_DB_NOTIFICATION_VIEWED', RDI_INSTANCE_LIST_SORTED = 'RDI_INSTANCE_LIST_SORTED', RDI_INSTANCE_SINGLE_DELETE_CLICKED = 'RDI_INSTANCE_SINGLE_DELETE_CLICKED', diff --git a/redisinsight/ui/src/utils/capability.ts b/redisinsight/ui/src/utils/capability.ts index 19371f3244..bda81a1298 100644 --- a/redisinsight/ui/src/utils/capability.ts +++ b/redisinsight/ui/src/utils/capability.ts @@ -24,7 +24,7 @@ export const getTutorialCapability = (source: any = '') => { case getSourceTutorialByCapability(RedisDefaultModules.FTL): return getCapability( 'searchAndQuery', - 'Redis Query Engine capability', + 'Redis Query Engine', findMarkdownPath(store.getState()?.workbench?.tutorials?.items, { id: 'sq-intro' }) ) @@ -33,7 +33,7 @@ export const getTutorialCapability = (source: any = '') => { case getSourceTutorialByCapability(RedisDefaultModules.ReJSON): return getCapability( 'JSON', - 'JSON capability', + 'JSON data structure', findMarkdownPath(store.getState()?.workbench?.tutorials?.items, { id: 'ds-json-intro' }) ) diff --git a/redisinsight/ui/src/utils/test-utils.tsx b/redisinsight/ui/src/utils/test-utils.tsx index f6464e8005..f11d7ae74b 100644 --- a/redisinsight/ui/src/utils/test-utils.tsx +++ b/redisinsight/ui/src/utils/test-utils.tsx @@ -186,13 +186,13 @@ const clearStoreActions = (actions: any[]) => { /** * Ensure the EuiToolTip being tested is open and visible before continuing */ -const waitForEuiToolTipVisible = async () => { +const waitForEuiToolTipVisible = async (timeout = 500) => { await waitFor( () => { const tooltip = document.querySelector('.euiToolTipPopover') expect(tooltip).toBeInTheDocument() }, - { timeout: 500 } // Account for long delay on tooltips + { timeout } // Account for long delay on tooltips ) } diff --git a/redisinsight/ui/src/utils/tests/capability.spec.ts b/redisinsight/ui/src/utils/tests/capability.spec.ts index 962182a5ee..cd3148f7a7 100644 --- a/redisinsight/ui/src/utils/tests/capability.spec.ts +++ b/redisinsight/ui/src/utils/tests/capability.spec.ts @@ -16,8 +16,8 @@ describe('getSourceTutorialByCapability', () => { }) const emptyCapability = { name: '', telemetryName: '', path: null, } -const searchCapability = { name: 'Redis Query Engine capability', telemetryName: 'searchAndQuery', path: null, } -const jsonCapability = { name: 'JSON capability', telemetryName: 'JSON', path: null, } +const searchCapability = { name: 'Redis Query Engine', telemetryName: 'searchAndQuery', path: null, } +const jsonCapability = { name: 'JSON data structure', telemetryName: 'JSON', path: null, } const tsCapability = { name: 'Time series data structure', telemetryName: 'timeSeries', path: null, } const bloomCapability = { name: 'Probabilistic data structures', telemetryName: 'probabilistic', path: null, }