diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx index e2c693b4bf..40ddd6eef7 100644 --- a/frontend/src/AppRoutes/index.tsx +++ b/frontend/src/AppRoutes/index.tsx @@ -23,6 +23,7 @@ import AlertRuleProvider from 'providers/Alert'; import { useAppContext } from 'providers/App/App'; import { IUser } from 'providers/App/types'; import { DashboardProvider } from 'providers/Dashboard/Dashboard'; +import { ErrorModalProvider } from 'providers/ErrorModalProvider'; import { QueryBuilderProvider } from 'providers/QueryBuilder'; import { Suspense, useCallback, useEffect, useState } from 'react'; import { Route, Router, Switch } from 'react-router-dom'; @@ -358,34 +359,36 @@ function App(): JSX.Element { - - - - - - - - }> - - {routes.map(({ path, component, exact }) => ( - - ))} - - - - - - - - - - - + + + + + + + + + }> + + {routes.map(({ path, component, exact }) => ( + + ))} + + + + + + + + + + + + diff --git a/frontend/src/assets/Error.tsx b/frontend/src/assets/Error.tsx new file mode 100644 index 0000000000..9b6924c4fc --- /dev/null +++ b/frontend/src/assets/Error.tsx @@ -0,0 +1,191 @@ +import React from 'react'; + +type ErrorIconProps = React.SVGProps; + +function ErrorIcon({ ...props }: ErrorIconProps): JSX.Element { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default ErrorIcon; diff --git a/frontend/src/components/ErrorModal/ErrorModal.styles.scss b/frontend/src/components/ErrorModal/ErrorModal.styles.scss new file mode 100644 index 0000000000..87c2ea6edd --- /dev/null +++ b/frontend/src/components/ErrorModal/ErrorModal.styles.scss @@ -0,0 +1,118 @@ +.error-modal { + &__trigger { + width: fit-content; + display: flex; + align-items: center; + gap: 4px; + border-radius: 20px; + background: rgba(229, 72, 77, 0.2); + padding-left: 3px; + padding-right: 8px; + cursor: pointer; + span { + color: var(--bg-cherry-500); + font-size: 10px; + font-weight: 500; + line-height: 20px; /* 200% */ + letter-spacing: 0.4px; + text-transform: uppercase; + } + } + &__wrap { + background: linear-gradient( + 180deg, + rgba(11, 12, 14, 0.12) 0.07%, + rgba(39, 8, 14, 0.24) 50.04%, + rgba(106, 29, 44, 0.36) 75.02%, + rgba(197, 57, 85, 0.48) 87.51%, + rgba(242, 71, 105, 0.6) 100% + ); + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + + .ant-modal { + bottom: 40px; + top: unset; + position: absolute; + width: 520px; + left: 0px; + right: 0px; + margin: auto; + } + } + &__body { + padding: 0; + background: var(--bg-ink-400); + overflow: hidden; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } + &__header { + background: none !important; + .ant-modal-title { + display: flex; + justify-content: space-between; + align-items: center; + } + .key-value-label { + padding: 0; + border: none; + border-radius: 4px; + overflow: hidden; + &__key, + &__value { + padding: 4px 8px; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.48px; + } + &__key { + text-transform: uppercase; + &, + &:hover { + color: var(--bg-vanilla-100); + } + } + &__value { + color: var(--bg-vanilla-400); + pointer-events: none; + } + } + .close-button { + padding: 3px 7px; + background: var(--bg-ink-400); + display: inline-flex; + align-items: center; + border-radius: 4px; + border: 1px solid var(--bg-slate-500); + box-shadow: none; + } + } + &__footer { + margin: 0 !important; + height: 6px; + background: var(--bg-sakura-500); + } + &__content { + padding: 0 !important; + border-radius: 4px; + overflow: hidden; + background: none !important; + } +} + +.lightMode { + .error-modal { + &__body, + &__header .close-button { + background: var(--bg-vanilla-100); + } + &__header .close-button { + svg { + fill: var(--bg-vanilla-100); + } + } + } +} diff --git a/frontend/src/components/ErrorModal/ErrorModal.test.tsx b/frontend/src/components/ErrorModal/ErrorModal.test.tsx new file mode 100644 index 0000000000..64f880e8ce --- /dev/null +++ b/frontend/src/components/ErrorModal/ErrorModal.test.tsx @@ -0,0 +1,195 @@ +import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils'; +import APIError from 'types/api/error'; + +import ErrorModal from './ErrorModal'; + +// Mock the query client to return version data +const mockVersionData = { + payload: { + ee: 'Y', + version: '1.0.0', + }, +}; +jest.mock('react-query', () => ({ + ...jest.requireActual('react-query'), + useQueryClient: (): { getQueryData: () => typeof mockVersionData } => ({ + getQueryData: jest.fn(() => mockVersionData), + }), +})); +const mockError: APIError = new APIError({ + httpStatusCode: 400, + error: { + // eslint-disable-next-line sonarjs/no-duplicate-string + message: 'Something went wrong while processing your request.', + // eslint-disable-next-line sonarjs/no-duplicate-string + code: 'An error occurred', + // eslint-disable-next-line sonarjs/no-duplicate-string + url: 'https://example.com/docs', + errors: [ + { message: 'First error detail' }, + { message: 'Second error detail' }, + { message: 'Third error detail' }, + ], + }, +}); +describe('ErrorModal Component', () => { + it('should render the modal when open is true', () => { + render(); + + // Check if the error message is displayed + expect(screen.getByText('An error occurred')).toBeInTheDocument(); + expect( + screen.getByText('Something went wrong while processing your request.'), + ).toBeInTheDocument(); + }); + + it('should not render the modal when open is false', () => { + render(); + + // Check that the modal content is not in the document + expect(screen.queryByText('An error occurred')).not.toBeInTheDocument(); + }); + + it('should call onClose when the close button is clicked', async () => { + const onCloseMock = jest.fn(); + render(); + + // Click the close button + const closeButton = screen.getByTestId('close-button'); + act(() => { + fireEvent.click(closeButton); + }); + + // Check if onClose was called + expect(onCloseMock).toHaveBeenCalledTimes(1); + }); + + it('should display version data if available', async () => { + render(); + + // Check if the version data is displayed + expect(screen.getByText('ENTERPRISE')).toBeInTheDocument(); + expect(screen.getByText('1.0.0')).toBeInTheDocument(); + }); + it('should render the messages count badge when there are multiple errors', () => { + render(); + + // Check if the messages count badge is displayed + expect(screen.getByText('MESSAGES')).toBeInTheDocument(); + + expect(screen.getByText('3')).toBeInTheDocument(); + + // Check if the individual error messages are displayed + expect(screen.getByText('First error detail')).toBeInTheDocument(); + expect(screen.getByText('Second error detail')).toBeInTheDocument(); + expect(screen.getByText('Third error detail')).toBeInTheDocument(); + }); + + it('should render the open docs button when URL is provided', async () => { + render(); + + // Check if the open docs button is displayed + const openDocsButton = screen.getByTestId('error-docs-button'); + + expect(openDocsButton).toBeInTheDocument(); + + expect(openDocsButton).toHaveAttribute('href', 'https://example.com/docs'); + + expect(openDocsButton).toHaveAttribute('target', '_blank'); + }); + + it('should not display scroll for more if there are less than 10 messages', () => { + render(); + + expect(screen.queryByText('Scroll for more')).not.toBeInTheDocument(); + }); + it('should display scroll for more if there are more than 10 messages', async () => { + const longError = new APIError({ + httpStatusCode: 400, + error: { + ...mockError.error, + code: 'An error occurred', + message: 'Something went wrong while processing your request.', + url: 'https://example.com/docs', + errors: Array.from({ length: 15 }, (_, i) => ({ + message: `Error detail ${i + 1}`, + })), + }, + }); + + render(); + + // Check if the scroll hint is displayed + expect(screen.getByText('Scroll for more')).toBeInTheDocument(); + }); +}); +it('should render the trigger component if provided', () => { + const mockTrigger = Open Error Modal; + render( + , + ); + + // Check if the trigger component is rendered + expect(screen.getByText('Open Error Modal')).toBeInTheDocument(); +}); + +it('should open the modal when the trigger component is clicked', async () => { + const mockTrigger = Open Error Modal; + render( + , + ); + + // Click the trigger component + const triggerButton = screen.getByText('Open Error Modal'); + act(() => { + fireEvent.click(triggerButton); + }); + + // Check if the modal is displayed + expect(screen.getByText('An error occurred')).toBeInTheDocument(); +}); + +it('should render the default trigger tag if no trigger component is provided', () => { + render(); + + // Check if the default trigger tag is rendered + expect(screen.getByText('error')).toBeInTheDocument(); +}); + +it('should close the modal when the onCancel event is triggered', async () => { + const onCloseMock = jest.fn(); + render(); + + // Click the trigger component + const triggerButton = screen.getByText('error'); + act(() => { + fireEvent.click(triggerButton); + }); + + await waitFor(() => { + expect(screen.getByText('An error occurred')).toBeInTheDocument(); + }); + + // Trigger the onCancel event + act(() => { + fireEvent.click(screen.getByTestId('close-button')); + }); + + // Check if the modal is closed + expect(onCloseMock).toHaveBeenCalledTimes(1); + + await waitFor(() => { + // check if the modal is not visible + const modal = document.getElementsByClassName('ant-modal'); + const style = window.getComputedStyle(modal[0]); + expect(style.display).toBe('none'); + }); +}); diff --git a/frontend/src/components/ErrorModal/ErrorModal.tsx b/frontend/src/components/ErrorModal/ErrorModal.tsx new file mode 100644 index 0000000000..3765345ba4 --- /dev/null +++ b/frontend/src/components/ErrorModal/ErrorModal.tsx @@ -0,0 +1,102 @@ +import './ErrorModal.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Button, Modal, Tag } from 'antd'; +import { CircleAlert, X } from 'lucide-react'; +import KeyValueLabel from 'periscope/components/KeyValueLabel'; +import { useAppContext } from 'providers/App/App'; +import React from 'react'; +import APIError from 'types/api/error'; + +import ErrorContent from './components/ErrorContent'; + +type Props = { + error: APIError; + triggerComponent?: React.ReactElement; + onClose?: () => void; + open?: boolean; +}; + +const classNames = { + body: 'error-modal__body', + mask: 'error-modal__mask', + header: 'error-modal__header', + footer: 'error-modal__footer', + content: 'error-modal__content', +}; + +function ErrorModal({ + open, + error, + triggerComponent, + onClose, +}: Props): JSX.Element { + const [visible, setVisible] = React.useState(open); + + const handleClose = (): void => { + setVisible(false); + onClose?.(); + }; + + const { versionData } = useAppContext(); + + const versionDataPayload = versionData; + + return ( + <> + {!triggerComponent ? ( + } + color="error" + onClick={(): void => setVisible(true)} + > + error + + ) : ( + React.cloneElement(triggerComponent, { + onClick: () => setVisible(true), + }) + )} + + } + title={ + <> + {versionDataPayload ? ( + + ) : ( + + )} + + + + > + } + onCancel={handleClose} + closeIcon={false} + classNames={classNames} + wrapClassName="error-modal__wrap" + > + + + > + ); +} + +ErrorModal.defaultProps = { + onClose: undefined, + triggerComponent: null, + open: false, +}; + +export default ErrorModal; diff --git a/frontend/src/components/ErrorModal/components/ErrorContent.styles.scss b/frontend/src/components/ErrorModal/components/ErrorContent.styles.scss new file mode 100644 index 0000000000..a00f2111f3 --- /dev/null +++ b/frontend/src/components/ErrorModal/components/ErrorContent.styles.scss @@ -0,0 +1,208 @@ +.error-content { + display: flex; + flex-direction: column; + // === SECTION: Summary (Top) + &__summary-section { + display: flex; + flex-direction: column; + border-bottom: 1px solid var(--bg-slate-400); + } + + &__summary { + display: flex; + justify-content: space-between; + padding: 16px; + } + + &__summary-left { + display: flex; + align-items: baseline; + gap: 8px; + } + + &__summary-text { + display: flex; + flex-direction: column; + gap: 6px; + } + + &__error-code { + color: var(--bg-vanilla-100); + margin: 0; + font-size: 16px; + font-weight: 500; + line-height: 24px; /* 150% */ + letter-spacing: -0.08px; + } + + &__error-message { + margin: 0; + color: var(--bg-vanilla-400); + font-size: 14px; + font-weight: 400; + line-height: 20px; /* 142.857% */ + letter-spacing: -0.07px; + } + + &__docs-button { + display: flex; + align-items: center; + gap: 6px; + padding: 9px 12.5px; + color: var(--bg-vanilla-400); + font-size: 12px; + font-weight: 400; + line-height: 18px; /* 150% */ + letter-spacing: 0.12px; + border-radius: 2px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + box-shadow: none; + } + + &__message-badge { + display: flex; + align-items: center; + gap: 12px; + padding: 0px 16px 16px; + + .key-value-label { + width: fit-content; + border-color: var(--bg-slate-400); + border-radius: 20px; + overflow: hidden; + &__key { + padding-left: 8px; + padding-right: 8px; + } + &__value { + padding-right: 10px; + color: var(--bg-vanilla-400); + font-size: 12px; + font-weight: 500; + line-height: 18px; /* 150% */ + letter-spacing: 0.48px; + pointer-events: none; + } + } + &-label { + display: flex; + align-items: center; + gap: 6px; + &-dot { + height: 6px; + width: 6px; + background: var(--bg-sakura-500); + border-radius: 50%; + } + &-text { + color: var(--bg-vanilla-100); + font-size: 10px; + font-weight: 500; + line-height: 18px; /* 180% */ + letter-spacing: 0.5px; + } + } + &-line { + flex: 1; + height: 8px; + background-image: radial-gradient(circle, #444c63 1px, transparent 2px); + background-size: 8px 11px; + background-position: top left; + padding: 6px; + } + } + + // === SECTION: Message List (Bottom) + + &__message-list-container { + position: relative; + } + + &__message-list { + margin: 0; + padding: 0; + list-style: none; + max-height: 275px; + } + + &__message-item { + position: relative; + margin-bottom: 4px; + color: var(--bg-vanilla-400); + font-family: Geist Mono; + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--bg-vanilla-400); + padding: 3px 12px; + padding-left: 26px; + } + + &__message-item::before { + font-family: unset; + content: ''; + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + width: 2px; + height: 4px; + border-radius: 50px; + background: var(--bg-slate-400); + } + + &__scroll-hint { + position: absolute; + bottom: 10px; + left: 0px; + right: 0px; + margin: auto; + width: fit-content; + display: inline-flex; + padding: 4px 12px 4px 10px; + justify-content: center; + align-items: center; + gap: 3px; + background: var(--bg-slate-200); + border-radius: 20px; + box-shadow: 0px 103px 12px 0px rgba(0, 0, 0, 0.01), + 0px 66px 18px 0px rgba(0, 0, 0, 0.01), 0px 37px 22px 0px rgba(0, 0, 0, 0.03), + 0px 17px 17px 0px rgba(0, 0, 0, 0.04), 0px 4px 9px 0px rgba(0, 0, 0, 0.04); + } + + &__scroll-hint-text { + color: var(--bg-vanilla-100); + + font-size: 12px; + font-weight: 400; + line-height: 18px; + letter-spacing: -0.06px; + } +} + +.lightMode { + .error-content { + &__error-code { + color: var(--bg-ink-100); + } + &__error-message { + color: var(--bg-ink-400); + } + &__message-item { + color: var(--bg-ink-400); + } + &__message-badge { + &-label-text { + color: var(--bg-ink-400); + } + .key-value-label__value { + color: var(--bg-ink-400); + } + } + &__docs-button { + background: var(--bg-vanilla-100); + color: var(--bg-ink-100); + } + } +} diff --git a/frontend/src/components/ErrorModal/components/ErrorContent.tsx b/frontend/src/components/ErrorModal/components/ErrorContent.tsx new file mode 100644 index 0000000000..3817b0d82c --- /dev/null +++ b/frontend/src/components/ErrorModal/components/ErrorContent.tsx @@ -0,0 +1,98 @@ +import './ErrorContent.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Button } from 'antd'; +import ErrorIcon from 'assets/Error'; +import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar'; +import { BookOpenText, ChevronsDown } from 'lucide-react'; +import KeyValueLabel from 'periscope/components/KeyValueLabel'; +import APIError from 'types/api/error'; + +interface ErrorContentProps { + error: APIError; +} + +function ErrorContent({ error }: ErrorContentProps): JSX.Element { + const { + url: errorUrl, + errors: errorMessages, + code: errorCode, + message: errorMessage, + } = error.error.error; + return ( + + {/* Summary Header */} + + + + + + + + + {errorCode} + {errorMessage} + + + + {errorUrl && ( + + + + Open Docs + + + )} + + + {errorMessages?.length > 0 && ( + + + + MESSAGES + + } + badgeValue={errorMessages.length.toString()} + /> + + + )} + + + {/* Detailed Messages */} + + + + + {errorMessages?.map((error) => ( + + {error.message} + + ))} + + + {errorMessages?.length > 10 && ( + + + Scroll for more + + )} + + + + ); +} + +export default ErrorContent; diff --git a/frontend/src/container/AllAlertChannels/__tests__/CreateAlertChannel.test.tsx b/frontend/src/container/AllAlertChannels/__tests__/CreateAlertChannel.test.tsx index 3735ed1ff6..13b12a3174 100644 --- a/frontend/src/container/AllAlertChannels/__tests__/CreateAlertChannel.test.tsx +++ b/frontend/src/container/AllAlertChannels/__tests__/CreateAlertChannel.test.tsx @@ -15,7 +15,7 @@ import { } from 'mocks-server/__mockdata__/alerts'; import { server } from 'mocks-server/server'; import { rest } from 'msw'; -import { fireEvent, render, screen, waitFor } from 'tests/test-utils'; +import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils'; import { testLabelInputAndHelpValue } from './testUtils'; @@ -30,6 +30,14 @@ jest.mock('hooks/useNotifications', () => ({ }, })), })); +const showErrorModal = jest.fn(); +jest.mock('providers/ErrorModalProvider', () => ({ + __esModule: true, + ...jest.requireActual('providers/ErrorModalProvider'), + useErrorModal: jest.fn(() => ({ + showErrorModal, + })), +})); jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({ MarkdownRenderer: jest.fn(() => Mocked MarkdownRenderer), @@ -119,7 +127,7 @@ describe('Create Alert Channel', () => { fireEvent.click(saveButton); - await waitFor(() => expect(errorNotification).toHaveBeenCalled()); + await waitFor(() => expect(showErrorModal).toHaveBeenCalled()); }); it('Should check if clicking on Test button shows "An alert has been sent to this channel" success message if testing passes', async () => { server.use( @@ -151,9 +159,11 @@ describe('Create Alert Channel', () => { name: 'button_test_channel', }); - fireEvent.click(testButton); + act(() => { + fireEvent.click(testButton); + }); - await waitFor(() => expect(errorNotification).toHaveBeenCalled()); + await waitFor(() => expect(showErrorModal).toHaveBeenCalled()); }); }); describe('New Alert Channel Cascading Fields Based on Channel Type', () => { diff --git a/frontend/src/container/CreateAlertChannels/index.tsx b/frontend/src/container/CreateAlertChannels/index.tsx index 8651474347..a55792533f 100644 --- a/frontend/src/container/CreateAlertChannels/index.tsx +++ b/frontend/src/container/CreateAlertChannels/index.tsx @@ -16,6 +16,7 @@ import ROUTES from 'constants/routes'; import FormAlertChannels from 'container/FormAlertChannels'; import { useNotifications } from 'hooks/useNotifications'; import history from 'lib/history'; +import { useErrorModal } from 'providers/ErrorModalProvider'; import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import APIError from 'types/api/error'; @@ -42,6 +43,7 @@ function CreateAlertChannels({ }: CreateAlertChannelsProps): JSX.Element { // init namespace for translations const { t } = useTranslation('channels'); + const { showErrorModal } = useErrorModal(); const [formInstance] = Form.useForm(); @@ -145,15 +147,12 @@ function CreateAlertChannels({ history.replace(ROUTES.ALL_CHANNELS); return { status: 'success', statusMessage: t('channel_creation_done') }; } catch (error) { - notifications.error({ - message: (error as APIError).error.error.code, - description: (error as APIError).error.error.message, - }); + showErrorModal(error as APIError); return { status: 'failed', statusMessage: t('channel_creation_failed') }; } finally { setSavingState(false); } - }, [prepareSlackRequest, t, notifications]); + }, [prepareSlackRequest, notifications, t, showErrorModal]); const prepareWebhookRequest = useCallback(() => { // initial api request without auth params @@ -202,15 +201,12 @@ function CreateAlertChannels({ history.replace(ROUTES.ALL_CHANNELS); return { status: 'success', statusMessage: t('channel_creation_done') }; } catch (error) { - notifications.error({ - message: (error as APIError).getErrorCode(), - description: (error as APIError).getErrorMessage(), - }); + showErrorModal(error as APIError); return { status: 'failed', statusMessage: t('channel_creation_failed') }; } finally { setSavingState(false); } - }, [prepareWebhookRequest, t, notifications]); + }, [prepareWebhookRequest, notifications, t, showErrorModal]); const preparePagerRequest = useCallback(() => { const validationError = ValidatePagerChannel(selectedConfig as PagerChannel); @@ -254,15 +250,12 @@ function CreateAlertChannels({ } return { status: 'failed', statusMessage: t('channel_creation_failed') }; } catch (error) { - notifications.error({ - message: (error as APIError).getErrorCode(), - description: (error as APIError).getErrorMessage(), - }); + showErrorModal(error as APIError); return { status: 'failed', statusMessage: t('channel_creation_failed') }; } finally { setSavingState(false); } - }, [t, notifications, preparePagerRequest]); + }, [preparePagerRequest, t, notifications, showErrorModal]); const prepareOpsgenieRequest = useCallback( () => ({ @@ -287,15 +280,12 @@ function CreateAlertChannels({ history.replace(ROUTES.ALL_CHANNELS); return { status: 'success', statusMessage: t('channel_creation_done') }; } catch (error) { - notifications.error({ - message: (error as APIError).getErrorCode(), - description: (error as APIError).getErrorMessage(), - }); + showErrorModal(error as APIError); return { status: 'failed', statusMessage: t('channel_creation_failed') }; } finally { setSavingState(false); } - }, [prepareOpsgenieRequest, t, notifications]); + }, [prepareOpsgenieRequest, notifications, t, showErrorModal]); const prepareEmailRequest = useCallback( () => ({ @@ -320,15 +310,12 @@ function CreateAlertChannels({ history.replace(ROUTES.ALL_CHANNELS); return { status: 'success', statusMessage: t('channel_creation_done') }; } catch (error) { - notifications.error({ - message: (error as APIError).getErrorCode(), - description: (error as APIError).getErrorMessage(), - }); + showErrorModal(error as APIError); return { status: 'failed', statusMessage: t('channel_creation_failed') }; } finally { setSavingState(false); } - }, [prepareEmailRequest, t, notifications]); + }, [prepareEmailRequest, notifications, t, showErrorModal]); const prepareMsTeamsRequest = useCallback( () => ({ @@ -353,15 +340,12 @@ function CreateAlertChannels({ history.replace(ROUTES.ALL_CHANNELS); return { status: 'success', statusMessage: t('channel_creation_done') }; } catch (error) { - notifications.error({ - message: (error as APIError).getErrorCode(), - description: (error as APIError).getErrorMessage(), - }); + showErrorModal(error as APIError); return { status: 'failed', statusMessage: t('channel_creation_failed') }; } finally { setSavingState(false); } - }, [prepareMsTeamsRequest, t, notifications]); + }, [prepareMsTeamsRequest, notifications, t, showErrorModal]); const onSaveHandler = useCallback( async (value: ChannelType) => { @@ -459,10 +443,8 @@ function CreateAlertChannels({ status: 'Test success', }); } catch (error) { - notifications.error({ - message: (error as APIError).error.error.code, - description: (error as APIError).error.error.message, - }); + showErrorModal(error as APIError); + logEvent('Alert Channel: Test notification', { type: channelType, sendResolvedAlert: selectedConfig?.send_resolved, diff --git a/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx index c0987ccff1..ee3cc7f08d 100644 --- a/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx +++ b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx @@ -6,7 +6,7 @@ import { useMemo } from 'react'; import TrimmedText from '../TrimmedText/TrimmedText'; type KeyValueLabelProps = { - badgeKey: string; + badgeKey: string | React.ReactNode; badgeValue: string; maxCharacters?: number; }; @@ -25,7 +25,11 @@ export default function KeyValueLabel({ return ( - + {typeof badgeKey === 'string' ? ( + + ) : ( + badgeKey + )} {isUrl ? ( { if ( !isFetchingOrgPreferences && @@ -246,6 +253,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element { updateUser, updateOrgPreferences, updateOrg, + versionData: versionData?.payload || null, }), [ trialInfo, @@ -265,6 +273,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element { updateOrg, user, userFetchError, + versionData, ], ); return {children}; diff --git a/frontend/src/providers/App/types.ts b/frontend/src/providers/App/types.ts index 4dd5b31bf9..6823b9d827 100644 --- a/frontend/src/providers/App/types.ts +++ b/frontend/src/providers/App/types.ts @@ -3,6 +3,7 @@ import { FeatureFlagProps as FeatureFlags } from 'types/api/features/getFeatures import { LicenseResModel, TrialInfo } from 'types/api/licensesV3/getActive'; import { Organization } from 'types/api/user/getOrganization'; import { UserResponse as User } from 'types/api/user/getUser'; +import { PayloadProps } from 'types/api/user/getVersion'; import { OrgPreference } from 'types/reducer/app'; export interface IAppContext { @@ -25,6 +26,7 @@ export interface IAppContext { updateUser: (user: IUser) => void; updateOrgPreferences: (orgPreferences: OrgPreference[]) => void; updateOrg(orgId: string, updatedOrgName: string): void; + versionData: PayloadProps | null; } // User diff --git a/frontend/src/providers/ErrorModalProvider.tsx b/frontend/src/providers/ErrorModalProvider.tsx new file mode 100644 index 0000000000..7a47d95316 --- /dev/null +++ b/frontend/src/providers/ErrorModalProvider.tsx @@ -0,0 +1,60 @@ +import ErrorModal from 'components/ErrorModal/ErrorModal'; +import { + createContext, + ReactNode, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import APIError from 'types/api/error'; + +interface ErrorModalContextType { + showErrorModal: (error: APIError) => void; + hideErrorModal: () => void; +} + +const ErrorModalContext = createContext( + undefined, +); + +export function ErrorModalProvider({ + children, +}: { + children: ReactNode; +}): JSX.Element { + const [error, setError] = useState(null); + const [isVisible, setIsVisible] = useState(false); + + const showErrorModal = useCallback((error: APIError): void => { + setError(error); + setIsVisible(true); + }, []); + + const hideErrorModal = useCallback((): void => { + setError(null); + setIsVisible(false); + }, []); + + const value = useMemo(() => ({ showErrorModal, hideErrorModal }), [ + showErrorModal, + hideErrorModal, + ]); + + return ( + + {children} + {isVisible && error && ( + + )} + + ); +} + +export const useErrorModal = (): ErrorModalContextType => { + const context = useContext(ErrorModalContext); + if (!context) { + throw new Error('useErrorModal must be used within an ErrorModalProvider'); + } + return context; +}; diff --git a/frontend/src/tests/test-utils.tsx b/frontend/src/tests/test-utils.tsx index cdbe9bdd6d..f58b818ec0 100644 --- a/frontend/src/tests/test-utils.tsx +++ b/frontend/src/tests/test-utils.tsx @@ -5,6 +5,7 @@ import ROUTES from 'constants/routes'; import { ResourceProvider } from 'hooks/useResourceAttribute'; import { AppContext } from 'providers/App/App'; import { IAppContext } from 'providers/App/types'; +import { ErrorModalProvider } from 'providers/ErrorModalProvider'; import { QueryBuilderProvider } from 'providers/QueryBuilder'; import TimezoneProvider from 'providers/Timezone'; import React, { ReactElement } from 'react'; @@ -234,6 +235,11 @@ export function getAppContextMock( updateOrg: jest.fn(), updateOrgPreferences: jest.fn(), activeLicenseRefetch: jest.fn(), + versionData: { + version: '1.0.0', + ee: 'Y', + setupCompleted: true, + }, ...appContextOverrides, }; } @@ -249,16 +255,18 @@ function AllTheProviders({ return ( - - - - {/* Use the mock store with the provided role */} - - {children} - - - - + + + + + {/* Use the mock store with the provided role */} + + {children} + + + + + );
{errorMessage}