diff --git a/.gitignore b/.gitignore index f25ae39dd6..c43a20fbd6 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,7 @@ static/ .env* .npmrc + +# AI rules +.windsurfrules +.junie/ diff --git a/redisinsight/ui/src/components/base/display/toast/RiToast.tsx b/redisinsight/ui/src/components/base/display/toast/RiToast.tsx index 455ad6e2fb..c1dcb8db5f 100644 --- a/redisinsight/ui/src/components/base/display/toast/RiToast.tsx +++ b/redisinsight/ui/src/components/base/display/toast/RiToast.tsx @@ -9,6 +9,7 @@ import { ToastOptions as RcToastOptions } from 'react-toastify' import { CommonProps } from 'uiSrc/components/base/theme/types' import { ColorText, Text } from 'uiSrc/components/base/text' +import { ColorType } from 'uiSrc/components/base/text/text.styles' import { Spacer } from '../../layout' type RiToastProps = React.ComponentProps @@ -27,18 +28,15 @@ export const riToast = ( } if (typeof message === 'string') { - let color = options?.variant + let color: ColorType = options?.variant if (color === 'informative') { - // @ts-ignore color = 'subdued' } toastContent.message = ( - - - {message} - + + {message} - + ) } else { toastContent.message = message @@ -55,3 +53,4 @@ export const riToast = ( riToast.Variant = toast.Variant riToast.Position = toast.Position riToast.dismiss = toast.dismiss +riToast.isActive = toast.isActive diff --git a/redisinsight/ui/src/components/base/external-link/ExternalLink.tsx b/redisinsight/ui/src/components/base/external-link/ExternalLink.tsx index 0841ad56ca..5d0c2fa03e 100644 --- a/redisinsight/ui/src/components/base/external-link/ExternalLink.tsx +++ b/redisinsight/ui/src/components/base/external-link/ExternalLink.tsx @@ -22,7 +22,11 @@ const ExternalLink = (props: Props) => { } = props const ArrowIcon = () => ( - + ) return ( diff --git a/redisinsight/ui/src/components/base/text/text.styles.ts b/redisinsight/ui/src/components/base/text/text.styles.ts index 6af3e4d879..c4454a754d 100644 --- a/redisinsight/ui/src/components/base/text/text.styles.ts +++ b/redisinsight/ui/src/components/base/text/text.styles.ts @@ -14,6 +14,7 @@ export type EuiColorNames = | 'accent' | 'warning' | 'success' + export type ColorType = BodyProps['color'] | EuiColorNames | (string & {}) export interface MapProps extends HTMLAttributes { $color?: ColorType diff --git a/redisinsight/ui/src/components/notifications/Notifications.tsx b/redisinsight/ui/src/components/notifications/Notifications.tsx index a7c8d0be6f..7181a8e95c 100644 --- a/redisinsight/ui/src/components/notifications/Notifications.tsx +++ b/redisinsight/ui/src/components/notifications/Notifications.tsx @@ -1,230 +1,19 @@ -import React, { useEffect, useRef } from 'react' -import { useDispatch, useSelector } from 'react-redux' -import cx from 'classnames' -import { - errorsSelector, - infiniteNotificationsSelector, - messagesSelector, - removeInfiniteNotification, - removeMessage, -} from 'uiSrc/slices/app/notifications' -import { setReleaseNotesViewed } from 'uiSrc/slices/app/info' -import { IError, IMessage, InfiniteMessage } from 'uiSrc/slices/interfaces' -import { ApiEncryptionErrors } from 'uiSrc/constants/apiErrors' -import { DEFAULT_ERROR_MESSAGE } from 'uiSrc/utils' -import { showOAuthProgress } from 'uiSrc/slices/oauth/cloud' -import { CustomErrorCodes } from 'uiSrc/constants' -import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' -import { ColorText } from 'uiSrc/components/base/text' -import { riToast, RiToaster } from 'uiSrc/components/base/display/toast' - -import errorMessages from './error-messages' -import { InfiniteMessagesIds } from './components' - -import styles from './styles.module.scss' - -const ONE_HOUR = 3_600_000 -const DEFAULT_ERROR_TITLE = 'Error' +import React from 'react' +import { RiToaster } from 'uiSrc/components/base/display/toast' +import { useErrorNotifications, useMessageNotifications } from './hooks' +import { InfiniteNotifications } from './components/infinite-messages/InfiniteNotifications' +import { defaultContainerId } from './constants' const Notifications = () => { - const messagesData = useSelector(messagesSelector) - const errorsData = useSelector(errorsSelector) - const infiniteNotifications = useSelector(infiniteNotificationsSelector) - - const dispatch = useDispatch() - const toastIdsRef = useRef(new Map()) - - const removeToast = (id: string) => { - if (toastIdsRef.current.has(id)) { - riToast.dismiss(toastIdsRef.current.get(id)) - toastIdsRef.current.delete(id) - } - dispatch(removeMessage(id)) - } - - const onSubmitNotification = (id: string, group?: string) => { - if (group === 'upgrade') { - dispatch(setReleaseNotesViewed(true)) - } - dispatch(removeMessage(id)) - } - - const getSuccessText = (text: string | JSX.Element | JSX.Element[]) => ( - {text} + useErrorNotifications() + useMessageNotifications() + + return ( + <> + + + ) - - const showSuccessToasts = (data: IMessage[]) => - data.forEach( - ({ - id = '', - title = '', - message = '', - showCloseButton = true, - actions, - className, - group, - }) => { - const handleClose = () => { - onSubmitNotification(id, group) - removeToast(id) - } - if (toastIdsRef.current.has(id)) { - removeToast(id) - return - } - const toastId = riToast( - { - className, - message: title, - description: getSuccessText(message), - actions: actions ?? { - primary: { - closes: true, - label: 'OK', - onClick: handleClose, - }, - }, - showCloseButton, - }, - { variant: riToast.Variant.Success, toastId: id }, - ) - toastIdsRef.current.set(id, toastId) - }, - ) - - const showErrorsToasts = (errors: IError[]) => - errors.forEach( - ({ - id = '', - message = DEFAULT_ERROR_MESSAGE, - instanceId = '', - name, - title = DEFAULT_ERROR_TITLE, - additionalInfo, - }) => { - if (toastIdsRef.current.has(id)) { - removeToast(id) - return - } - let toastId: ReturnType - if (ApiEncryptionErrors.includes(name)) { - toastId = errorMessages.ENCRYPTION( - () => removeToast(id), - instanceId, - id, - ) - } else if ( - additionalInfo?.errorCode === - CustomErrorCodes.CloudCapiKeyUnauthorized - ) { - toastId = errorMessages.CLOUD_CAPI_KEY_UNAUTHORIZED( - { message, title }, - additionalInfo, - () => removeToast(id), - id, - ) - } else if ( - additionalInfo?.errorCode === - CustomErrorCodes.RdiDeployPipelineFailure - ) { - toastId = errorMessages.RDI_DEPLOY_PIPELINE( - { title, message }, - () => removeToast(id), - id, - ) - } else { - toastId = errorMessages.DEFAULT( - message, - () => removeToast(id), - title, - id, - ) - } - - toastIdsRef.current.set(id, toastId) - }, - ) - const infiniteToastIdsRef = useRef(new Set()) - - const showInfiniteToasts = (data: InfiniteMessage[]) => { - infiniteToastIdsRef.current.forEach((toastId) => { - setTimeout(() => { - riToast.dismiss(toastId) - infiniteToastIdsRef.current.delete(toastId) - }, 50) - }) - data.forEach((notification: InfiniteMessage) => { - const { - id, - message, - description, - actions, - className = '', - variant, - customIcon, - showCloseButton = true, - onClose: onCloseCallback, - } = notification - const toastId = riToast( - { - className: cx(styles.infiniteMessage, className), - message: message, - description: description, - actions, - showCloseButton, - customIcon, - onClose: () => { - switch (id) { - case InfiniteMessagesIds.oAuthProgress: - dispatch(showOAuthProgress(false)) - break - case InfiniteMessagesIds.databaseExists: - sendEventTelemetry({ - event: - TelemetryEvent.CLOUD_IMPORT_EXISTING_DATABASE_FORM_CLOSED, - }) - break - case InfiniteMessagesIds.subscriptionExists: - sendEventTelemetry({ - event: - TelemetryEvent.CLOUD_CREATE_DATABASE_IN_SUBSCRIPTION_FORM_CLOSED, - }) - break - case InfiniteMessagesIds.appUpdateAvailable: - sendEventTelemetry({ - event: TelemetryEvent.UPDATE_NOTIFICATION_CLOSED, - }) - break - default: - break - } - - dispatch(removeInfiniteNotification(id)) - onCloseCallback?.() - }, - }, - { - variant: variant ?? riToast.Variant.Notice, - autoClose: ONE_HOUR, - toastId: id, - }, - ) - infiniteToastIdsRef.current.add(toastId) - toastIdsRef.current.set(id, toastId) - }) - } - - useEffect(() => { - showSuccessToasts(messagesData) - }, [messagesData]) - useEffect(() => { - showErrorsToasts(errorsData) - }, [errorsData]) - useEffect(() => { - showInfiniteToasts(infiniteNotifications) - }, [infiniteNotifications]) - - return } export default Notifications diff --git a/redisinsight/ui/src/components/notifications/components/default-error-content/DefaultErrorContent.tsx b/redisinsight/ui/src/components/notifications/components/default-error-content/DefaultErrorContent.tsx index 76ea835662..2755119bc0 100644 --- a/redisinsight/ui/src/components/notifications/components/default-error-content/DefaultErrorContent.tsx +++ b/redisinsight/ui/src/components/notifications/components/default-error-content/DefaultErrorContent.tsx @@ -1,8 +1,6 @@ import React from 'react' import { ColorText } from 'uiSrc/components/base/text' -import { Spacer } from 'uiSrc/components/base/layout/spacer' -import { SecondaryButton } from 'uiSrc/components/base/forms/buttons' export interface Props { text: string | JSX.Element | JSX.Element[] diff --git a/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.tsx b/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.tsx index 3cf468c670..f64d5dd810 100644 --- a/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.tsx +++ b/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.tsx @@ -20,6 +20,7 @@ import { FlexItem, Row } from 'uiSrc/components/base/layout/flex' import { Spacer } from 'uiSrc/components/base/layout/spacer' import { PrimaryButton } from 'uiSrc/components/base/forms/buttons' import { RiIcon } from 'uiSrc/components/base/icons/RiIcon' + import styles from './styles.module.scss' export enum InfiniteMessagesIds { @@ -38,11 +39,32 @@ const MANAGE_DB_LINK = getUtmExternalLink(EXTERNAL_LINKS.cloudConsole, { medium: UTM_MEDIUMS.Main, }) -// TODO: Refactor this type definition to work with the real parameters and their types we use in each message -export const INFINITE_MESSAGES: Record< - string, - (...args: any[]) => InfiniteMessage -> = { +interface InfiniteMessagesType { + AUTHENTICATING: () => InfiniteMessage + PENDING_CREATE_DB: (step?: CloudJobStep) => InfiniteMessage + SUCCESS_CREATE_DB: ( + details: Omit, + onSuccess: () => void, + jobName: Maybe, + ) => InfiniteMessage + DATABASE_EXISTS: ( + onSuccess?: () => void, + onClose?: () => void, + ) => InfiniteMessage + DATABASE_IMPORT_FORBIDDEN: (onClose?: () => void) => InfiniteMessage + SUBSCRIPTION_EXISTS: ( + onSuccess?: () => void, + onClose?: () => void, + ) => InfiniteMessage + AUTO_CREATING_DATABASE: () => InfiniteMessage + APP_UPDATE_AVAILABLE: ( + version: string, + onSuccess?: () => void, + ) => InfiniteMessage + SUCCESS_DEPLOY_PIPELINE: () => InfiniteMessage +} + +export const INFINITE_MESSAGES: InfiniteMessagesType = { AUTHENTICATING: () => ({ id: InfiniteMessagesIds.oAuthProgress, message: 'Authenticating…', @@ -52,6 +74,7 @@ export const INFINITE_MESSAGES: Record< PENDING_CREATE_DB: (step?: CloudJobStep) => ({ id: InfiniteMessagesIds.oAuthProgress, customIcon: LoaderLargeIcon, + variation: step, message: ( <> {(step === CloudJobStep.Credentials || !step) && @@ -207,8 +230,7 @@ export const INFINITE_MESSAGES: Record< }), SUBSCRIPTION_EXISTS: (onSuccess?: () => void, onClose?: () => void) => ({ id: InfiniteMessagesIds.subscriptionExists, - message: - 'Your subscription does not have a free Redis Cloud database.', + message: 'Your subscription does not have a free Redis Cloud database.', description: 'Do you want to create a free database in your existing subscription?', actions: { diff --git a/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteNotifications.tsx b/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteNotifications.tsx new file mode 100644 index 0000000000..92bbc08e88 --- /dev/null +++ b/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteNotifications.tsx @@ -0,0 +1,93 @@ +import React, { useEffect, useRef } from 'react' + +import { useInfiniteNotifications } from 'uiSrc/components/notifications/hooks' +import { riToast, RiToaster } from 'uiSrc/components/base/display/toast' +import { InfiniteMessage } from 'uiSrc/slices/interfaces' +import { IMContainerId, ONE_HOUR } from '../../constants' + +const DISMISS_DELAY = 3000 // 3 seconds interval + +const showNotification = (notification: InfiniteMessage) => { + if (!notification) { + return + } + + // Show latest notification + return riToast(notification, { + containerId: IMContainerId, + }) +} + +export const InfiniteNotifications = () => { + const notifications = useInfiniteNotifications() + const queueRef = useRef[]>([]) + const dismissIntervalRef = useRef(null) + + const dismissFromQueue = () => { + if (queueRef.current.length > 0) { + const toastToRemove = queueRef.current.shift() // Remove from top of queue + if (toastToRemove) { + riToast.dismiss(toastToRemove) + } + } + } + + const startDismissInterval = () => { + if (dismissIntervalRef.current) { + clearInterval(dismissIntervalRef.current) + } + + dismissIntervalRef.current = window.setInterval(() => { + if (queueRef.current.length > 1) { + dismissFromQueue() + } else { + // Stop interval when queue has 1 or fewer items + if (dismissIntervalRef.current) { + clearInterval(dismissIntervalRef.current) + dismissIntervalRef.current = null + } + } + }, DISMISS_DELAY) + } + + const addToQueue = (toastId: ReturnType) => { + queueRef.current.push(toastId) // Add to end of queue + } + + const renderNotification = (notification: InfiniteMessage) => { + const toastId = showNotification(notification) + if (toastId) { + addToQueue(toastId) + } + } + + useEffect(() => { + if (notifications.length > 0) { + // Process each notification and add to queue + notifications.forEach((notification) => { + renderNotification(notification) + }) + } + // Start interval if queue has more than 1 item + if (queueRef.current.length > 1) { + startDismissInterval() + } + + // Cleanup on unmount + return () => { + if (dismissIntervalRef.current) { + clearInterval(dismissIntervalRef.current) + dismissIntervalRef.current = null + } + } + }, [notifications]) + + return ( + + ) +} diff --git a/redisinsight/ui/src/components/notifications/constants.ts b/redisinsight/ui/src/components/notifications/constants.ts new file mode 100644 index 0000000000..9d31e69b60 --- /dev/null +++ b/redisinsight/ui/src/components/notifications/constants.ts @@ -0,0 +1,4 @@ +export const defaultContainerId = 'default' + +export const IMContainerId = 'InfiniteMessages' +export const ONE_HOUR = 3_600_000 diff --git a/redisinsight/ui/src/components/notifications/error-messages.tsx b/redisinsight/ui/src/components/notifications/error-messages.tsx index a9d8a468eb..09133d8afd 100644 --- a/redisinsight/ui/src/components/notifications/error-messages.tsx +++ b/redisinsight/ui/src/components/notifications/error-messages.tsx @@ -1,49 +1,34 @@ import React from 'react' -import { riToast } from 'uiSrc/components/base/display/toast' import { InfoIcon, ToastDangerIcon } from 'uiSrc/components/base/icons' import RdiDeployErrorContent from './components/rdi-deploy-error-content' import { EncryptionErrorContent, DefaultErrorContent } from './components' import CloudCapiUnAuthorizedErrorContent from './components/cloud-capi-unauthorized' -// TODO: use i18n file for texts export default { - DEFAULT: ( - text: any, - onClose = () => {}, - title: string = 'Error', - toastId?: string, - ) => - riToast( - { - 'data-testid': 'toast-error', - customIcon: ToastDangerIcon, - message: title, - description: , - actions: { - primary: { - label: 'OK', - closes: true, - onClick: onClose, - }, - }, + DEFAULT: (text: any, onClose = () => {}, title: string = 'Error') => ({ + 'data-testid': 'toast-error', + customIcon: ToastDangerIcon, + message: title, + description: , + actions: { + primary: { + label: 'OK', + closes: true, + onClick: onClose, }, - { variant: riToast.Variant.Danger, toastId }, - ), - ENCRYPTION: (onClose = () => {}, instanceId = '', toastId?: string) => - riToast( - { - 'data-testid': 'toast-error-encryption', - customIcon: InfoIcon, - message: 'Unable to decrypt', - description: ( - - ), - showCloseButton: false, - }, - { variant: riToast.Variant.Danger, toastId }, + }, + }), + ENCRYPTION: (onClose = () => {}, instanceId = '') => ({ + 'data-testid': 'toast-error-encryption', + customIcon: InfoIcon, + message: 'Unable to decrypt', + description: ( + ), + showCloseButton: false, + }), CLOUD_CAPI_KEY_UNAUTHORIZED: ( { message, @@ -54,39 +39,27 @@ export default { }, additionalInfo: Record, onClose: () => void, - toastId?: string, - ) => - riToast( - { - 'data-testid': 'toast-error-cloud-capi-key-unauthorized', - customIcon: ToastDangerIcon, - message: title, - showCloseButton: false, - description: ( - - ), - }, - { variant: riToast.Variant.Danger, toastId }, + ) => ({ + 'data-testid': 'toast-error-cloud-capi-key-unauthorized', + customIcon: ToastDangerIcon, + message: title, + showCloseButton: false, + description: ( + ), + }), RDI_DEPLOY_PIPELINE: ( { title, message }: { title?: string; message: string }, onClose: () => void, - toastId?: string, - ) => - riToast( - { - 'data-testid': 'toast-error-deploy', - customIcon: ToastDangerIcon, - onClose, - message: title, - description: ( - - ), - }, - { variant: riToast.Variant.Danger, toastId }, - ), + ) => ({ + 'data-testid': 'toast-error-deploy', + customIcon: ToastDangerIcon, + onClose, + message: title, + description: , + }), } diff --git a/redisinsight/ui/src/components/notifications/hooks/index.ts b/redisinsight/ui/src/components/notifications/hooks/index.ts new file mode 100644 index 0000000000..d873241dec --- /dev/null +++ b/redisinsight/ui/src/components/notifications/hooks/index.ts @@ -0,0 +1,3 @@ +export { useErrorNotifications } from './useErrorNotifications' +export { useMessageNotifications } from './useMessageNotifications' +export { useInfiniteNotifications } from './useInfiniteNotifications' diff --git a/redisinsight/ui/src/components/notifications/hooks/useErrorNotifications.ts b/redisinsight/ui/src/components/notifications/hooks/useErrorNotifications.ts new file mode 100644 index 0000000000..9e5bef21db --- /dev/null +++ b/redisinsight/ui/src/components/notifications/hooks/useErrorNotifications.ts @@ -0,0 +1,84 @@ +import { useEffect, useRef } from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import { IError } from 'uiSrc/slices/interfaces' +import { DEFAULT_ERROR_MESSAGE } from 'uiSrc/utils' +import { riToast } from 'uiSrc/components/base/display/toast' +import { ApiEncryptionErrors } from 'uiSrc/constants/apiErrors' +import errorMessages from 'uiSrc/components/notifications/error-messages' +import { CustomErrorCodes } from 'uiSrc/constants' +import { errorsSelector, removeMessage } from 'uiSrc/slices/app/notifications' +import { defaultContainerId } from 'uiSrc/components/notifications/constants' +import { RiToastType } from 'uiSrc/components/base/display/toast/RiToast' + +const DEFAULT_ERROR_TITLE = 'Error' + +export const useErrorNotifications = () => { + const errorsData = useSelector(errorsSelector) + const dispatch = useDispatch() + const toastIdsRef = useRef(new Map()) + const removeToast = (id: string) => { + if (toastIdsRef.current.has(id)) { + riToast.dismiss(toastIdsRef.current.get(id)) + toastIdsRef.current.delete(id) + } + dispatch(removeMessage(id)) + } + const showErrorsToasts = (errors: IError[]) => + errors.forEach( + ({ + id = '', + message = DEFAULT_ERROR_MESSAGE, + instanceId = '', + name, + title = DEFAULT_ERROR_TITLE, + additionalInfo, + }) => { + if (toastIdsRef.current.has(id)) { + removeToast(id) + return + } + + let errorMessage: RiToastType + if (ApiEncryptionErrors.includes(name)) { + errorMessage = errorMessages.ENCRYPTION( + () => removeToast(id), + instanceId, + ) + } else if ( + additionalInfo?.errorCode === + CustomErrorCodes.CloudCapiKeyUnauthorized + ) { + errorMessage = errorMessages.CLOUD_CAPI_KEY_UNAUTHORIZED( + { message, title }, + additionalInfo, + () => removeToast(id), + ) + } else if ( + additionalInfo?.errorCode === + CustomErrorCodes.RdiDeployPipelineFailure + ) { + errorMessage = errorMessages.RDI_DEPLOY_PIPELINE( + { title, message }, + () => removeToast(id), + ) + } else { + errorMessage = errorMessages.DEFAULT( + message, + () => removeToast(id), + title, + ) + } + const toastId: ReturnType = riToast(errorMessage, { + variant: riToast.Variant.Danger, + toastId: id, + containerId: defaultContainerId, + }) + toastIdsRef.current.set(id, toastId) + }, + ) + + useEffect(() => { + showErrorsToasts(errorsData) + }, [errorsData]) +} diff --git a/redisinsight/ui/src/components/notifications/hooks/useInfiniteNotifications.ts b/redisinsight/ui/src/components/notifications/hooks/useInfiniteNotifications.ts new file mode 100644 index 0000000000..d2334b5780 --- /dev/null +++ b/redisinsight/ui/src/components/notifications/hooks/useInfiniteNotifications.ts @@ -0,0 +1,66 @@ +import { useMemo } from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import { InfiniteMessage } from 'uiSrc/slices/interfaces' +import { infiniteNotificationsSelector } from 'uiSrc/slices/app/notifications' +import { InfiniteMessagesIds } from 'uiSrc/components/notifications/components' +import { showOAuthProgress } from 'uiSrc/slices/oauth/cloud' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +export const useInfiniteNotifications = () => { + const infiniteNotifications = useSelector(infiniteNotificationsSelector) + const dispatch = useDispatch() + return useMemo(() => { + return infiniteNotifications.map( + ({ + id, + message, + description, + actions, + className = '', + variant, + customIcon, + showCloseButton = true, + onClose: onCloseCallback, + }: InfiniteMessage) => { + return { + id, + message, + description, + actions, + className, + variant, + customIcon, + showCloseButton, + onClose: () => { + switch (id) { + case InfiniteMessagesIds.oAuthProgress: + dispatch(showOAuthProgress(false)) + break + case InfiniteMessagesIds.databaseExists: + sendEventTelemetry({ + event: + TelemetryEvent.CLOUD_IMPORT_EXISTING_DATABASE_FORM_CLOSED, + }) + break + case InfiniteMessagesIds.subscriptionExists: + sendEventTelemetry({ + event: + TelemetryEvent.CLOUD_CREATE_DATABASE_IN_SUBSCRIPTION_FORM_CLOSED, + }) + break + case InfiniteMessagesIds.appUpdateAvailable: + sendEventTelemetry({ + event: TelemetryEvent.UPDATE_NOTIFICATION_CLOSED, + }) + break + default: + break + } + onCloseCallback?.() + }, + } + }, + ) + }, [infiniteNotifications]) +} diff --git a/redisinsight/ui/src/components/notifications/hooks/useMessageNotifications.tsx b/redisinsight/ui/src/components/notifications/hooks/useMessageNotifications.tsx new file mode 100644 index 0000000000..27c2876e31 --- /dev/null +++ b/redisinsight/ui/src/components/notifications/hooks/useMessageNotifications.tsx @@ -0,0 +1,78 @@ +import React, { useEffect, useRef } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { riToast } from 'uiSrc/components/base/display/toast' +import { messagesSelector, removeMessage } from 'uiSrc/slices/app/notifications' +import { IMessage } from 'uiSrc/slices/interfaces' +import { setReleaseNotesViewed } from 'uiSrc/slices/app/info' +import { ColorText } from 'uiSrc/components/base/text' +import { defaultContainerId } from '../constants' + +export const useMessageNotifications = () => { + const messagesData = useSelector(messagesSelector) + + const dispatch = useDispatch() + const toastIdsRef = useRef(new Map()) + const removeToast = (id: string) => { + if (toastIdsRef.current.has(id)) { + riToast.dismiss(toastIdsRef.current.get(id)) + toastIdsRef.current.delete(id) + } + dispatch(removeMessage(id)) + } + const onSubmitNotification = (id: string, group?: string) => { + if (group === 'upgrade') { + dispatch(setReleaseNotesViewed(true)) + } + dispatch(removeMessage(id)) + } + + const getSuccessText = (text: string | JSX.Element | JSX.Element[]) => ( + {text} + ) + const showSuccessToasts = (data: IMessage[]) => + data.forEach( + ({ + id = '', + title = '', + message = '', + showCloseButton = true, + actions, + className, + group, + }) => { + const handleClose = () => { + onSubmitNotification(id, group) + removeToast(id) + } + if (toastIdsRef.current.has(id)) { + removeToast(id) + return + } + const toastId = riToast( + { + className, + message: title, + description: getSuccessText(message), + actions: actions ?? { + primary: { + closes: true, + label: 'OK', + onClick: handleClose, + }, + }, + showCloseButton, + }, + { + variant: riToast.Variant.Success, + toastId: id, + containerId: defaultContainerId, + }, + ) + toastIdsRef.current.set(id, toastId) + }, + ) + + useEffect(() => { + showSuccessToasts(messagesData) + }, [messagesData]) +} diff --git a/redisinsight/ui/src/components/notifications/styles.module.scss b/redisinsight/ui/src/components/notifications/styles.module.scss deleted file mode 100644 index 817b08578e..0000000000 --- a/redisinsight/ui/src/components/notifications/styles.module.scss +++ /dev/null @@ -1,56 +0,0 @@ -.toastSuccessBtn { - background-color: var(--euiToastSuccessBtnColor) !important; - border: none !important; -} - -.list { - font: normal normal normal 12px/17px Graphik, sans-serif; - font-weight: 400; - padding-bottom: 10px; - - &:first-of-type { - padding-top: 10px; - } -} - -:global(.euiToast) { - box-shadow: none !important; -} - -.infiniteMessage { - :global { - .euiToastHeader { - display: none; - } - - .euiText, .euiTitle { - color: var(--euiColorPrimaryText) !important; - } - - .euiToast__closeButton { - opacity: 1; - } - - .infiniteMessage__title { - display: flex; - font-size: 18px; - line-height: 1.2; - margin-bottom: 8px; - - padding-right: 24px; - } - - .infiniteMessage__icon { - margin-right: 8px; - margin-top: 2px; - } - - .infiniteMessage__btn .euiButton__text { - color: var(--euiColorSecondaryText) !important; - } - } - - &:global(.euiToast.wide) { - width: 368px !important; - } -} diff --git a/redisinsight/ui/src/components/notifications/success-messages.tsx b/redisinsight/ui/src/components/notifications/success-messages.tsx index 6275acced6..f1f876ac90 100644 --- a/redisinsight/ui/src/components/notifications/success-messages.tsx +++ b/redisinsight/ui/src/components/notifications/success-messages.tsx @@ -1,4 +1,5 @@ import React from 'react' +import styled from 'styled-components' import { EXTERNAL_LINKS } from 'uiSrc/constants/links' import { IBulkActionOverview, @@ -14,10 +15,16 @@ import { import { numberWithSpaces } from 'uiSrc/utils/numbers' import { FlexItem, Row } from 'uiSrc/components/base/layout/flex' import { Text, Title } from 'uiSrc/components/base/text' -import styles from './styles.module.scss' -import { Spacer } from '../base/layout' +import { Spacer } from 'uiSrc/components/base/layout' + +const Li = styled.li>` + padding-bottom: 10px; + + &:first-of-type { + padding-top: 10px; + } +` -// TODO: use i18n file for texts export default { ADDED_NEW_INSTANCE: (instanceName: string) => ({ title: 'Database has been added', @@ -78,9 +85,11 @@ export default {
    {instanceNames.slice(0, limitShowRemovedInstances).map((el, i) => ( // eslint-disable-next-line react/no-array-index-key -
  • - {formatNameShort(el)} -
  • +
  • + + {formatNameShort(el)} + +
  • ))} {instanceNames.length >= limitShowRemovedInstances &&
  • ...
  • }
@@ -103,9 +112,11 @@ export default {
    {instanceNames.slice(0, limitShowRemovedInstances).map((el, i) => ( // eslint-disable-next-line react/no-array-index-key -
  • - {formatNameShort(el)} -
  • +
  • + + {formatNameShort(el)} + +
  • ))} {instanceNames.length >= limitShowRemovedInstances &&
  • ...
  • }
@@ -173,9 +184,11 @@ export default {
    {listOfElements.slice(0, limitShowRemovedElements).map((el, i) => ( // eslint-disable-next-line react/no-array-index-key -
  • - {formatNameShort(bufferToString(el))} -
  • +
  • + + {formatNameShort(bufferToString(el))} + +
  • ))} {listOfElements.length >= limitShowRemovedElements &&
  • ...
  • }
diff --git a/redisinsight/ui/src/components/oauth/oauth-sso/oauth-autodiscovery/OAuthAutodiscovery.tsx b/redisinsight/ui/src/components/oauth/oauth-sso/oauth-autodiscovery/OAuthAutodiscovery.tsx index 637f9a1269..043b82506d 100644 --- a/redisinsight/ui/src/components/oauth/oauth-sso/oauth-autodiscovery/OAuthAutodiscovery.tsx +++ b/redisinsight/ui/src/components/oauth/oauth-sso/oauth-autodiscovery/OAuthAutodiscovery.tsx @@ -80,6 +80,7 @@ const OAuthAutodiscovery = (props: Props) => { {' '} account to auto-discover subscriptions and add your databases. + { const { onClick, className, inline, disabled } = props const agreement = useSelector(oauthCloudPAgreementSelector) - const socialLinks = [ - { - text: 'Google', - className: styles.googleButton, - icon: 'GoogleSigninIcon', - label: 'google-oauth', - strategy: OAuthStrategy.Google, - }, - { - text: 'Github', - className: styles.githubButton, - icon: 'GithubIcon', - label: 'github-oauth', - strategy: OAuthStrategy.GitHub, - }, - { - text: 'SSO', - className: styles.ssoButton, - icon: 'SsoIcon', - label: 'sso-oauth', - strategy: OAuthStrategy.SSO, - }, - ] - return ( -
@@ -61,6 +64,7 @@ const OAuthSocialButtons = (props: Props) => { data-testid={`${label}-tooltip`} > { data-testid={label} aria-labelledby={label} > - + {text} ))} -
+ ) } diff --git a/redisinsight/ui/src/components/oauth/shared/oauth-social-buttons/styles.module.scss b/redisinsight/ui/src/components/oauth/shared/oauth-social-buttons/styles.module.scss index bfef0a5ebc..e04b4bca56 100644 --- a/redisinsight/ui/src/components/oauth/shared/oauth-social-buttons/styles.module.scss +++ b/redisinsight/ui/src/components/oauth/shared/oauth-social-buttons/styles.module.scss @@ -1,7 +1,4 @@ .container { - display: flex; - align-items: center; - .button { height: auto !important; padding: 0 !important; diff --git a/redisinsight/ui/src/pages/home/components/db-status/DbStatus.spec.tsx b/redisinsight/ui/src/pages/home/components/db-status/DbStatus.spec.tsx index 022736c619..6185921781 100644 --- a/redisinsight/ui/src/pages/home/components/db-status/DbStatus.spec.tsx +++ b/redisinsight/ui/src/pages/home/components/db-status/DbStatus.spec.tsx @@ -18,8 +18,22 @@ jest.mock('uiSrc/telemetry', () => ({ const mockedProps = mock() const daysToMs = (days: number) => days * 60 * 60 * 24 * 1000 +let mockDate: Date describe('DbStatus', () => { + beforeEach(() => { + jest.clearAllMocks() + + // Set up fake timers + jest.useFakeTimers() + mockDate = new Date('2024-11-22T12:00:00Z') + jest.setSystemTime(mockDate) + }) + + afterEach(() => { + jest.useRealTimers() + }) + it('should render', () => { expect(render()).toBeTruthy() }) diff --git a/redisinsight/ui/src/slices/app/notifications.ts b/redisinsight/ui/src/slices/app/notifications.ts index d901ac827b..e7d37457ad 100644 --- a/redisinsight/ui/src/slices/app/notifications.ts +++ b/redisinsight/ui/src/slices/app/notifications.ts @@ -168,6 +168,14 @@ const notificationsSlice = createSlice({ if (index === -1) { state.infiniteMessages.push(payload) } else { + const currentNotification = state.infiniteMessages[index] + // check if existing notification is exactly the same as the new one, if yes, do not update + if ( + currentNotification.variation && + payload.variation === currentNotification.variation + ) { + return + } state.infiniteMessages[index] = payload } }, diff --git a/redisinsight/ui/src/slices/interfaces/app.ts b/redisinsight/ui/src/slices/interfaces/app.ts index 525567331e..26d5240ea2 100644 --- a/redisinsight/ui/src/slices/interfaces/app.ts +++ b/redisinsight/ui/src/slices/interfaces/app.ts @@ -247,6 +247,7 @@ export interface IGlobalNotification { export interface InfiniteMessage { id: string + variation?: string variant?: ToastVariant className?: string message?: RiToastType['message']