mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-08-14 23:55:59 +08:00
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:
parent
3a396602a8
commit
4d484b225f
@ -23,6 +23,7 @@ import AlertRuleProvider from 'providers/Alert';
|
|||||||
import { useAppContext } from 'providers/App/App';
|
import { useAppContext } from 'providers/App/App';
|
||||||
import { IUser } from 'providers/App/types';
|
import { IUser } from 'providers/App/types';
|
||||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||||
|
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||||
import { Route, Router, Switch } from 'react-router-dom';
|
import { Route, Router, Switch } from 'react-router-dom';
|
||||||
@ -358,6 +359,7 @@ function App(): JSX.Element {
|
|||||||
<CompatRouter>
|
<CompatRouter>
|
||||||
<UserpilotRouteTracker />
|
<UserpilotRouteTracker />
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
|
<ErrorModalProvider>
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<ResourceProvider>
|
<ResourceProvider>
|
||||||
<QueryBuilderProvider>
|
<QueryBuilderProvider>
|
||||||
@ -386,6 +388,7 @@ function App(): JSX.Element {
|
|||||||
</QueryBuilderProvider>
|
</QueryBuilderProvider>
|
||||||
</ResourceProvider>
|
</ResourceProvider>
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
|
</ErrorModalProvider>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</CompatRouter>
|
</CompatRouter>
|
||||||
</Router>
|
</Router>
|
||||||
|
191
frontend/src/assets/Error.tsx
Normal file
191
frontend/src/assets/Error.tsx
Normal 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;
|
118
frontend/src/components/ErrorModal/ErrorModal.styles.scss
Normal file
118
frontend/src/components/ErrorModal/ErrorModal.styles.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
195
frontend/src/components/ErrorModal/ErrorModal.test.tsx
Normal file
195
frontend/src/components/ErrorModal/ErrorModal.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
102
frontend/src/components/ErrorModal/ErrorModal.tsx
Normal file
102
frontend/src/components/ErrorModal/ErrorModal.tsx
Normal 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;
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
@ -15,7 +15,7 @@ import {
|
|||||||
} from 'mocks-server/__mockdata__/alerts';
|
} from 'mocks-server/__mockdata__/alerts';
|
||||||
import { server } from 'mocks-server/server';
|
import { server } from 'mocks-server/server';
|
||||||
import { rest } from 'msw';
|
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';
|
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', () => ({
|
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
|
||||||
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
|
MarkdownRenderer: jest.fn(() => <div>Mocked MarkdownRenderer</div>),
|
||||||
@ -119,7 +127,7 @@ describe('Create Alert Channel', () => {
|
|||||||
|
|
||||||
fireEvent.click(saveButton);
|
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 () => {
|
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(
|
server.use(
|
||||||
@ -151,9 +159,11 @@ describe('Create Alert Channel', () => {
|
|||||||
name: 'button_test_channel',
|
name: 'button_test_channel',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
fireEvent.click(testButton);
|
fireEvent.click(testButton);
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(errorNotification).toHaveBeenCalled());
|
await waitFor(() => expect(showErrorModal).toHaveBeenCalled());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('New Alert Channel Cascading Fields Based on Channel Type', () => {
|
describe('New Alert Channel Cascading Fields Based on Channel Type', () => {
|
||||||
|
@ -16,6 +16,7 @@ import ROUTES from 'constants/routes';
|
|||||||
import FormAlertChannels from 'container/FormAlertChannels';
|
import FormAlertChannels from 'container/FormAlertChannels';
|
||||||
import { useNotifications } from 'hooks/useNotifications';
|
import { useNotifications } from 'hooks/useNotifications';
|
||||||
import history from 'lib/history';
|
import history from 'lib/history';
|
||||||
|
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import APIError from 'types/api/error';
|
import APIError from 'types/api/error';
|
||||||
@ -42,6 +43,7 @@ function CreateAlertChannels({
|
|||||||
}: CreateAlertChannelsProps): JSX.Element {
|
}: CreateAlertChannelsProps): JSX.Element {
|
||||||
// init namespace for translations
|
// init namespace for translations
|
||||||
const { t } = useTranslation('channels');
|
const { t } = useTranslation('channels');
|
||||||
|
const { showErrorModal } = useErrorModal();
|
||||||
|
|
||||||
const [formInstance] = Form.useForm();
|
const [formInstance] = Form.useForm();
|
||||||
|
|
||||||
@ -145,15 +147,12 @@ function CreateAlertChannels({
|
|||||||
history.replace(ROUTES.ALL_CHANNELS);
|
history.replace(ROUTES.ALL_CHANNELS);
|
||||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error({
|
showErrorModal(error as APIError);
|
||||||
message: (error as APIError).error.error.code,
|
|
||||||
description: (error as APIError).error.error.message,
|
|
||||||
});
|
|
||||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||||
} finally {
|
} finally {
|
||||||
setSavingState(false);
|
setSavingState(false);
|
||||||
}
|
}
|
||||||
}, [prepareSlackRequest, t, notifications]);
|
}, [prepareSlackRequest, notifications, t, showErrorModal]);
|
||||||
|
|
||||||
const prepareWebhookRequest = useCallback(() => {
|
const prepareWebhookRequest = useCallback(() => {
|
||||||
// initial api request without auth params
|
// initial api request without auth params
|
||||||
@ -202,15 +201,12 @@ function CreateAlertChannels({
|
|||||||
history.replace(ROUTES.ALL_CHANNELS);
|
history.replace(ROUTES.ALL_CHANNELS);
|
||||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error({
|
showErrorModal(error as APIError);
|
||||||
message: (error as APIError).getErrorCode(),
|
|
||||||
description: (error as APIError).getErrorMessage(),
|
|
||||||
});
|
|
||||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||||
} finally {
|
} finally {
|
||||||
setSavingState(false);
|
setSavingState(false);
|
||||||
}
|
}
|
||||||
}, [prepareWebhookRequest, t, notifications]);
|
}, [prepareWebhookRequest, notifications, t, showErrorModal]);
|
||||||
|
|
||||||
const preparePagerRequest = useCallback(() => {
|
const preparePagerRequest = useCallback(() => {
|
||||||
const validationError = ValidatePagerChannel(selectedConfig as PagerChannel);
|
const validationError = ValidatePagerChannel(selectedConfig as PagerChannel);
|
||||||
@ -254,15 +250,12 @@ function CreateAlertChannels({
|
|||||||
}
|
}
|
||||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error({
|
showErrorModal(error as APIError);
|
||||||
message: (error as APIError).getErrorCode(),
|
|
||||||
description: (error as APIError).getErrorMessage(),
|
|
||||||
});
|
|
||||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||||
} finally {
|
} finally {
|
||||||
setSavingState(false);
|
setSavingState(false);
|
||||||
}
|
}
|
||||||
}, [t, notifications, preparePagerRequest]);
|
}, [preparePagerRequest, t, notifications, showErrorModal]);
|
||||||
|
|
||||||
const prepareOpsgenieRequest = useCallback(
|
const prepareOpsgenieRequest = useCallback(
|
||||||
() => ({
|
() => ({
|
||||||
@ -287,15 +280,12 @@ function CreateAlertChannels({
|
|||||||
history.replace(ROUTES.ALL_CHANNELS);
|
history.replace(ROUTES.ALL_CHANNELS);
|
||||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error({
|
showErrorModal(error as APIError);
|
||||||
message: (error as APIError).getErrorCode(),
|
|
||||||
description: (error as APIError).getErrorMessage(),
|
|
||||||
});
|
|
||||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||||
} finally {
|
} finally {
|
||||||
setSavingState(false);
|
setSavingState(false);
|
||||||
}
|
}
|
||||||
}, [prepareOpsgenieRequest, t, notifications]);
|
}, [prepareOpsgenieRequest, notifications, t, showErrorModal]);
|
||||||
|
|
||||||
const prepareEmailRequest = useCallback(
|
const prepareEmailRequest = useCallback(
|
||||||
() => ({
|
() => ({
|
||||||
@ -320,15 +310,12 @@ function CreateAlertChannels({
|
|||||||
history.replace(ROUTES.ALL_CHANNELS);
|
history.replace(ROUTES.ALL_CHANNELS);
|
||||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error({
|
showErrorModal(error as APIError);
|
||||||
message: (error as APIError).getErrorCode(),
|
|
||||||
description: (error as APIError).getErrorMessage(),
|
|
||||||
});
|
|
||||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||||
} finally {
|
} finally {
|
||||||
setSavingState(false);
|
setSavingState(false);
|
||||||
}
|
}
|
||||||
}, [prepareEmailRequest, t, notifications]);
|
}, [prepareEmailRequest, notifications, t, showErrorModal]);
|
||||||
|
|
||||||
const prepareMsTeamsRequest = useCallback(
|
const prepareMsTeamsRequest = useCallback(
|
||||||
() => ({
|
() => ({
|
||||||
@ -353,15 +340,12 @@ function CreateAlertChannels({
|
|||||||
history.replace(ROUTES.ALL_CHANNELS);
|
history.replace(ROUTES.ALL_CHANNELS);
|
||||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error({
|
showErrorModal(error as APIError);
|
||||||
message: (error as APIError).getErrorCode(),
|
|
||||||
description: (error as APIError).getErrorMessage(),
|
|
||||||
});
|
|
||||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||||
} finally {
|
} finally {
|
||||||
setSavingState(false);
|
setSavingState(false);
|
||||||
}
|
}
|
||||||
}, [prepareMsTeamsRequest, t, notifications]);
|
}, [prepareMsTeamsRequest, notifications, t, showErrorModal]);
|
||||||
|
|
||||||
const onSaveHandler = useCallback(
|
const onSaveHandler = useCallback(
|
||||||
async (value: ChannelType) => {
|
async (value: ChannelType) => {
|
||||||
@ -459,10 +443,8 @@ function CreateAlertChannels({
|
|||||||
status: 'Test success',
|
status: 'Test success',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error({
|
showErrorModal(error as APIError);
|
||||||
message: (error as APIError).error.error.code,
|
|
||||||
description: (error as APIError).error.error.message,
|
|
||||||
});
|
|
||||||
logEvent('Alert Channel: Test notification', {
|
logEvent('Alert Channel: Test notification', {
|
||||||
type: channelType,
|
type: channelType,
|
||||||
sendResolvedAlert: selectedConfig?.send_resolved,
|
sendResolvedAlert: selectedConfig?.send_resolved,
|
||||||
|
@ -6,7 +6,7 @@ import { useMemo } from 'react';
|
|||||||
import TrimmedText from '../TrimmedText/TrimmedText';
|
import TrimmedText from '../TrimmedText/TrimmedText';
|
||||||
|
|
||||||
type KeyValueLabelProps = {
|
type KeyValueLabelProps = {
|
||||||
badgeKey: string;
|
badgeKey: string | React.ReactNode;
|
||||||
badgeValue: string;
|
badgeValue: string;
|
||||||
maxCharacters?: number;
|
maxCharacters?: number;
|
||||||
};
|
};
|
||||||
@ -25,7 +25,11 @@ export default function KeyValueLabel({
|
|||||||
return (
|
return (
|
||||||
<div className="key-value-label">
|
<div className="key-value-label">
|
||||||
<div className="key-value-label__key">
|
<div className="key-value-label__key">
|
||||||
|
{typeof badgeKey === 'string' ? (
|
||||||
<TrimmedText text={badgeKey} maxCharacters={maxCharacters} />
|
<TrimmedText text={badgeKey} maxCharacters={maxCharacters} />
|
||||||
|
) : (
|
||||||
|
badgeKey
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isUrl ? (
|
{isUrl ? (
|
||||||
<a
|
<a
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||||
import getAllOrgPreferences from 'api/preferences/getAllOrgPreferences';
|
import getAllOrgPreferences from 'api/preferences/getAllOrgPreferences';
|
||||||
import { Logout } from 'api/utils';
|
import { Logout } from 'api/utils';
|
||||||
|
import getUserVersion from 'api/v1/version/getVersion';
|
||||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import useActiveLicenseV3 from 'hooks/useActiveLicenseV3/useActiveLicenseV3';
|
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,
|
enabled: !!isLoggedIn && !!user.email && user.role === USER_ROLES.ADMIN,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: versionData } = useQuery({
|
||||||
|
queryFn: getUserVersion,
|
||||||
|
queryKey: ['getUserVersion', user?.accessJwt],
|
||||||
|
enabled: isLoggedIn,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!isFetchingOrgPreferences &&
|
!isFetchingOrgPreferences &&
|
||||||
@ -246,6 +253,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
|||||||
updateUser,
|
updateUser,
|
||||||
updateOrgPreferences,
|
updateOrgPreferences,
|
||||||
updateOrg,
|
updateOrg,
|
||||||
|
versionData: versionData?.payload || null,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
trialInfo,
|
trialInfo,
|
||||||
@ -265,6 +273,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
|||||||
updateOrg,
|
updateOrg,
|
||||||
user,
|
user,
|
||||||
userFetchError,
|
userFetchError,
|
||||||
|
versionData,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||||
|
@ -3,6 +3,7 @@ import { FeatureFlagProps as FeatureFlags } from 'types/api/features/getFeatures
|
|||||||
import { LicenseResModel, TrialInfo } from 'types/api/licensesV3/getActive';
|
import { LicenseResModel, TrialInfo } from 'types/api/licensesV3/getActive';
|
||||||
import { Organization } from 'types/api/user/getOrganization';
|
import { Organization } from 'types/api/user/getOrganization';
|
||||||
import { UserResponse as User } from 'types/api/user/getUser';
|
import { UserResponse as User } from 'types/api/user/getUser';
|
||||||
|
import { PayloadProps } from 'types/api/user/getVersion';
|
||||||
import { OrgPreference } from 'types/reducer/app';
|
import { OrgPreference } from 'types/reducer/app';
|
||||||
|
|
||||||
export interface IAppContext {
|
export interface IAppContext {
|
||||||
@ -25,6 +26,7 @@ export interface IAppContext {
|
|||||||
updateUser: (user: IUser) => void;
|
updateUser: (user: IUser) => void;
|
||||||
updateOrgPreferences: (orgPreferences: OrgPreference[]) => void;
|
updateOrgPreferences: (orgPreferences: OrgPreference[]) => void;
|
||||||
updateOrg(orgId: string, updatedOrgName: string): void;
|
updateOrg(orgId: string, updatedOrgName: string): void;
|
||||||
|
versionData: PayloadProps | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// User
|
// User
|
||||||
|
60
frontend/src/providers/ErrorModalProvider.tsx
Normal file
60
frontend/src/providers/ErrorModalProvider.tsx
Normal 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;
|
||||||
|
};
|
@ -5,6 +5,7 @@ import ROUTES from 'constants/routes';
|
|||||||
import { ResourceProvider } from 'hooks/useResourceAttribute';
|
import { ResourceProvider } from 'hooks/useResourceAttribute';
|
||||||
import { AppContext } from 'providers/App/App';
|
import { AppContext } from 'providers/App/App';
|
||||||
import { IAppContext } from 'providers/App/types';
|
import { IAppContext } from 'providers/App/types';
|
||||||
|
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||||
import TimezoneProvider from 'providers/Timezone';
|
import TimezoneProvider from 'providers/Timezone';
|
||||||
import React, { ReactElement } from 'react';
|
import React, { ReactElement } from 'react';
|
||||||
@ -234,6 +235,11 @@ export function getAppContextMock(
|
|||||||
updateOrg: jest.fn(),
|
updateOrg: jest.fn(),
|
||||||
updateOrgPreferences: jest.fn(),
|
updateOrgPreferences: jest.fn(),
|
||||||
activeLicenseRefetch: jest.fn(),
|
activeLicenseRefetch: jest.fn(),
|
||||||
|
versionData: {
|
||||||
|
version: '1.0.0',
|
||||||
|
ee: 'Y',
|
||||||
|
setupCompleted: true,
|
||||||
|
},
|
||||||
...appContextOverrides,
|
...appContextOverrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -249,6 +255,7 @@ function AllTheProviders({
|
|||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ResourceProvider>
|
<ResourceProvider>
|
||||||
|
<ErrorModalProvider>
|
||||||
<Provider store={mockStored(role)}>
|
<Provider store={mockStored(role)}>
|
||||||
<AppContext.Provider value={getAppContextMock(role, appContextOverrides)}>
|
<AppContext.Provider value={getAppContextMock(role, appContextOverrides)}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@ -259,6 +266,7 @@ function AllTheProviders({
|
|||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
</Provider>
|
</Provider>
|
||||||
|
</ErrorModalProvider>
|
||||||
</ResourceProvider>
|
</ResourceProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user