mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-17 15:36:48 +00:00
chore: add notification settings section to create alert (#9162)
This commit is contained in:
parent
2c59c1196d
commit
a54c3a3d7f
@ -8,6 +8,7 @@ import AlertCondition from './AlertCondition';
|
||||
import { CreateAlertProvider } from './context';
|
||||
import CreateAlertHeader from './CreateAlertHeader';
|
||||
import EvaluationSettings from './EvaluationSettings';
|
||||
import NotificationSettings from './NotificationSettings';
|
||||
import QuerySection from './QuerySection';
|
||||
import { showCondensedLayout } from './utils';
|
||||
|
||||
@ -27,6 +28,7 @@ function CreateAlertV2({
|
||||
<QuerySection />
|
||||
<AlertCondition />
|
||||
{!showCondensedLayoutFlag ? <EvaluationSettings /> : null}
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
</CreateAlertProvider>
|
||||
);
|
||||
|
||||
@ -49,3 +49,40 @@
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.time-input-container {
|
||||
.time-input-field {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--bg-ink-300);
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-300);
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.time-input-separator {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from 'container/CreateAlertV2/context/constants';
|
||||
import {
|
||||
EvaluationWindowState,
|
||||
@ -23,6 +24,8 @@ export const createMockAlertContextState = (
|
||||
setAdvancedOptions: jest.fn(),
|
||||
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
|
||||
setEvaluationWindow: jest.fn(),
|
||||
notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
setNotificationSettings: jest.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
import { Select, Tooltip, Typography } from 'antd';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
|
||||
function MultipleNotifications(): JSX.Element {
|
||||
const {
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
} = useCreateAlertState();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const spaceAggregationOptions = useMemo(() => {
|
||||
const allGroupBys = currentQuery.builder.queryData?.reduce<string[]>(
|
||||
(acc, query) => {
|
||||
const groupByKeys = query.groupBy?.map((groupBy) => groupBy.key) || [];
|
||||
return [...acc, ...groupByKeys];
|
||||
},
|
||||
[],
|
||||
);
|
||||
const uniqueGroupBys = [...new Set(allGroupBys)];
|
||||
return uniqueGroupBys.map((key) => ({
|
||||
label: key,
|
||||
value: key,
|
||||
}));
|
||||
}, [currentQuery.builder.queryData]);
|
||||
|
||||
const isMultipleNotificationsEnabled = spaceAggregationOptions.length > 0;
|
||||
|
||||
const multipleNotificationsInput = useMemo(() => {
|
||||
const placeholder = isMultipleNotificationsEnabled
|
||||
? 'Select fields to group by (optional)'
|
||||
: 'No grouping fields available';
|
||||
let input = (
|
||||
<div>
|
||||
<Select
|
||||
options={spaceAggregationOptions}
|
||||
onChange={(value): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_MULTIPLE_NOTIFICATIONS',
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
value={notificationSettings.multipleNotifications}
|
||||
mode="multiple"
|
||||
placeholder={placeholder}
|
||||
disabled={!isMultipleNotificationsEnabled}
|
||||
aria-disabled={!isMultipleNotificationsEnabled}
|
||||
maxTagCount={3}
|
||||
/>
|
||||
{isMultipleNotificationsEnabled && (
|
||||
<Typography.Paragraph className="multiple-notifications-select-description">
|
||||
{notificationSettings.multipleNotifications?.length
|
||||
? `Alerts with same ${notificationSettings.multipleNotifications?.join(
|
||||
', ',
|
||||
)} will be grouped`
|
||||
: 'Empty = all matching alerts combined into one notification'}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
if (!isMultipleNotificationsEnabled) {
|
||||
input = (
|
||||
<Tooltip title="Add 'Group by' fields to your query to enable alert grouping">
|
||||
{input}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return input;
|
||||
}, [
|
||||
isMultipleNotificationsEnabled,
|
||||
notificationSettings.multipleNotifications,
|
||||
setNotificationSettings,
|
||||
spaceAggregationOptions,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="multiple-notifications-container">
|
||||
<div className="multiple-notifications-header">
|
||||
<Typography.Text className="multiple-notifications-header-title">
|
||||
Group alerts by{' '}
|
||||
<Tooltip title="Group similar alerts together to reduce notification volume. Leave empty to combine all matching alerts into one notification without grouping.">
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text className="multiple-notifications-header-description">
|
||||
Combine alerts with the same field values into a single notification.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{multipleNotificationsInput}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MultipleNotifications;
|
||||
@ -0,0 +1,92 @@
|
||||
import { Button, Popover, Tooltip, Typography } from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
|
||||
function NotificationMessage(): JSX.Element {
|
||||
const {
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
} = useCreateAlertState();
|
||||
|
||||
const templateVariables = [
|
||||
{ variable: '{{alertname}}', description: 'Name of the alert rule' },
|
||||
{
|
||||
variable: '{{value}}',
|
||||
description: 'Current value that triggered the alert',
|
||||
},
|
||||
{
|
||||
variable: '{{threshold}}',
|
||||
description: 'Threshold value from alert condition',
|
||||
},
|
||||
{ variable: '{{unit}}', description: 'Unit of measurement for the metric' },
|
||||
{
|
||||
variable: '{{severity}}',
|
||||
description: 'Alert severity level (Critical, Warning, Info)',
|
||||
},
|
||||
{
|
||||
variable: '{{queryname}}',
|
||||
description: 'Name of the query that triggered the alert',
|
||||
},
|
||||
{
|
||||
variable: '{{labels}}',
|
||||
description: 'All labels associated with the alert',
|
||||
},
|
||||
{
|
||||
variable: '{{timestamp}}',
|
||||
description: 'Timestamp when alert was triggered',
|
||||
},
|
||||
];
|
||||
|
||||
const templateVariableContent = (
|
||||
<div className="template-variable-content">
|
||||
<Typography.Text strong>Available Template Variables:</Typography.Text>
|
||||
{templateVariables.map((item) => (
|
||||
<div className="template-variable-content-item" key={item.variable}>
|
||||
<code>{item.variable}</code>
|
||||
<Typography.Text>{item.description}</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="notification-message-container">
|
||||
<div className="notification-message-header">
|
||||
<div className="notification-message-header-content">
|
||||
<Typography.Text className="notification-message-header-title">
|
||||
Notification Message
|
||||
<Tooltip title="Customize the message content sent in alert notifications. Template variables like {{alertname}}, {{value}}, and {{threshold}} will be replaced with actual values when the alert fires.">
|
||||
<Info size={16} />
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text className="notification-message-header-description">
|
||||
Custom message content for alert notifications. Use template variables to
|
||||
include dynamic information.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="notification-message-header-actions">
|
||||
<Popover content={templateVariableContent}>
|
||||
<Button type="text">
|
||||
<Info size={12} />
|
||||
Variables
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<TextArea
|
||||
value={notificationSettings.description}
|
||||
onChange={(e): void =>
|
||||
setNotificationSettings({
|
||||
type: 'SET_DESCRIPTION',
|
||||
payload: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Enter notification message..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationMessage;
|
||||
@ -0,0 +1,112 @@
|
||||
import './styles.scss';
|
||||
|
||||
import { Input, Select, Typography } from 'antd';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
ADVANCED_OPTIONS_TIME_UNIT_OPTIONS as RE_NOTIFICATION_UNIT_OPTIONS,
|
||||
RE_NOTIFICATION_CONDITION_OPTIONS,
|
||||
} from '../context/constants';
|
||||
import AdvancedOptionItem from '../EvaluationSettings/AdvancedOptionItem';
|
||||
import Stepper from '../Stepper';
|
||||
import { showCondensedLayout } from '../utils';
|
||||
import MultipleNotifications from './MultipleNotifications';
|
||||
import NotificationMessage from './NotificationMessage';
|
||||
|
||||
function NotificationSettings(): JSX.Element {
|
||||
const showCondensedLayoutFlag = showCondensedLayout();
|
||||
|
||||
const {
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
} = useCreateAlertState();
|
||||
|
||||
const repeatNotificationsInput = (
|
||||
<div className="repeat-notifications-input">
|
||||
<Typography.Text>Every</Typography.Text>
|
||||
<Input
|
||||
value={notificationSettings.reNotification.value}
|
||||
placeholder="Enter time interval..."
|
||||
disabled={!notificationSettings.reNotification.enabled}
|
||||
type="number"
|
||||
onChange={(e): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
enabled: notificationSettings.reNotification.enabled,
|
||||
value: parseInt(e.target.value, 10),
|
||||
unit: notificationSettings.reNotification.unit,
|
||||
conditions: notificationSettings.reNotification.conditions,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={notificationSettings.reNotification.unit || null}
|
||||
placeholder="Select unit"
|
||||
disabled={!notificationSettings.reNotification.enabled}
|
||||
options={RE_NOTIFICATION_UNIT_OPTIONS}
|
||||
onChange={(value): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
enabled: notificationSettings.reNotification.enabled,
|
||||
value: notificationSettings.reNotification.value,
|
||||
unit: value,
|
||||
conditions: notificationSettings.reNotification.conditions,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Typography.Text>while</Typography.Text>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={notificationSettings.reNotification.conditions || null}
|
||||
placeholder="Select conditions"
|
||||
disabled={!notificationSettings.reNotification.enabled}
|
||||
options={RE_NOTIFICATION_CONDITION_OPTIONS}
|
||||
onChange={(value): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
enabled: notificationSettings.reNotification.enabled,
|
||||
value: notificationSettings.reNotification.value,
|
||||
unit: notificationSettings.reNotification.unit,
|
||||
conditions: value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="notification-settings-container">
|
||||
<Stepper
|
||||
stepNumber={showCondensedLayoutFlag ? 3 : 4}
|
||||
label="Notification settings"
|
||||
/>
|
||||
<NotificationMessage />
|
||||
<div className="notification-settings-content">
|
||||
<MultipleNotifications />
|
||||
<AdvancedOptionItem
|
||||
title="Repeat notifications"
|
||||
description="Send periodic notifications while the alert condition remains active."
|
||||
tooltipText="Continue sending periodic notifications while the alert condition persists. Useful for ensuring critical alerts aren't missed during long-running incidents. Configure how often to repeat and under what conditions."
|
||||
input={repeatNotificationsInput}
|
||||
onToggle={(): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
...notificationSettings.reNotification,
|
||||
enabled: !notificationSettings.reNotification.enabled,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationSettings;
|
||||
@ -0,0 +1,172 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as createAlertContext from 'container/CreateAlertV2/context';
|
||||
import {
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from 'container/CreateAlertV2/context/constants';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
|
||||
import MultipleNotifications from '../MultipleNotifications';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
|
||||
const TEST_QUERY = 'test-query';
|
||||
const TEST_GROUP_BY_FIELDS = [{ key: 'service' }, { key: 'environment' }];
|
||||
const TRUE = 'true';
|
||||
const FALSE = 'false';
|
||||
const COMBOBOX_ROLE = 'combobox';
|
||||
const ARIA_DISABLED_ATTR = 'aria-disabled';
|
||||
const mockSetNotificationSettings = jest.fn();
|
||||
const mockUseQueryBuilder = {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: TEST_QUERY,
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const initialAlertThresholdState = createMockAlertContextState().thresholdState;
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
thresholdState: {
|
||||
...initialAlertThresholdState,
|
||||
selectedQuery: TEST_QUERY,
|
||||
},
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
describe('MultipleNotifications', () => {
|
||||
const { useQueryBuilder } = jest.requireMock(
|
||||
'hooks/queryBuilder/useQueryBuilder',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useQueryBuilder.mockReturnValue(mockUseQueryBuilder);
|
||||
});
|
||||
|
||||
it('should render the multiple notifications component with no grouping fields and disabled input by default', () => {
|
||||
render(<MultipleNotifications />);
|
||||
expect(screen.getByText('Group alerts by')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Combine alerts with the same field values into a single notification.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('No grouping fields available')).toBeInTheDocument();
|
||||
const select = screen.getByRole(COMBOBOX_ROLE);
|
||||
expect(select).toHaveAttribute(ARIA_DISABLED_ATTR, TRUE);
|
||||
});
|
||||
|
||||
it('should render the multiple notifications component with grouping fields and enabled input when space aggregation options are set', () => {
|
||||
useQueryBuilder.mockReturnValue({
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: TEST_QUERY,
|
||||
groupBy: TEST_GROUP_BY_FIELDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
render(<MultipleNotifications />);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Empty = all matching alerts combined into one notification',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
const select = screen.getByRole(COMBOBOX_ROLE);
|
||||
expect(select).toHaveAttribute(ARIA_DISABLED_ATTR, FALSE);
|
||||
});
|
||||
|
||||
it('should render the multiple notifications component with grouping fields and enabled input when space aggregation options are set and multiple notifications are enabled', () => {
|
||||
useQueryBuilder.mockReturnValue({
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: TEST_QUERY,
|
||||
groupBy: TEST_GROUP_BY_FIELDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
thresholdState: {
|
||||
...INITIAL_ALERT_THRESHOLD_STATE,
|
||||
selectedQuery: TEST_QUERY,
|
||||
},
|
||||
notificationSettings: {
|
||||
...INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
multipleNotifications: ['service', 'environment'],
|
||||
},
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MultipleNotifications />);
|
||||
|
||||
expect(
|
||||
screen.getByText('Alerts with same service, environment will be grouped'),
|
||||
).toBeInTheDocument();
|
||||
const select = screen.getByRole(COMBOBOX_ROLE);
|
||||
expect(select).toHaveAttribute(ARIA_DISABLED_ATTR, FALSE);
|
||||
});
|
||||
|
||||
it('should render unique group by options from all queries', async () => {
|
||||
useQueryBuilder.mockReturnValue({
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'test-query-1',
|
||||
groupBy: [{ key: 'http.status_code' }],
|
||||
},
|
||||
{
|
||||
queryName: 'test-query-2',
|
||||
groupBy: [{ key: 'service' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<MultipleNotifications />);
|
||||
|
||||
const select = screen.getByRole(COMBOBOX_ROLE);
|
||||
await userEvent.click(select);
|
||||
|
||||
expect(
|
||||
screen.getByRole('option', { name: 'http.status_code' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'service' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,75 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as createAlertContext from 'container/CreateAlertV2/context';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
|
||||
import NotificationMessage from '../NotificationMessage';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
const mockSetNotificationSettings = jest.fn();
|
||||
const initialNotificationSettingsState = createMockAlertContextState()
|
||||
.notificationSettings;
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
notificationSettings: {
|
||||
...initialNotificationSettingsState,
|
||||
description: '',
|
||||
},
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
describe('NotificationMessage', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders textarea with message and placeholder', () => {
|
||||
render(<NotificationMessage />);
|
||||
expect(screen.getByText('Notification Message')).toBeInTheDocument();
|
||||
const textarea = screen.getByPlaceholderText('Enter notification message...');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates notification settings when textarea value changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NotificationMessage />);
|
||||
const textarea = screen.getByPlaceholderText('Enter notification message...');
|
||||
await user.type(textarea, 'x');
|
||||
expect(mockSetNotificationSettings).toHaveBeenLastCalledWith({
|
||||
type: 'SET_DESCRIPTION',
|
||||
payload: 'x',
|
||||
});
|
||||
});
|
||||
|
||||
it('displays existing description value', () => {
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
notificationSettings: {
|
||||
description: 'Existing message',
|
||||
},
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
} as any),
|
||||
);
|
||||
|
||||
render(<NotificationMessage />);
|
||||
|
||||
const textarea = screen.getByDisplayValue('Existing message');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,120 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import * as createAlertContext from 'container/CreateAlertV2/context';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
import * as utils from 'container/CreateAlertV2/utils';
|
||||
|
||||
import NotificationSettings from '../NotificationSettings';
|
||||
|
||||
jest.mock(
|
||||
'container/CreateAlertV2/NotificationSettings/MultipleNotifications',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="multiple-notifications">MultipleNotifications</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'container/CreateAlertV2/NotificationSettings/NotificationMessage',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="notification-message">NotificationMessage</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const initialNotificationSettings = createMockAlertContextState()
|
||||
.notificationSettings;
|
||||
const mockSetNotificationSettings = jest.fn();
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
}),
|
||||
);
|
||||
|
||||
const REPEAT_NOTIFICATIONS_TEXT = 'Repeat notifications';
|
||||
const ENTER_TIME_INTERVAL_TEXT = 'Enter time interval...';
|
||||
|
||||
describe('NotificationSettings', () => {
|
||||
it('renders the notification settings tab with step number 4 and default values', () => {
|
||||
render(<NotificationSettings />);
|
||||
expect(screen.getByText('Notification settings')).toBeInTheDocument();
|
||||
expect(screen.getByText('4')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('multiple-notifications')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('notification-message')).toBeInTheDocument();
|
||||
expect(screen.getByText(REPEAT_NOTIFICATIONS_TEXT)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Send periodic notifications while the alert condition remains active.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the notification settings tab with step number 3 in condensed layout', () => {
|
||||
jest.spyOn(utils, 'showCondensedLayout').mockReturnValueOnce(true);
|
||||
render(<NotificationSettings />);
|
||||
expect(screen.getByText('Notification settings')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('multiple-notifications')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('notification-message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Repeat notifications', () => {
|
||||
it('renders the repeat notifications with inputs hidden when the repeat notifications switch is off', () => {
|
||||
render(<NotificationSettings />);
|
||||
expect(screen.getByText(REPEAT_NOTIFICATIONS_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText('Every')).not.toBeVisible();
|
||||
expect(
|
||||
screen.getByPlaceholderText(ENTER_TIME_INTERVAL_TEXT),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('toggles the repeat notifications switch and shows the inputs', () => {
|
||||
render(<NotificationSettings />);
|
||||
expect(screen.getByText(REPEAT_NOTIFICATIONS_TEXT)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Every')).not.toBeVisible();
|
||||
expect(
|
||||
screen.getByPlaceholderText(ENTER_TIME_INTERVAL_TEXT),
|
||||
).not.toBeVisible();
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'));
|
||||
|
||||
expect(screen.getByText('Every')).toBeVisible();
|
||||
expect(screen.getByPlaceholderText(ENTER_TIME_INTERVAL_TEXT)).toBeVisible();
|
||||
});
|
||||
|
||||
it('updates state when the repeat notifications input is changed', () => {
|
||||
jest.spyOn(createAlertContext, 'useCreateAlertState').mockReturnValue(
|
||||
createMockAlertContextState({
|
||||
setNotificationSettings: mockSetNotificationSettings,
|
||||
notificationSettings: {
|
||||
...initialNotificationSettings,
|
||||
reNotification: {
|
||||
...initialNotificationSettings.reNotification,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
render(<NotificationSettings />);
|
||||
expect(screen.getByText(REPEAT_NOTIFICATIONS_TEXT)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(ENTER_TIME_INTERVAL_TEXT), {
|
||||
target: { value: '13' },
|
||||
});
|
||||
|
||||
expect(mockSetNotificationSettings).toHaveBeenLastCalledWith({
|
||||
type: 'SET_RE_NOTIFICATION',
|
||||
payload: {
|
||||
enabled: true,
|
||||
value: 13,
|
||||
unit: 'min',
|
||||
conditions: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,3 @@
|
||||
import NotificationSettings from './NotificationSettings';
|
||||
|
||||
export default NotificationSettings;
|
||||
@ -0,0 +1,346 @@
|
||||
.notification-settings-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 16px;
|
||||
|
||||
.notification-message-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: -8px;
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
padding: 16px;
|
||||
|
||||
.notification-message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.notification-message-header-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.notification-message-header-title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: var(--bg-vanilla-300);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notification-message-header-description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-message-header-actions {
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
color: var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: 150px;
|
||||
background: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-200);
|
||||
border-radius: 4px;
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
|
||||
.repeat-notifications-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.ant-input {
|
||||
width: 120px;
|
||||
border: 1px solid var(--bg-slate-100);
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-multiple {
|
||||
.ant-select-selector {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.multiple-notifications-container {
|
||||
display: flex;
|
||||
padding: 4px 16px 16px 16px;
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
justify-content: space-between;
|
||||
|
||||
.multiple-notifications-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.ant-typography {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.multiple-notifications-header-title {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.multiple-notifications-header-description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.multiple-notifications-select-description {
|
||||
font-size: 10px;
|
||||
color: var(--bg-vanilla-400);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.re-notification-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
|
||||
.advanced-option-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
.advanced-option-item-left-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.advanced-option-item-title {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.advanced-option-item-description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.border-bottom {
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
width: 100%;
|
||||
margin-left: -16px;
|
||||
margin-right: -32px;
|
||||
}
|
||||
|
||||
.re-notification-condition {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
.ant-typography {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--bg-vanilla-400);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
width: 200px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.template-variable-content {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.template-variable-content-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
code {
|
||||
background-color: var(--bg-slate-500);
|
||||
color: var(--bg-vanilla-400);
|
||||
padding: 2px 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.notification-settings-container {
|
||||
.notification-message-container {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.notification-message-header {
|
||||
.notification-message-header-content {
|
||||
.notification-message-header-title {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.notification-message-header-description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.notification-message-header-actions {
|
||||
.ant-btn {
|
||||
color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
background: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-settings-content {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.repeat-notifications-input {
|
||||
.ant-input {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.multiple-notifications-container {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.multiple-notifications-header {
|
||||
.multiple-notifications-header-title {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.multiple-notifications-header-description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.multiple-notifications-select-description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.border-bottom {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.re-notification-container {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.advanced-option-item {
|
||||
.advanced-option-item-left-content {
|
||||
.advanced-option-item-title {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.advanced-option-item-description {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.border-bottom {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.re-notification-condition {
|
||||
.ant-typography {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.template-variable-content-item {
|
||||
code {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@ import {
|
||||
AlertThresholdState,
|
||||
Algorithm,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsState,
|
||||
Seasonality,
|
||||
Threshold,
|
||||
TimeDuration,
|
||||
@ -170,3 +171,22 @@ export const ADVANCED_OPTIONS_TIME_UNIT_OPTIONS = [
|
||||
{ value: UniversalYAxisUnit.HOURS, label: 'Hours' },
|
||||
{ value: UniversalYAxisUnit.DAYS, label: 'Days' },
|
||||
];
|
||||
|
||||
export const NOTIFICATION_MESSAGE_PLACEHOLDER =
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})';
|
||||
|
||||
export const RE_NOTIFICATION_CONDITION_OPTIONS = [
|
||||
{ value: 'firing', label: 'Firing' },
|
||||
{ value: 'no-data', label: 'No Data' },
|
||||
];
|
||||
|
||||
export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = {
|
||||
multipleNotifications: [],
|
||||
reNotification: {
|
||||
enabled: false,
|
||||
value: 1,
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
conditions: [],
|
||||
},
|
||||
description: NOTIFICATION_MESSAGE_PLACEHOLDER,
|
||||
};
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from './constants';
|
||||
import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types';
|
||||
import {
|
||||
@ -27,6 +28,7 @@ import {
|
||||
buildInitialAlertDef,
|
||||
evaluationWindowReducer,
|
||||
getInitialAlertTypeFromURL,
|
||||
notificationSettingsReducer,
|
||||
} from './utils';
|
||||
|
||||
const CreateAlertContext = createContext<ICreateAlertContextProps | null>(null);
|
||||
@ -94,6 +96,11 @@ export function CreateAlertProvider(
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
);
|
||||
|
||||
const [notificationSettings, setNotificationSettings] = useReducer(
|
||||
notificationSettingsReducer,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setThresholdState({
|
||||
type: 'RESET',
|
||||
@ -112,6 +119,8 @@ export function CreateAlertProvider(
|
||||
setEvaluationWindow,
|
||||
advancedOptions,
|
||||
setAdvancedOptions,
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
}),
|
||||
[
|
||||
alertState,
|
||||
@ -120,6 +129,7 @@ export function CreateAlertProvider(
|
||||
thresholdState,
|
||||
evaluationWindow,
|
||||
advancedOptions,
|
||||
notificationSettings,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -14,6 +14,8 @@ export interface ICreateAlertContextProps {
|
||||
setAdvancedOptions: Dispatch<AdvancedOptionsAction>;
|
||||
evaluationWindow: EvaluationWindowState;
|
||||
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
|
||||
notificationSettings: NotificationSettingsState;
|
||||
setNotificationSettings: Dispatch<NotificationSettingsAction>;
|
||||
}
|
||||
|
||||
export interface ICreateAlertProviderProps {
|
||||
@ -38,7 +40,8 @@ export type CreateAlertAction =
|
||||
| { type: 'SET_ALERT_NAME'; payload: string }
|
||||
| { type: 'SET_ALERT_DESCRIPTION'; payload: string }
|
||||
| { type: 'SET_ALERT_LABELS'; payload: Labels }
|
||||
| { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined };
|
||||
| { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export interface Threshold {
|
||||
id: string;
|
||||
@ -190,3 +193,31 @@ export type EvaluationWindowAction =
|
||||
| { type: 'RESET' };
|
||||
|
||||
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';
|
||||
|
||||
export interface NotificationSettingsState {
|
||||
multipleNotifications: string[] | null;
|
||||
reNotification: {
|
||||
enabled: boolean;
|
||||
value: number;
|
||||
unit: string;
|
||||
conditions: ('firing' | 'no-data')[];
|
||||
};
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type NotificationSettingsAction =
|
||||
| {
|
||||
type: 'SET_MULTIPLE_NOTIFICATIONS';
|
||||
payload: string[] | null;
|
||||
}
|
||||
| {
|
||||
type: 'SET_RE_NOTIFICATION';
|
||||
payload: {
|
||||
enabled: boolean;
|
||||
value: number;
|
||||
unit: string;
|
||||
conditions: ('firing' | 'no-data')[];
|
||||
};
|
||||
}
|
||||
| { type: 'SET_DESCRIPTION'; payload: string }
|
||||
| { type: 'RESET' };
|
||||
|
||||
@ -13,8 +13,10 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_STATE,
|
||||
INITIAL_ALERT_THRESHOLD_STATE,
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from './constants';
|
||||
import {
|
||||
AdvancedOptionsAction,
|
||||
@ -25,6 +27,8 @@ import {
|
||||
CreateAlertAction,
|
||||
EvaluationWindowAction,
|
||||
EvaluationWindowState,
|
||||
NotificationSettingsAction,
|
||||
NotificationSettingsState,
|
||||
} from './types';
|
||||
|
||||
export const alertCreationReducer = (
|
||||
@ -52,6 +56,8 @@ export const alertCreationReducer = (
|
||||
...state,
|
||||
yAxisUnit: action.payload,
|
||||
};
|
||||
case 'RESET':
|
||||
return INITIAL_ALERT_STATE;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@ -172,3 +178,21 @@ export const evaluationWindowReducer = (
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const notificationSettingsReducer = (
|
||||
state: NotificationSettingsState,
|
||||
action: NotificationSettingsAction,
|
||||
): NotificationSettingsState => {
|
||||
switch (action.type) {
|
||||
case 'SET_MULTIPLE_NOTIFICATIONS':
|
||||
return { ...state, multipleNotifications: action.payload };
|
||||
case 'SET_RE_NOTIFICATION':
|
||||
return { ...state, reNotification: action.payload };
|
||||
case 'SET_DESCRIPTION':
|
||||
return { ...state, description: action.payload };
|
||||
case 'RESET':
|
||||
return INITIAL_NOTIFICATION_SETTINGS_STATE;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user