diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html index a3407dc6d5..8157444099 100644 --- a/.storybook/preview-head.html +++ b/.storybook/preview-head.html @@ -1,6 +1,20 @@ + + + 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/notifications/ErrorNotifications.stories.tsx b/redisinsight/ui/src/components/notifications/ErrorNotifications.stories.tsx new file mode 100644 index 0000000000..21bc0fea14 --- /dev/null +++ b/redisinsight/ui/src/components/notifications/ErrorNotifications.stories.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import { faker } from '@faker-js/faker' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { action } from 'storybook/actions' + +import { PrimaryButton } from 'uiSrc/components/base/forms/buttons' +import { Row } from 'uiSrc/components/base/layout/flex' + +import Notifications from './Notifications' +import ERROR_MESSAGES from './error-messages' + +const meta = { + component: Notifications, +} satisfies Meta + +export default meta + +type Story = StoryObj + +const ErrorNotifications = () => { + return ( + <> + + + { + ERROR_MESSAGES.DEFAULT( + faker.lorem.sentence(), + action('default error close'), + 'Error', + ) + }} + size="small" + > + Default Error + + { + ERROR_MESSAGES.ENCRYPTION( + action('encryption error close'), + faker.database.mongodbObjectId(), + ) + }} + size="small" + > + Encryption Error + + { + ERROR_MESSAGES.CLOUD_CAPI_KEY_UNAUTHORIZED( + { + message: 'Your API key is unauthorized to access this resource', + title: 'Unauthorized', + }, + { + resourceId: faker.string.uuid(), + }, + action('cloud capi unauthorized close'), + ) + }} + size="small" + > + Cloud CAPI Unauthorized + + { + ERROR_MESSAGES.RDI_DEPLOY_PIPELINE( + { + title: 'Pipeline deployment failed', + message: faker.lorem.paragraph(), + }, + action('rdi deploy error close'), + ) + }} + size="small" + > + RDI Deploy Pipeline Error + + + + ) +} + +export const Errors: Story = { + render: () => , +} diff --git a/redisinsight/ui/src/components/notifications/InfiniteNotifications.stories.tsx b/redisinsight/ui/src/components/notifications/InfiniteNotifications.stories.tsx new file mode 100644 index 0000000000..733c267724 --- /dev/null +++ b/redisinsight/ui/src/components/notifications/InfiniteNotifications.stories.tsx @@ -0,0 +1,216 @@ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { useDispatch } from 'react-redux' +import { action } from 'storybook/actions' + +import { addInfiniteNotification } from 'uiSrc/slices/app/notifications' +import { PrimaryButton } from 'uiSrc/components/base/forms/buttons' +import { Row } from 'uiSrc/components/base/layout/flex' +import { INFINITE_MESSAGES } from 'uiSrc/components/notifications/components' +import { CloudJobName, CloudJobStep } from 'uiSrc/electron/constants' +import { OAuthProvider } from 'uiSrc/components/oauth/oauth-select-plan/constants' + +import Notifications from './Notifications' + +const meta = { + component: Notifications, +} satisfies Meta + +export default meta + +type Story = StoryObj + +const InfiniteNotifications = () => { + const dispatch = useDispatch() + + return ( + <> + + + { + dispatch( + addInfiniteNotification(INFINITE_MESSAGES.AUTHENTICATING()), + ) + }} + size="small" + > + Authenticating + + { + dispatch( + addInfiniteNotification( + INFINITE_MESSAGES.PENDING_CREATE_DB(CloudJobStep.Credentials), + ), + ) + }} + size="small" + > + Pending Create DB (Credentials) + + { + dispatch( + addInfiniteNotification( + INFINITE_MESSAGES.PENDING_CREATE_DB(CloudJobStep.Subscription), + ), + ) + }} + size="small" + > + Pending Create DB (Subscription) + + { + dispatch( + addInfiniteNotification( + INFINITE_MESSAGES.PENDING_CREATE_DB(CloudJobStep.Database), + ), + ) + }} + size="small" + > + Pending Create DB (Database) + + { + dispatch( + addInfiniteNotification( + INFINITE_MESSAGES.PENDING_CREATE_DB(CloudJobStep.Import), + ), + ) + }} + size="small" + > + Pending Create DB (Import) + + { + dispatch( + addInfiniteNotification( + INFINITE_MESSAGES.SUCCESS_CREATE_DB( + { + provider: OAuthProvider.AWS, + region: 'us-east-1', + }, + action('create aws success'), + CloudJobName.CreateFreeDatabase, + ), + ), + ) + }} + size="small" + > + Success Create DB (AWS) + + { + dispatch( + addInfiniteNotification( + INFINITE_MESSAGES.SUCCESS_CREATE_DB( + { + provider: OAuthProvider.Google, + region: 'us-central1', + }, + action('create gcp success'), + CloudJobName.CreateFreeSubscriptionAndDatabase, + ), + ), + ) + }} + size="small" + > + Success Create DB (GCP) + + { + dispatch( + addInfiniteNotification( + INFINITE_MESSAGES.DATABASE_EXISTS( + action('db exists success'), + action('db exists close'), + ), + ), + ) + }} + size="small" + > + Database Exists + + { + dispatch( + addInfiniteNotification( + INFINITE_MESSAGES.DATABASE_IMPORT_FORBIDDEN( + action('db import forbidden close'), + ), + ), + ) + }} + size="small" + > + Database Import Forbidden + + { + dispatch( + addInfiniteNotification( + INFINITE_MESSAGES.SUBSCRIPTION_EXISTS( + action('subscription exists success'), + action('subscription exists close'), + ), + ), + ) + }} + size="small" + > + Subscription Exists + + { + dispatch( + addInfiniteNotification( + INFINITE_MESSAGES.AUTO_CREATING_DATABASE(), + ), + ) + }} + size="small" + > + Auto Creating Database + + { + dispatch( + addInfiniteNotification( + INFINITE_MESSAGES.APP_UPDATE_AVAILABLE( + '2.60.0', + action('app update available success'), + ), + ), + ) + }} + size="small" + > + App Update Available + + { + dispatch( + addInfiniteNotification( + INFINITE_MESSAGES.SUCCESS_DEPLOY_PIPELINE(), + ), + ) + }} + size="small" + > + Success Deploy Pipeline + + + + ) +} + +export const Infinite: Story = { + render: () => , +} diff --git a/redisinsight/ui/src/components/notifications/MessageNotifications.stories.tsx b/redisinsight/ui/src/components/notifications/MessageNotifications.stories.tsx new file mode 100644 index 0000000000..d5a2ca0451 --- /dev/null +++ b/redisinsight/ui/src/components/notifications/MessageNotifications.stories.tsx @@ -0,0 +1,377 @@ +import React, { useEffect } from 'react' +import { faker } from '@faker-js/faker' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { action } from 'storybook/actions' +import { useDispatch } from 'react-redux' + +import { + addMessageNotification, + resetMessages, +} from 'uiSrc/slices/app/notifications' +import { PrimaryButton } from 'uiSrc/components/base/forms/buttons' +import { Row } from 'uiSrc/components/base/layout/flex' +import { stringToBuffer } from 'uiSrc/utils' +import { BulkActionsStatus, RedisDataType } from 'uiSrc/constants' + +import Notifications from './Notifications' +import SUCCESS_MESSAGES from './success-messages' +import { BulkActionType } from 'apiSrc/modules/bulk-actions/constants' + +const meta = { + component: Notifications, +} satisfies Meta + +export default meta + +type Story = StoryObj + +const MessageNotifications = () => { + const dispatch = useDispatch() + + useEffect(() => { + dispatch(resetMessages()) + }, []) + + return ( + <> + + + { + dispatch( + addMessageNotification( + SUCCESS_MESSAGES.ADDED_NEW_INSTANCE( + faker.database.mongodbObjectId(), + ), + ), + ) + }} + size="small" + > + Added New Instance + + { + dispatch( + addMessageNotification( + SUCCESS_MESSAGES.ADDED_NEW_RDI_INSTANCE( + faker.database.mongodbObjectId(), + ), + ), + ) + }} + size="small" + > + Added New RDI Instance + + { + dispatch( + addMessageNotification( + SUCCESS_MESSAGES.DELETE_INSTANCE( + faker.database.mongodbObjectId(), + ), + ), + ) + }} + size="small" + > + Delete Instance + + { + dispatch( + addMessageNotification( + SUCCESS_MESSAGES.DELETE_RDI_INSTANCE( + faker.database.mongodbObjectId(), + ), + ), + ) + }} + size="small" + > + Delete RDI Instance + + { + const instanceNames = Array.from({ length: 12 }, () => + faker.database.mongodbObjectId(), + ) + dispatch( + addMessageNotification( + SUCCESS_MESSAGES.DELETE_INSTANCES(instanceNames), + ), + ) + }} + size="small" + > + Delete Multiple Instances + + { + const instanceNames = Array.from({ length: 12 }, () => + faker.database.mongodbObjectId(), + ) + dispatch( + addMessageNotification( + SUCCESS_MESSAGES.DELETE_RDI_INSTANCES(instanceNames), + ), + ) + }} + size="small" + > + Delete Multiple RDI Instances + + { + dispatch( + addMessageNotification( + SUCCESS_MESSAGES.ADDED_NEW_KEY( + stringToBuffer(faker.lorem.word()), + ), + ), + ) + }} + size="small" + > + Added New Key + + { + dispatch( + addMessageNotification( + SUCCESS_MESSAGES.DELETED_KEY( + stringToBuffer(faker.lorem.word()), + ), + ), + ) + }} + size="small" + > + Deleted Key + + { + dispatch( + addMessageNotification( + SUCCESS_MESSAGES.REMOVED_KEY_VALUE( + stringToBuffer(faker.lorem.word()), + stringToBuffer(faker.lorem.word()), + 'Member', + ), + ), + ) + }} + size="small" + > + Removed Key Value + + { + const elements = Array.from({ length: 12 }, () => + stringToBuffer(faker.lorem.word()), + ) + dispatch( + addMessageNotification( + SUCCESS_MESSAGES.REMOVED_LIST_ELEMENTS( + stringToBuffer(faker.lorem.word()), + elements.length, + elements, + ), + ), + ) + }} + size="small" + > + Removed List Elements + + { + dispatch( + addMessageNotification( + SUCCESS_MESSAGES.INSTALLED_NEW_UPDATE( + '2.70.0', + action('update link click'), + ), + ), + ) + }} + size="small" + > + Installed New Update + + { + dispatch( + addMessageNotification( + SUCCESS_MESSAGES.MESSAGE_ACTION( + faker.lorem.sentence(), + 'claimed', + ), + ), + ) + }} + size="small" + > + Message Action + + { + dispatch( + addMessageNotification(SUCCESS_MESSAGES.NO_CLAIMED_MESSAGES()), + ) + }} + size="small" + > + No Claimed Messages + + { + dispatch(addMessageNotification(SUCCESS_MESSAGES.CREATE_INDEX())) + }} + size="small" + > + Create Index + + { + dispatch( + addMessageNotification( + SUCCESS_MESSAGES.DELETE_INDEX(faker.lorem.word()), + ), + ) + }} + size="small" + > + Delete Index + + { + dispatch(addMessageNotification(SUCCESS_MESSAGES.TEST_CONNECTION())) + }} + size="small" + > + Test Connection + + { + dispatch( + addMessageNotification( + SUCCESS_MESSAGES.UPLOAD_DATA_BULK( + { + id: faker.string.uuid(), + databaseId: faker.string.uuid(), + type: BulkActionType.Upload, + status: BulkActionsStatus.Completed, + filter: { + type: RedisDataType.String, + match: '*', + }, + progress: { + total: 1000, + scanned: 1000, + }, + summary: { + processed: 1000, + succeed: 950, + failed: 50, + errors: [], + keys: [], + }, + duration: 12345, + }, + 'bulk-data.txt', + ), + ), + ) + }} + size="small" + > + Upload Data Bulk + + { + dispatch( + addMessageNotification( + SUCCESS_MESSAGES.DELETE_LIBRARY(faker.lorem.word()), + ), + ) + }} + size="small" + > + Delete Library + + { + dispatch( + addMessageNotification( + SUCCESS_MESSAGES.ADD_LIBRARY(faker.lorem.word()), + ), + ) + }} + size="small" + > + Add Library + + { + dispatch( + addMessageNotification(SUCCESS_MESSAGES.REMOVED_ALL_CAPI_KEYS()), + ) + }} + size="small" + > + Removed All CAPI Keys + + { + dispatch( + addMessageNotification( + SUCCESS_MESSAGES.REMOVED_CAPI_KEY(faker.lorem.word()), + ), + ) + }} + size="small" + > + Removed CAPI Key + + { + dispatch( + addMessageNotification( + SUCCESS_MESSAGES.DATABASE_ALREADY_EXISTS(), + ), + ) + }} + size="small" + > + Database Already Exists + + { + dispatch( + addMessageNotification(SUCCESS_MESSAGES.SUCCESS_RESET_PIPELINE()), + ) + }} + size="small" + > + Success Reset Pipeline + + { + dispatch( + addMessageNotification(SUCCESS_MESSAGES.SUCCESS_TAGS_UPDATED()), + ) + }} + size="small" + > + Success Tags Updated + + + + ) +} + +export const Messages: Story = { + render: () => , +} diff --git a/redisinsight/ui/src/components/notifications/Notifications.tsx b/redisinsight/ui/src/components/notifications/Notifications.tsx index a7c8d0be6f..d9be9f4f2b 100644 --- a/redisinsight/ui/src/components/notifications/Notifications.tsx +++ b/redisinsight/ui/src/components/notifications/Notifications.tsx @@ -1,228 +1,15 @@ -import React, { useEffect, useRef } from 'react' -import { useDispatch, useSelector } from 'react-redux' -import cx from 'classnames' +import React from 'react' +import { RiToaster } from 'uiSrc/components/base/display/toast' 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' + useErrorNotifications, + useMessageNotifications, + useInfiniteNotifications, +} from './hooks' 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} - ) - - 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]) + useErrorNotifications() + useMessageNotifications() + useInfiniteNotifications() return } 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 655dfa2f1b..b9677b0c8d 100644 --- a/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.tsx +++ b/redisinsight/ui/src/components/notifications/components/infinite-messages/InfiniteMessages.tsx @@ -38,11 +38,23 @@ 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…', 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..7a837ff4d8 --- /dev/null +++ b/redisinsight/ui/src/components/notifications/hooks/useErrorNotifications.ts @@ -0,0 +1,81 @@ +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' + +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 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) + }, + ) + + 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..59fc01757a --- /dev/null +++ b/redisinsight/ui/src/components/notifications/hooks/useInfiniteNotifications.ts @@ -0,0 +1,89 @@ +import { useEffect, useRef } from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import { InfiniteMessage } from 'uiSrc/slices/interfaces' +import { riToast } from 'uiSrc/components/base/display/toast' +import { + infiniteNotificationsSelector, + removeInfiniteNotification, +} from 'uiSrc/slices/app/notifications' +import cx from 'classnames' +import { InfiniteMessagesIds } from 'uiSrc/components/notifications/components' +import { showOAuthProgress } from 'uiSrc/slices/oauth/cloud' +import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' + +const ONE_HOUR = 3_600_000 + +export const useInfiniteNotifications = () => { + const infiniteNotifications = useSelector(infiniteNotificationsSelector) + const dispatch = useDispatch() + const infiniteToastIdsRef = useRef(new Map()) + + const showInfiniteToasts = (data: InfiniteMessage[]) => { + data.forEach((notification: InfiniteMessage) => { + const { + id, + message, + description, + actions, + className = '', + variant, + customIcon, + showCloseButton = true, + onClose: onCloseCallback, + } = notification + const toastId = riToast( + { + className: cx(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, + }, + ) + // if this infinite toast id is already in the map, dismiss it + if (infiniteToastIdsRef.current.has(id)) { + riToast.dismiss(infiniteToastIdsRef.current.get(id)) + } + infiniteToastIdsRef.current.set(id, toastId) + }) + } + + useEffect(() => { + showInfiniteToasts(infiniteNotifications) + }, [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..6ef1e53585 --- /dev/null +++ b/redisinsight/ui/src/components/notifications/hooks/useMessageNotifications.tsx @@ -0,0 +1,73 @@ +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' + +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 }, + ) + 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 &&
  • ...
  • }