feat(error): build generic error component (#8038)

* feat: build generic error component

* chore: test error component in DataSourceInfo component

* feat: get version from API + minor improvements

* feat: enhance error notifications with ErrorV2 support and integrate ErrorModal

* feat: implement ErrorModalContext + directly display error modal in create channel if request fails

* chore: write tests for the generic error modal

* chore: add optional chaining + __blank to _blank

* test: add trigger component tests for ErrorModal component

* test: fix the failing tests by wrapping in ErrorModalProvider

* chore: address review comments

* test: fix the failing tests

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
This commit is contained in:
Shaheer Kochai 2025-05-26 21:20:24 +04:30 committed by GitHub
parent 3a396602a8
commit 4d484b225f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1068 additions and 78 deletions

View File

@ -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 {
<CompatRouter>
<UserpilotRouteTracker />
<NotificationProvider>
<PrivateRoute>
<ResourceProvider>
<QueryBuilderProvider>
<DashboardProvider>
<KeyboardHotkeysProvider>
<AlertRuleProvider>
<AppLayout>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<Route exact path="/" component={Home} />
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</AppLayout>
</AlertRuleProvider>
</KeyboardHotkeysProvider>
</DashboardProvider>
</QueryBuilderProvider>
</ResourceProvider>
</PrivateRoute>
<ErrorModalProvider>
<PrivateRoute>
<ResourceProvider>
<QueryBuilderProvider>
<DashboardProvider>
<KeyboardHotkeysProvider>
<AlertRuleProvider>
<AppLayout>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<Route exact path="/" component={Home} />
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</AppLayout>
</AlertRuleProvider>
</KeyboardHotkeysProvider>
</DashboardProvider>
</QueryBuilderProvider>
</ResourceProvider>
</PrivateRoute>
</ErrorModalProvider>
</NotificationProvider>
</CompatRouter>
</Router>

View File

@ -0,0 +1,191 @@
import React from 'react';
type ErrorIconProps = React.SVGProps<SVGSVGElement>;
function ErrorIcon({ ...props }: ErrorIconProps): JSX.Element {
return (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
>
<path
fill="#C62828"
d="M1.281 5.78a.922.922 0 0 0-.92.921v4.265a.922.922 0 0 0 .92.92h.617V5.775l-.617.005ZM12.747 5.78c.508 0 .92.413.92.92v4.264a.923.923 0 0 1-.92.922h-.617V5.775l.617.004Z"
/>
<path
fill="#90A4AE"
d="M12.463 5.931 12.45 4.82a.867.867 0 0 0-.87-.861H7.34v-1.49a1.083 1.083 0 1 0-.68 0v1.496H2.42a.867.867 0 0 0-.864.86v7.976c.003.475.389.86.865.862h9.16a.868.868 0 0 0 .869-.862v-.82h.013v-6.05Z"
/>
<path
fill="#C62828"
d="M7 1.885a.444.444 0 1 1 0-.888.444.444 0 0 1 0 .888Z"
/>
<path
fill="url(#a)"
d="M4.795 10.379h4.412c.384 0 .696.312.696.697v.063a.697.697 0 0 1-.696.697H4.795a.697.697 0 0 1-.697-.697v-.063c0-.385.312-.697.697-.697Z"
/>
<path fill="url(#b)" d="M6.115 10.38h-.262v1.455h.262V10.38Z" />
<path fill="url(#c)" d="M7.138 10.38h-.262v1.455h.262V10.38Z" />
<path fill="url(#d)" d="M8.147 10.38h-.262v1.455h.262V10.38Z" />
<path fill="url(#e)" d="M9.22 10.38h-.262v1.455h.262V10.38Z" />
<path fill="url(#f)" d="M5.042 10.379H4.78v1.454h.262V10.38Z" />
<path
fill="#C62828"
d="M7 9.367h-.593a.111.111 0 0 1-.098-.162l.304-.6.288-.532a.11.11 0 0 1 .195 0l.29.556.301.576a.11.11 0 0 1-.098.162H7Z"
/>
<path
fill="url(#g)"
d="M4.627 8.587a1.278 1.278 0 1 0 0-2.556 1.278 1.278 0 0 0 0 2.556Z"
/>
<path
fill="url(#h)"
fillRule="evenodd"
d="M4.627 6.142a1.167 1.167 0 1 0 0 2.333 1.167 1.167 0 0 0 0-2.333ZM3.237 7.31a1.389 1.389 0 1 1 2.778 0 1.389 1.389 0 0 1-2.777 0Z"
clipRule="evenodd"
/>
<path
fill="url(#i)"
d="M9.333 6.028a1.278 1.278 0 1 0 0 2.556 1.278 1.278 0 0 0 0-2.556Z"
/>
<path
fill="url(#j)"
fillRule="evenodd"
d="M7.944 7.306a1.39 1.39 0 0 1 2.778 0 1.389 1.389 0 0 1-2.778 0Zm1.39-1.167a1.167 1.167 0 1 0 0 2.334 1.167 1.167 0 0 0 0-2.334Z"
clipRule="evenodd"
/>
<defs>
<linearGradient
id="a"
x1="7.001"
x2="7.001"
y1="11.836"
y2="10.379"
gradientUnits="userSpaceOnUse"
>
<stop offset=".12" stopColor="#E0E0E0" />
<stop offset=".52" stopColor="#fff" />
<stop offset="1" stopColor="#EAEAEA" />
</linearGradient>
<linearGradient
id="b"
x1="5.984"
x2="5.984"
y1="11.835"
y2="10.381"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#333" />
<stop offset=".55" stopColor="#666" />
<stop offset="1" stopColor="#333" />
</linearGradient>
<linearGradient
id="c"
x1="7.007"
x2="7.007"
y1="11.835"
y2="10.381"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#333" />
<stop offset=".55" stopColor="#666" />
<stop offset="1" stopColor="#333" />
</linearGradient>
<linearGradient
id="d"
x1="8.016"
x2="8.016"
y1="11.835"
y2="10.381"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#333" />
<stop offset=".55" stopColor="#666" />
<stop offset="1" stopColor="#333" />
</linearGradient>
<linearGradient
id="e"
x1="9.089"
x2="9.089"
y1="11.835"
y2="10.381"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#333" />
<stop offset=".55" stopColor="#666" />
<stop offset="1" stopColor="#333" />
</linearGradient>
<linearGradient
id="f"
x1="4.911"
x2="4.911"
y1="11.833"
y2="10.379"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#333" />
<stop offset=".55" stopColor="#666" />
<stop offset="1" stopColor="#333" />
</linearGradient>
<linearGradient
id="h"
x1="3.238"
x2="6.015"
y1="7.309"
y2="7.309"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#333" />
<stop offset=".55" stopColor="#666" />
<stop offset="1" stopColor="#333" />
</linearGradient>
<linearGradient
id="j"
x1="7.939"
x2="10.716"
y1="7.306"
y2="7.306"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#333" />
<stop offset=".55" stopColor="#666" />
<stop offset="1" stopColor="#333" />
</linearGradient>
<radialGradient
id="g"
cx="0"
cy="0"
r="1"
gradientTransform="matrix(1.27771 0 0 1.2777 4.627 7.309)"
gradientUnits="userSpaceOnUse"
>
<stop offset=".48" stopColor="#fff" />
<stop offset=".77" stopColor="#FDFDFD" />
<stop offset=".88" stopColor="#F6F6F6" />
<stop offset=".96" stopColor="#EBEBEB" />
<stop offset="1" stopColor="#E0E0E0" />
</radialGradient>
<radialGradient
id="i"
cx="0"
cy="0"
r="1"
gradientTransform="matrix(1.27771 0 0 1.2777 9.328 7.306)"
gradientUnits="userSpaceOnUse"
>
<stop offset=".48" stopColor="#fff" />
<stop offset=".77" stopColor="#FDFDFD" />
<stop offset=".88" stopColor="#F6F6F6" />
<stop offset=".96" stopColor="#EBEBEB" />
<stop offset="1" stopColor="#E0E0E0" />
</radialGradient>
</defs>
</svg>
);
}
export default ErrorIcon;

View File

@ -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);
}
}
}
}

View File

@ -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(<ErrorModal error={mockError} open onClose={jest.fn()} />);
// 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(<ErrorModal error={mockError} open={false} onClose={jest.fn()} />);
// 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(<ErrorModal error={mockError} open onClose={onCloseMock} />);
// 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(<ErrorModal error={mockError} open onClose={jest.fn()} />);
// 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(<ErrorModal error={mockError} open onClose={jest.fn()} />);
// 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(<ErrorModal error={mockError} open onClose={jest.fn()} />);
// 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(<ErrorModal error={mockError} open onClose={jest.fn()} />);
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(<ErrorModal error={longError} open onClose={jest.fn()} />);
// Check if the scroll hint is displayed
expect(screen.getByText('Scroll for more')).toBeInTheDocument();
});
});
it('should render the trigger component if provided', () => {
const mockTrigger = <button type="button">Open Error Modal</button>;
render(
<ErrorModal
error={mockError}
triggerComponent={mockTrigger}
onClose={jest.fn()}
/>,
);
// 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 = <button type="button">Open Error Modal</button>;
render(
<ErrorModal
error={mockError}
triggerComponent={mockTrigger}
onClose={jest.fn()}
/>,
);
// 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(<ErrorModal error={mockError} onClose={jest.fn()} />);
// 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(<ErrorModal error={mockError} onClose={onCloseMock} />);
// 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');
});
});

View File

@ -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 ? (
<Tag
className="error-modal__trigger"
icon={<CircleAlert size={14} color={Color.BG_CHERRY_500} />}
color="error"
onClick={(): void => setVisible(true)}
>
error
</Tag>
) : (
React.cloneElement(triggerComponent, {
onClick: () => setVisible(true),
})
)}
<Modal
open={visible}
footer={<div className="error-modal__footer" />}
title={
<>
{versionDataPayload ? (
<KeyValueLabel
badgeKey={versionDataPayload.ee === 'Y' ? 'ENTERPRISE' : 'COMMUNITY'}
badgeValue={versionDataPayload.version}
/>
) : (
<div className="error-modal__version-placeholder" />
)}
<Button
type="default"
className="close-button"
onClick={handleClose}
data-testid="close-button"
>
<X size={16} color={Color.BG_VANILLA_400} />
</Button>
</>
}
onCancel={handleClose}
closeIcon={false}
classNames={classNames}
wrapClassName="error-modal__wrap"
>
<ErrorContent error={error} />
</Modal>
</>
);
}
ErrorModal.defaultProps = {
onClose: undefined,
triggerComponent: null,
open: false,
};
export default ErrorModal;

View File

@ -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);
}
}
}

View File

@ -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 (
<section className="error-content">
{/* Summary Header */}
<section className="error-content__summary-section">
<header className="error-content__summary">
<div className="error-content__summary-left">
<div className="error-content__icon-wrapper">
<ErrorIcon />
</div>
<div className="error-content__summary-text">
<h2 className="error-content__error-code">{errorCode}</h2>
<p className="error-content__error-message">{errorMessage}</p>
</div>
</div>
{errorUrl && (
<div className="error-content__summary-right">
<Button
type="default"
className="error-content__docs-button"
href={errorUrl}
target="_blank"
data-testid="error-docs-button"
>
<BookOpenText size={14} />
Open Docs
</Button>
</div>
)}
</header>
{errorMessages?.length > 0 && (
<div className="error-content__message-badge">
<KeyValueLabel
badgeKey={
<div className="error-content__message-badge-label">
<div className="error-content__message-badge-label-dot" />
<div className="error-content__message-badge-label-text">MESSAGES</div>
</div>
}
badgeValue={errorMessages.length.toString()}
/>
<div className="error-content__message-badge-line" />
</div>
)}
</section>
{/* Detailed Messages */}
<section className="error-content__messages-section">
<div className="error-content__message-list-container">
<OverlayScrollbar>
<ul className="error-content__message-list">
{errorMessages?.map((error) => (
<li className="error-content__message-item" key={error.message}>
{error.message}
</li>
))}
</ul>
</OverlayScrollbar>
{errorMessages?.length > 10 && (
<div className="error-content__scroll-hint">
<ChevronsDown
size={16}
color={Color.BG_VANILLA_100}
className="error-content__scroll-hint-icon"
/>
<span className="error-content__scroll-hint-text">Scroll for more</span>
</div>
)}
</div>
</section>
</section>
);
}
export default ErrorContent;

View File

@ -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(() => <div>Mocked MarkdownRenderer</div>),
@ -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', () => {

View File

@ -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,

View File

@ -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 (
<div className="key-value-label">
<div className="key-value-label__key">
<TrimmedText text={badgeKey} maxCharacters={maxCharacters} />
{typeof badgeKey === 'string' ? (
<TrimmedText text={badgeKey} maxCharacters={maxCharacters} />
) : (
badgeKey
)}
</div>
{isUrl ? (
<a

View File

@ -1,6 +1,7 @@
import getLocalStorageApi from 'api/browser/localstorage/get';
import getAllOrgPreferences from 'api/preferences/getAllOrgPreferences';
import { Logout } from 'api/utils';
import getUserVersion from 'api/v1/version/getVersion';
import { LOCALSTORAGE } from 'constants/localStorage';
import dayjs from 'dayjs';
import useActiveLicenseV3 from 'hooks/useActiveLicenseV3/useActiveLicenseV3';
@ -151,6 +152,12 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
enabled: !!isLoggedIn && !!user.email && user.role === USER_ROLES.ADMIN,
});
const { data: versionData } = useQuery({
queryFn: getUserVersion,
queryKey: ['getUserVersion', user?.accessJwt],
enabled: isLoggedIn,
});
useEffect(() => {
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 <AppContext.Provider value={value}>{children}</AppContext.Provider>;

View File

@ -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

View File

@ -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<ErrorModalContextType | undefined>(
undefined,
);
export function ErrorModalProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const [error, setError] = useState<APIError | null>(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 (
<ErrorModalContext.Provider value={value}>
{children}
{isVisible && error && (
<ErrorModal error={error} onClose={hideErrorModal} open={isVisible} />
)}
</ErrorModalContext.Provider>
);
}
export const useErrorModal = (): ErrorModalContextType => {
const context = useContext(ErrorModalContext);
if (!context) {
throw new Error('useErrorModal must be used within an ErrorModalProvider');
}
return context;
};

View File

@ -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 (
<QueryClientProvider client={queryClient}>
<ResourceProvider>
<Provider store={mockStored(role)}>
<AppContext.Provider value={getAppContextMock(role, appContextOverrides)}>
<BrowserRouter>
{/* Use the mock store with the provided role */}
<TimezoneProvider>
<QueryBuilderProvider>{children}</QueryBuilderProvider>
</TimezoneProvider>
</BrowserRouter>
</AppContext.Provider>
</Provider>
<ErrorModalProvider>
<Provider store={mockStored(role)}>
<AppContext.Provider value={getAppContextMock(role, appContextOverrides)}>
<BrowserRouter>
{/* Use the mock store with the provided role */}
<TimezoneProvider>
<QueryBuilderProvider>{children}</QueryBuilderProvider>
</TimezoneProvider>
</BrowserRouter>
</AppContext.Provider>
</Provider>
</ErrorModalProvider>
</ResourceProvider>
</QueryClientProvider>
);