Compare commits

...

18 Commits

Author SHA1 Message Date
Ekansh Gupta
dd89f274c1 Merge branch 'main' into indirectDescOperatorImprovement 2025-09-25 10:05:52 +05:30
SagarRajput-7
9114b44c0e fix: correctly set and unset the stackbarchart value across panel types (#9158) 2025-09-24 22:37:31 +05:30
Vikrant Gupta
c68096152d chore(clickhouse): bump ch-go (#9169)
* fix(integration): fix tests

* fix(integration): fix tests

* chore(clickhouse): bump ch-go

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2025-09-24 15:10:29 +05:30
Vikrant Gupta
4d8d0223e7 fix(integration): fix tests (#9168)
* fix(integration): fix tests

* fix(integration): fix tests
2025-09-24 14:54:43 +05:30
Yunus M
2f4b8f6f80 feat: standardise header to include share and feedback sections (#9037)
* feat: standardise header to include share and feedback sections

* feat: add unit test cases

* feat: handle click outside to close open modals

* fix: handle click outside to close modals

* chore: update event name and placeholder

* fix: test cases

* feat: show success / failure message on feedback submit, fix test cases

* feat: add test cases to check if toast messages are shown on feedback submit

* feat: address review comments

* feat: update test cases

---------

Co-authored-by: makeavish <makeavish786@gmail.com>
2025-09-24 11:52:37 +05:30
Amlan Kumar Nandy
a54c3a3d7f chore: add notification settings section to create alert (#9162) 2025-09-24 08:52:05 +05:30
Amlan Kumar Nandy
2c59c1196d chore: add evaluation settings section (#9134) 2025-09-23 15:36:40 +00:00
manika-signoz
73ff89a80a feat: revamp onboarding (#9068)
* feat: revamp onboarding, send list to mixpanel, join logic to convert to single string

* chore: props changes

* fix: allow user to proceed even if api fails

* chore: remove console.log

* chore: remove commented code

* chore: minor colour tweaks

* chore: resolve comments
2025-09-23 20:47:39 +05:30
Ekansh Gupta
b258d7653f Merge branch 'main' into indirectDescOperatorImprovement 2025-09-23 20:09:43 +05:30
Abhi kumar
b2dc2790d8 fix: invalid function name cumsum (#9161) 2025-09-23 14:37:44 +00:00
Ekansh Gupta
65247b268e Merge branch 'main' into indirectDescOperatorImprovement 2025-09-23 20:07:43 +05:30
SagarRajput-7
dc8e4365f5 fix: fixed scroll reset issue when interacting with legends (#9065)
* fix: fixed scroll reset issue when interacting with legends

* fix: added test cases to ensure codes execution and req function are attached

* fix: added test cases

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-09-23 12:13:13 +00:00
Ekansh Gupta
8501ab5d9d Merge branch 'main' into indirectDescOperatorImprovement 2025-09-23 17:04:19 +05:30
Ekansh Gupta
eb38dd548a 3rd party sem conv fix (#8980)
* feat: adding support for 1.26 semconv

* feat: adding support for 1.26 semconv

* feat: adding support for 1.26 semconv

* feat: adding support for 1.26 semconv

* feat: added native support for 1.26

* feat: added native support for 1.26

* feat: adding support for 1.26 semconv

* feat: adding support for 1.26 semconv

* feat: adding support for 1.26 semconv

* feat: resolved conflicts

* feat: resolved conflicts

* feat: resolved conflicts

* feat: resolved conflicts

* feat: resolved conflicts

* feat: fixed merge conflicts

* feat: fixed merge conflicts

* feat: added intermediate methods to fix response structure

* feat: fixed the errors on errors.newf
2025-09-23 10:55:59 +00:00
Abhi kumar
0ac5d97495 feat: Move 3rd party apis to QB V5 (#9042)
* feat: moved apis out and added proper types

* feat: intergrated new api in 3rd party monitoring

* feat: intergrated new API structure

* chore: fix for null pointer exception

* test: added test for formatDataForTable function

* chore: added placeholder prop in querysearch

* chore: added placeholder prop in querysearch

* feat: added hook for listoverview api
2025-09-23 16:15:05 +05:30
eKuG
d37ca6eac2 feat: added top down approach for indirect descendant operator 2025-09-23 13:44:32 +05:30
Abhi kumar
710f7740d3 fix: added fix for cursor jump in QB (#9140)
* fix: added fix for cursor jump in QB

* chore: minor cleanup

* feat: updating the query when the editor is getting out for focus or running the query

* test: added test for QuerySearch

* chore: updated variable name for QB interaction

* chore: updated PR review changes

* chore: removed non required comments
2025-09-23 13:06:52 +05:30
Amlan Kumar Nandy
a16ab114f5 chore: add evaluation cadence component for alerts v2 (#9131) 2025-09-22 20:12:59 +05:30
125 changed files with 9927 additions and 1544 deletions

View File

@@ -1,6 +1,6 @@
services:
signoz-otel-collector:
image: signoz/signoz-otel-collector:v0.128.2
image: signoz/signoz-otel-collector:v0.129.6
container_name: signoz-otel-collector-dev
command:
- --config=/etc/otel-collector-config.yaml

View File

@@ -21,10 +21,9 @@ jobs:
- postgres
- sqlite
clickhouse-version:
- 24.1.2-alpine
- 25.5.6
schema-migrator-version:
- v0.128.1
- v0.129.6
postgres-version:
- 15
if: |

4
.gitignore vendored
View File

@@ -230,4 +230,6 @@ poetry.toml
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python
# End of https://www.toptal.com/developers/gitignore/api/python
frontend/.cursor/rules/

View File

@@ -192,7 +192,7 @@ Tests can be configured using pytest options:
- `--sqlstore-provider` - Choose database provider (default: postgres)
- `--postgres-version` - PostgreSQL version (default: 15)
- `--clickhouse-version` - ClickHouse version (default: 24.1.2-alpine)
- `--clickhouse-version` - ClickHouse version (default: 25.5.6)
- `--zookeeper-version` - Zookeeper version (default: 3.7.1)
Example:

View File

@@ -0,0 +1,31 @@
import { ApiBaseInstance } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/thirdPartyApis/listOverview';
const listOverview = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
const { start, end, show_ip: showIp, filter } = props;
try {
const response = await ApiBaseInstance.post(
`/third-party-apis/overview/list`,
{
start,
end,
show_ip: showIp,
filter,
},
);
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default listOverview;

View File

@@ -124,7 +124,7 @@ export const FUNCTION_NAMES: Record<string, FunctionName> = {
RUNNING_DIFF: 'runningDiff',
LOG2: 'log2',
LOG10: 'log10',
CUM_SUM: 'cumSum',
CUM_SUM: 'cumulativeSum',
EWMA3: 'ewma3',
EWMA5: 'ewma5',
EWMA7: 'ewma7',

View File

@@ -0,0 +1,15 @@
import { Typography } from 'antd';
function AnnouncementsModal(): JSX.Element {
return (
<div className="announcements-modal-container">
<div className="announcements-modal-container-header">
<Typography.Text className="announcements-modal-title">
Announcements
</Typography.Text>
</div>
</div>
);
}
export default AnnouncementsModal;

View File

@@ -0,0 +1,160 @@
import { toast } from '@signozhq/sonner';
import { Button, Input, Radio, RadioChangeEvent, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { handleContactSupport } from 'pages/Integrations/utils';
import { useCallback, useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
const [activeTab, setActiveTab] = useState('feedback');
const [feedback, setFeedback] = useState('');
const location = useLocation();
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (): Promise<void> => {
setIsLoading(true);
let entityName = 'Feedback';
if (activeTab === 'reportBug') {
entityName = 'Bug report';
} else if (activeTab === 'featureRequest') {
entityName = 'Feature request';
}
logEvent('Feedback: Submitted', {
data: feedback,
type: activeTab,
page: location.pathname,
})
.then(() => {
onClose();
toast.success(`${entityName} submitted successfully`, {
position: 'top-right',
});
})
.catch(() => {
console.error(`Failed to submit ${entityName}`);
toast.error(`Failed to submit ${entityName}`, {
position: 'top-right',
});
})
.finally(() => {
setIsLoading(false);
});
};
useEffect(
() => (): void => {
setFeedback('');
setActiveTab('feedback');
},
[],
);
const items = [
{
label: (
<div className="feedback-modal-tab-label">
<div className="tab-icon dot feedback-tab" />
Feedback
</div>
),
key: 'feedback',
value: 'feedback',
},
{
label: (
<div className="feedback-modal-tab-label">
<div className="tab-icon dot bug-tab" />
Report a bug
</div>
),
key: 'reportBug',
value: 'reportBug',
},
{
label: (
<div className="feedback-modal-tab-label">
<div className="tab-icon dot feature-tab" />
Feature request
</div>
),
key: 'featureRequest',
value: 'featureRequest',
},
];
const handleFeedbackChange = (
e: React.ChangeEvent<HTMLTextAreaElement>,
): void => {
setFeedback(e.target.value);
};
const handleContactSupportClick = useCallback((): void => {
handleContactSupport(isCloudUserVal);
}, [isCloudUserVal]);
return (
<div className="feedback-modal-container">
<div className="feedback-modal-header">
<Radio.Group
value={activeTab}
defaultValue={activeTab}
optionType="button"
className="feedback-modal-tabs"
options={items}
onChange={(e: RadioChangeEvent): void => setActiveTab(e.target.value)}
/>
</div>
<div className="feedback-modal-content">
<div className="feedback-modal-content-header">
<Input.TextArea
placeholder="Write your feedback here..."
rows={6}
required
className="feedback-input"
value={feedback}
onChange={handleFeedbackChange}
/>
</div>
</div>
<div className="feedback-modal-content-footer">
<Button
className="periscope-btn primary"
type="primary"
onClick={handleSubmit}
loading={isLoading}
disabled={feedback.length === 0}
>
Submit
</Button>
<div className="feedback-modal-content-footer-info-text">
<Typography.Text>
Have a specific issue?{' '}
<Typography.Link
className="contact-support-link"
onClick={handleContactSupportClick}
>
Contact Support{' '}
</Typography.Link>
or{' '}
<a
href="https://signoz.io/docs/introduction/"
target="_blank"
rel="noreferrer"
className="read-docs-link"
>
Read our docs
</a>
</Typography.Text>
</div>
</div>
</div>
);
}
export default FeedbackModal;

View File

@@ -0,0 +1,253 @@
.header-right-section-container {
display: flex;
align-items: center;
gap: 8px;
}
.share-modal-content,
.feedback-modal-container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 12px;
width: 460px;
border-radius: 4px;
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
.absolute-relative-time-toggler-container {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
.absolute-relative-time-toggler-label {
color: var(--bg-vanilla-100);
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.absolute-relative-time-toggler {
display: flex;
gap: 4px;
align-items: center;
}
.absolute-relative-time-error {
font-size: 12px;
color: var(--bg-amber-600);
}
.share-link {
.url-share-container {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
.url-share-container-header {
display: flex;
flex-direction: column;
gap: 4px;
.url-share-title,
.url-share-sub-title {
color: var(--bg-vanilla-100);
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.url-share-sub-title {
font-size: 12px;
color: var(--bg-vanilla-300);
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
}
}
}
}
.feedback-modal-container {
.feedback-modal-tabs {
width: 100%;
display: flex;
.ant-radio-button-wrapper {
flex: 1;
margin: 0px !important;
border: 1px solid var(--bg-slate-400);
&:before {
display: none;
}
.ant-radio-button-checked {
background-color: var(--bg-slate-400);
}
}
.feedback-modal-tab-label {
display: flex;
align-items: center;
gap: 8px;
.tab-icon {
width: 6px;
height: 6px;
}
.feedback-tab {
background-color: var(--bg-sakura-500);
}
.bug-tab {
background-color: var(--bg-amber-500);
}
.feature-tab {
background-color: var(--bg-robin-500);
}
}
.ant-tabs-nav-list {
.ant-tabs-tab {
padding: 6px 16px;
border-radius: 2px;
background: var(--bg-ink-400);
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
border: 1px solid var(--bg-slate-400);
margin: 0 !important;
.ant-tabs-tab-btn {
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
letter-spacing: -0.06px;
}
&-active {
background: var(--bg-slate-400);
color: var(--bg-vanilla-100);
border-bottom: none !important;
.ant-tabs-tab-btn {
color: var(--bg-vanilla-100);
}
}
}
}
}
.feedback-modal-content {
display: flex;
flex-direction: column;
gap: 16px;
.feedback-input {
resize: none;
text-area {
resize: none;
}
}
.feedback-content-include-console-logs {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
}
.feedback-modal-content-footer {
display: flex;
flex-direction: column;
gap: 16px;
.feedback-modal-content-footer-info-text {
font-size: 12px;
color: var(--bg-vanilla-400, #c0c1c3);
text-align: center;
/* button/ small */
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px; /* 200% */
.contact-support-link,
.read-docs-link {
color: var(--bg-robin-400);
font-weight: 500;
font-size: 12px;
}
}
}
}
.lightMode {
.share-modal-content,
.feedback-modal-container {
.absolute-relative-time-toggler-container {
.absolute-relative-time-toggler-label {
color: var(--bg-ink-400);
}
}
.share-link {
.url-share-container {
.url-share-container-header {
.url-share-title,
.url-share-sub-title {
color: var(--bg-ink-400);
}
.url-share-sub-title {
color: var(--bg-ink-300);
}
}
}
}
}
.feedback-modal-container {
.feedback-modal-tabs {
.ant-radio-button-wrapper {
flex: 1;
margin: 0px !important;
border: 1px solid var(--bg-vanilla-300);
&:before {
display: none;
}
.ant-radio-button-checked {
background-color: var(--bg-vanilla-300);
}
}
}
.feedback-modal-content-footer {
.feedback-modal-content-footer-info-text {
color: var(--bg-slate-400);
}
}
}
}

View File

@@ -0,0 +1,137 @@
import './HeaderRightSection.styles.scss';
import { Button, Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { Globe, Inbox, SquarePen } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import AnnouncementsModal from './AnnouncementsModal';
import FeedbackModal from './FeedbackModal';
import ShareURLModal from './ShareURLModal';
interface HeaderRightSectionProps {
enableAnnouncements: boolean;
enableShare: boolean;
enableFeedback: boolean;
}
function HeaderRightSection({
enableAnnouncements,
enableShare,
enableFeedback,
}: HeaderRightSectionProps): JSX.Element {
const location = useLocation();
const [openFeedbackModal, setOpenFeedbackModal] = useState(false);
const [openShareURLModal, setOpenShareURLModal] = useState(false);
const [openAnnouncementsModal, setOpenAnnouncementsModal] = useState(false);
const handleOpenFeedbackModal = useCallback((): void => {
logEvent('Feedback: Clicked', {
page: location.pathname,
});
setOpenFeedbackModal(true);
setOpenShareURLModal(false);
setOpenAnnouncementsModal(false);
}, [location.pathname]);
const handleOpenShareURLModal = useCallback((): void => {
logEvent('Share: Clicked', {
page: location.pathname,
});
setOpenShareURLModal(true);
setOpenFeedbackModal(false);
setOpenAnnouncementsModal(false);
}, [location.pathname]);
const handleCloseFeedbackModal = (): void => {
setOpenFeedbackModal(false);
};
const handleOpenFeedbackModalChange = (open: boolean): void => {
setOpenFeedbackModal(open);
};
const handleOpenAnnouncementsModalChange = (open: boolean): void => {
setOpenAnnouncementsModal(open);
};
const handleOpenShareURLModalChange = (open: boolean): void => {
setOpenShareURLModal(open);
};
return (
<div className="header-right-section-container">
{enableFeedback && (
<Popover
rootClassName="header-section-popover-root"
className="shareable-link-popover"
placement="bottomRight"
content={<FeedbackModal onClose={handleCloseFeedbackModal} />}
destroyTooltipOnHide
arrow={false}
trigger="click"
open={openFeedbackModal}
onOpenChange={handleOpenFeedbackModalChange}
>
<Button
className="share-feedback-btn periscope-btn ghost"
icon={<SquarePen size={14} />}
onClick={handleOpenFeedbackModal}
/>
</Popover>
)}
{enableAnnouncements && (
<Popover
rootClassName="header-section-popover-root"
className="shareable-link-popover"
placement="bottomRight"
content={<AnnouncementsModal />}
arrow={false}
destroyTooltipOnHide
trigger="click"
open={openAnnouncementsModal}
onOpenChange={handleOpenAnnouncementsModalChange}
>
<Button
icon={<Inbox size={14} />}
className="periscope-btn ghost announcements-btn"
onClick={(): void => {
logEvent('Announcements: Clicked', {
page: location.pathname,
});
}}
/>
</Popover>
)}
{enableShare && (
<Popover
rootClassName="header-section-popover-root"
className="shareable-link-popover"
placement="bottomRight"
content={<ShareURLModal />}
open={openShareURLModal}
destroyTooltipOnHide
arrow={false}
trigger="click"
onOpenChange={handleOpenShareURLModalChange}
>
<Button
className="share-link-btn periscope-btn ghost"
icon={<Globe size={14} />}
onClick={handleOpenShareURLModal}
>
Share
</Button>
</Popover>
)}
</div>
);
}
export default HeaderRightSection;

View File

@@ -0,0 +1,171 @@
import { Color } from '@signozhq/design-tokens';
import { Button, Switch, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import { Check, Info, Link2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { matchPath, useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
const routesToBeSharedWithTime = [
ROUTES.LOGS_EXPLORER,
ROUTES.TRACES_EXPLORER,
ROUTES.METRICS_EXPLORER_EXPLORER,
ROUTES.METER_EXPLORER,
];
function ShareURLModal(): JSX.Element {
const urlQuery = useUrlQuery();
const location = useLocation();
const { selectedTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const [enableAbsoluteTime, setEnableAbsoluteTime] = useState(
selectedTime !== 'custom',
);
const startTime = urlQuery.get(QueryParams.startTime);
const endTime = urlQuery.get(QueryParams.endTime);
const relativeTime = urlQuery.get(QueryParams.relativeTime);
const [isURLCopied, setIsURLCopied] = useState(false);
const [, handleCopyToClipboard] = useCopyToClipboard();
const isValidateRelativeTime = useMemo(
() =>
selectedTime !== 'custom' ||
(startTime && endTime && selectedTime === 'custom'),
[startTime, endTime, selectedTime],
);
const shareURLWithTime = useMemo(
() => relativeTime || (startTime && endTime),
[relativeTime, startTime, endTime],
);
const isRouteToBeSharedWithTime = useMemo(
() =>
routesToBeSharedWithTime.some((route) =>
matchPath(location.pathname, { path: route, exact: true }),
),
[location.pathname],
);
// eslint-disable-next-line sonarjs/cognitive-complexity
const processURL = (): string => {
let currentUrl = window.location.href;
const isCustomTime = !!(startTime && endTime && selectedTime === 'custom');
if (shareURLWithTime || isRouteToBeSharedWithTime) {
if (enableAbsoluteTime || isCustomTime) {
if (selectedTime === 'custom') {
if (startTime && endTime) {
urlQuery.set(QueryParams.startTime, startTime.toString());
urlQuery.set(QueryParams.endTime, endTime.toString());
}
} else {
const { minTime, maxTime } = GetMinMax(selectedTime);
urlQuery.set(QueryParams.startTime, minTime.toString());
urlQuery.set(QueryParams.endTime, maxTime.toString());
}
urlQuery.delete(QueryParams.relativeTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
} else {
urlQuery.delete(QueryParams.startTime);
urlQuery.delete(QueryParams.endTime);
urlQuery.set(QueryParams.relativeTime, selectedTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
}
}
return currentUrl;
};
const handleCopyURL = (): void => {
const URL = processURL();
handleCopyToClipboard(URL);
setIsURLCopied(true);
logEvent('Share: Copy link clicked', {
page: location.pathname,
URL,
});
setTimeout(() => {
setIsURLCopied(false);
}, 1000);
};
return (
<div className="share-modal-content">
{(shareURLWithTime || isRouteToBeSharedWithTime) && (
<>
<div className="absolute-relative-time-toggler-container">
<Typography.Text className="absolute-relative-time-toggler-label">
Enable absolute time
</Typography.Text>
<div className="absolute-relative-time-toggler">
{!isValidateRelativeTime && (
<Info size={14} color={Color.BG_AMBER_600} />
)}
<Switch
checked={enableAbsoluteTime}
disabled={!isValidateRelativeTime}
size="small"
onChange={(): void => {
setEnableAbsoluteTime((prev) => !prev);
}}
/>
</div>
</div>
{!isValidateRelativeTime && (
<div className="absolute-relative-time-error">
Please select / enter valid relative time to toggle.
</div>
)}
</>
)}
<div className="share-link">
<div className="url-share-container">
<div className="url-share-container-header">
<Typography.Text className="url-share-title">
Share page link
</Typography.Text>
<Typography.Text className="url-share-sub-title">
Share the current page link with your team member
</Typography.Text>
</div>
<Button
className="periscope-btn secondary"
onClick={handleCopyURL}
icon={isURLCopied ? <Check size={14} /> : <Link2 size={14} />}
>
Copy page link
</Button>
</div>
</div>
</div>
);
}
export default ShareURLModal;

View File

@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react';
import AnnouncementsModal from '../AnnouncementsModal';
describe('AnnouncementsModal', () => {
it('should render announcements modal with title', () => {
render(<AnnouncementsModal />);
expect(screen.getByText('Announcements')).toBeInTheDocument();
});
it('should have proper structure and classes', () => {
render(<AnnouncementsModal />);
const container = screen
.getByText('Announcements')
.closest('.announcements-modal-container');
expect(container).toBeInTheDocument();
const headerContainer = screen
.getByText('Announcements')
.closest('.announcements-modal-container-header');
expect(headerContainer).toBeInTheDocument();
});
it('should render without any errors', () => {
expect(() => render(<AnnouncementsModal />)).not.toThrow();
});
});

View File

@@ -0,0 +1,274 @@
/* eslint-disable sonarjs/no-duplicate-string */
// Mock dependencies before imports
import { toast } from '@signozhq/sonner';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { handleContactSupport } from 'pages/Integrations/utils';
import { useLocation } from 'react-router-dom';
import FeedbackModal from '../FeedbackModal';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve()),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
}));
jest.mock('@signozhq/sonner', () => ({
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: jest.fn(),
}));
jest.mock('pages/Integrations/utils', () => ({
handleContactSupport: jest.fn(),
}));
const mockLogEvent = logEvent as jest.MockedFunction<typeof logEvent>;
const mockUseLocation = useLocation as jest.Mock;
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
const mockHandleContactSupport = handleContactSupport as jest.Mock;
const mockToast = toast as jest.Mocked<typeof toast>;
const mockOnClose = jest.fn();
const mockLocation = {
pathname: '/test-path',
};
describe('FeedbackModal', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseLocation.mockReturnValue(mockLocation);
mockUseGetTenantLicense.mockReturnValue({
isCloudUser: false,
});
mockToast.success.mockClear();
mockToast.error.mockClear();
});
it('should render feedback modal with all tabs', () => {
render(<FeedbackModal onClose={mockOnClose} />);
expect(screen.getByText('Feedback')).toBeInTheDocument();
expect(screen.getByText('Report a bug')).toBeInTheDocument();
expect(screen.getByText('Feature request')).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Write your feedback here...'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
});
it('should switch between tabs when clicked', async () => {
const user = userEvent.setup();
render(<FeedbackModal onClose={mockOnClose} />);
// Initially, feedback radio should be active
const feedbackRadio = screen.getByRole('radio', { name: 'Feedback' });
expect(feedbackRadio).toBeChecked();
const bugTab = screen.getByText('Report a bug');
await user.click(bugTab);
// Bug radio should now be active
const bugRadio = screen.getByRole('radio', { name: 'Report a bug' });
expect(bugRadio).toBeChecked();
const featureTab = screen.getByText('Feature request');
await user.click(featureTab);
// Feature radio should now be active
const featureRadio = screen.getByRole('radio', { name: 'Feature request' });
expect(featureRadio).toBeChecked();
});
it('should update feedback text when typing in textarea', async () => {
const user = userEvent.setup();
render(<FeedbackModal onClose={mockOnClose} />);
const textarea = screen.getByPlaceholderText('Write your feedback here...');
const testFeedback = 'This is my feedback';
await user.type(textarea, testFeedback);
expect(textarea).toHaveValue(testFeedback);
});
it('should submit feedback and log event when submit button is clicked', async () => {
const user = userEvent.setup();
render(<FeedbackModal onClose={mockOnClose} />);
const textarea = screen.getByPlaceholderText('Write your feedback here...');
const submitButton = screen.getByRole('button', { name: /submit/i });
const testFeedback = 'Test feedback content';
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'feedback',
page: mockLocation.pathname,
});
expect(mockOnClose).toHaveBeenCalled();
expect(mockToast.success).toHaveBeenCalledWith(
'Feedback submitted successfully',
{
position: 'top-right',
},
);
});
it('should submit bug report with correct type', async () => {
const user = userEvent.setup();
render(<FeedbackModal onClose={mockOnClose} />);
// Switch to bug report tab
const bugTab = screen.getByText('Report a bug');
await user.click(bugTab);
// Verify bug report radio is now active
const bugRadio = screen.getByRole('radio', { name: 'Report a bug' });
expect(bugRadio).toBeChecked();
const textarea = screen.getByPlaceholderText('Write your feedback here...');
const submitButton = screen.getByRole('button', { name: /submit/i });
const testFeedback = 'This is a bug report';
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'reportBug',
page: mockLocation.pathname,
});
expect(mockOnClose).toHaveBeenCalled();
expect(mockToast.success).toHaveBeenCalledWith(
'Bug report submitted successfully',
{
position: 'top-right',
},
);
});
it('should submit feature request with correct type', async () => {
const user = userEvent.setup();
render(<FeedbackModal onClose={mockOnClose} />);
// Switch to feature request tab
const featureTab = screen.getByText('Feature request');
await user.click(featureTab);
// Verify feature request radio is now active
const featureRadio = screen.getByRole('radio', { name: 'Feature request' });
expect(featureRadio).toBeChecked();
const textarea = screen.getByPlaceholderText('Write your feedback here...');
const submitButton = screen.getByRole('button', { name: /submit/i });
const testFeedback = 'This is a feature request';
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'featureRequest',
page: mockLocation.pathname,
});
expect(mockOnClose).toHaveBeenCalled();
expect(mockToast.success).toHaveBeenCalledWith(
'Feature request submitted successfully',
{
position: 'top-right',
},
);
});
it('should call handleContactSupport when contact support link is clicked', async () => {
const user = userEvent.setup();
const isCloudUser = true;
mockUseGetTenantLicense.mockReturnValue({
isCloudUser,
});
render(<FeedbackModal onClose={mockOnClose} />);
const contactSupportLink = screen.getByText('Contact Support');
await user.click(contactSupportLink);
expect(mockHandleContactSupport).toHaveBeenCalledWith(isCloudUser);
});
it('should handle non-cloud user for contact support', async () => {
const user = userEvent.setup();
const isCloudUser = false;
mockUseGetTenantLicense.mockReturnValue({
isCloudUser,
});
render(<FeedbackModal onClose={mockOnClose} />);
const contactSupportLink = screen.getByText('Contact Support');
await user.click(contactSupportLink);
expect(mockHandleContactSupport).toHaveBeenCalledWith(isCloudUser);
});
it('should render docs link with correct attributes', () => {
render(<FeedbackModal onClose={mockOnClose} />);
const docsLink = screen.getByText('Read our docs');
expect(docsLink).toHaveAttribute(
'href',
'https://signoz.io/docs/introduction/',
);
expect(docsLink).toHaveAttribute('target', '_blank');
expect(docsLink).toHaveAttribute('rel', 'noreferrer');
});
it('should reset form state when component unmounts', async () => {
const user = userEvent.setup();
// Render component
const { unmount } = render(<FeedbackModal onClose={mockOnClose} />);
// Change the form state first
const textArea = screen.getByPlaceholderText('Write your feedback here...');
await user.type(textArea, 'Some feedback text');
// Change the active tab
const bugTab = screen.getByText('Report a bug');
await user.click(bugTab);
// Verify state has changed
expect(textArea).toHaveValue('Some feedback text');
// Unmount the component - this should trigger cleanup
unmount();
// Re-render the component to verify state was reset
render(<FeedbackModal onClose={mockOnClose} />);
// Verify form state is reset
const newTextArea = screen.getByPlaceholderText(
'Write your feedback here...',
);
expect(newTextArea).toHaveValue(''); // Should be empty
// Verify active radio is reset to default (Feedback radio)
const feedbackRadio = screen.getByRole('radio', { name: 'Feedback' });
expect(feedbackRadio).toBeChecked();
});
});

View File

@@ -0,0 +1,192 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable react/jsx-props-no-spreading */
// Mock dependencies before imports
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { useLocation } from 'react-router-dom';
import HeaderRightSection from '../HeaderRightSection';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
}));
jest.mock('../FeedbackModal', () => ({
__esModule: true,
default: ({ onClose }: { onClose: () => void }): JSX.Element => (
<div data-testid="feedback-modal">
<button onClick={onClose} type="button">
Close Feedback
</button>
</div>
),
}));
jest.mock('../ShareURLModal', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="share-modal">Share URL Modal</div>
),
}));
jest.mock('../AnnouncementsModal', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="announcements-modal">Announcements Modal</div>
),
}));
const mockLogEvent = logEvent as jest.Mock;
const mockUseLocation = useLocation as jest.Mock;
const defaultProps = {
enableAnnouncements: true,
enableShare: true,
enableFeedback: true,
};
const mockLocation = {
pathname: '/test-path',
};
describe('HeaderRightSection', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseLocation.mockReturnValue(mockLocation);
});
it('should render all buttons when all features are enabled', () => {
render(<HeaderRightSection {...defaultProps} />);
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(3);
expect(screen.getByRole('button', { name: /share/i })).toBeInTheDocument();
// Check for feedback button by class
const feedbackButton = document.querySelector(
'.share-feedback-btn[class*="share-feedback-btn"]',
);
expect(feedbackButton).toBeInTheDocument();
// Check for announcements button by finding the inbox icon
const inboxIcon = document.querySelector('.lucide-inbox');
expect(inboxIcon).toBeInTheDocument();
});
it('should render only enabled features', () => {
render(
<HeaderRightSection
enableAnnouncements={false}
enableShare={false}
enableFeedback
/>,
);
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(1);
expect(
screen.queryByRole('button', { name: /share/i }),
).not.toBeInTheDocument();
// Check that inbox icon is not present
const inboxIcon = document.querySelector('.lucide-inbox');
expect(inboxIcon).not.toBeInTheDocument();
// Check that feedback button is present
const squarePenIcon = document.querySelector('.lucide-square-pen');
expect(squarePenIcon).toBeInTheDocument();
});
it('should open feedback modal and log event when feedback button is clicked', async () => {
const user = userEvent.setup();
render(<HeaderRightSection {...defaultProps} />);
const feedbackButton = document
.querySelector('.lucide-square-pen')
?.closest('button');
expect(feedbackButton).toBeInTheDocument();
await user.click(feedbackButton!);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Clicked', {
page: mockLocation.pathname,
});
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
});
it('should open share modal and log event when share button is clicked', async () => {
const user = userEvent.setup();
render(<HeaderRightSection {...defaultProps} />);
const shareButton = screen.getByRole('button', { name: /share/i });
await user.click(shareButton);
expect(mockLogEvent).toHaveBeenCalledWith('Share: Clicked', {
page: mockLocation.pathname,
});
expect(screen.getByTestId('share-modal')).toBeInTheDocument();
});
it('should log event when announcements button is clicked', async () => {
const user = userEvent.setup();
render(<HeaderRightSection {...defaultProps} />);
const announcementsButton = document
.querySelector('.lucide-inbox')
?.closest('button');
expect(announcementsButton).toBeInTheDocument();
await user.click(announcementsButton!);
expect(mockLogEvent).toHaveBeenCalledWith('Announcements: Clicked', {
page: mockLocation.pathname,
});
});
it('should close feedback modal when onClose is called', async () => {
const user = userEvent.setup();
render(<HeaderRightSection {...defaultProps} />);
// Open feedback modal
const feedbackButton = document
.querySelector('.lucide-square-pen')
?.closest('button');
expect(feedbackButton).toBeInTheDocument();
await user.click(feedbackButton!);
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
// Close feedback modal
const closeFeedbackButton = screen.getByText('Close Feedback');
await user.click(closeFeedbackButton);
expect(screen.queryByTestId('feedback-modal')).not.toBeInTheDocument();
});
it('should close other modals when opening feedback modal', async () => {
const user = userEvent.setup();
render(<HeaderRightSection {...defaultProps} />);
// Open share modal first
const shareButton = screen.getByRole('button', { name: /share/i });
await user.click(shareButton);
expect(screen.getByTestId('share-modal')).toBeInTheDocument();
// Open feedback modal - should close share modal
const feedbackButton = document
.querySelector('.lucide-square-pen')
?.closest('button');
expect(feedbackButton).toBeInTheDocument();
await user.click(feedbackButton!);
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
expect(screen.queryByTestId('share-modal')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,289 @@
// Mock dependencies before imports
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import { useSelector } from 'react-redux';
import { matchPath, useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import ShareURLModal from '../ShareURLModal';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
matchPath: jest.fn(),
}));
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
}));
jest.mock('lib/getMinMax', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: jest.fn(),
}));
// Mock window.location
const mockLocation = {
href: 'https://example.com/test-path?param=value',
origin: 'https://example.com',
};
Object.defineProperty(window, 'location', {
value: mockLocation,
writable: true,
});
const mockLogEvent = logEvent as jest.Mock;
const mockUseLocation = useLocation as jest.Mock;
const mockUseUrlQuery = useUrlQuery as jest.Mock;
const mockUseSelector = useSelector as jest.Mock;
const mockGetMinMax = GetMinMax as jest.Mock;
const mockUseCopyToClipboard = useCopyToClipboard as jest.Mock;
const mockMatchPath = matchPath as jest.Mock;
const mockUrlQuery = {
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
toString: jest.fn(() => 'param=value'),
};
const mockHandleCopyToClipboard = jest.fn();
const TEST_PATH = '/test-path';
const ENABLE_ABSOLUTE_TIME_TEXT = 'Enable absolute time';
describe('ShareURLModal', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseLocation.mockReturnValue({
pathname: TEST_PATH,
});
mockUseUrlQuery.mockReturnValue(mockUrlQuery);
mockUseSelector.mockReturnValue({
selectedTime: '5min',
});
mockGetMinMax.mockReturnValue({
minTime: 1000000,
maxTime: 2000000,
});
mockUseCopyToClipboard.mockReturnValue([null, mockHandleCopyToClipboard]);
mockMatchPath.mockReturnValue(false);
// Reset URL query mocks - all return null by default
mockUrlQuery.get.mockReturnValue(null);
// Reset mock functions
mockUrlQuery.set.mockClear();
mockUrlQuery.delete.mockClear();
mockUrlQuery.toString.mockReturnValue('param=value');
});
it('should render share modal with copy button', () => {
render(<ShareURLModal />);
expect(screen.getByText('Share page link')).toBeInTheDocument();
expect(
screen.getByText('Share the current page link with your team member'),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /copy page link/i }),
).toBeInTheDocument();
});
it('should copy URL and log event when copy button is clicked', async () => {
const user = userEvent.setup();
render(<ShareURLModal />);
const copyButton = screen.getByRole('button', { name: /copy page link/i });
await user.click(copyButton);
expect(mockHandleCopyToClipboard).toHaveBeenCalled();
expect(mockLogEvent).toHaveBeenCalledWith('Share: Copy link clicked', {
page: TEST_PATH,
URL: expect.any(String),
});
});
it('should show absolute time toggle when on time-enabled route', () => {
mockMatchPath.mockReturnValue(true); // Simulate being on a route that supports time
render(<ShareURLModal />);
expect(screen.getByText(ENABLE_ABSOLUTE_TIME_TEXT)).toBeInTheDocument();
expect(screen.getByRole('switch')).toBeInTheDocument();
});
it('should show absolute time toggle when URL has time parameters', () => {
mockUrlQuery.get.mockImplementation((key: string) =>
key === 'relativeTime' ? '5min' : null,
);
render(<ShareURLModal />);
expect(screen.getByText(ENABLE_ABSOLUTE_TIME_TEXT)).toBeInTheDocument();
});
it('should toggle absolute time switch', async () => {
const user = userEvent.setup();
mockMatchPath.mockReturnValue(true);
mockUseSelector.mockReturnValue({
selectedTime: '5min', // Non-custom time should enable absolute time by default
});
render(<ShareURLModal />);
const toggleSwitch = screen.getByRole('switch');
// Should be checked by default for non-custom time
expect(toggleSwitch).toBeChecked();
await user.click(toggleSwitch);
expect(toggleSwitch).not.toBeChecked();
});
it('should disable toggle when relative time is invalid', () => {
mockUseSelector.mockReturnValue({
selectedTime: 'custom',
});
// Invalid - missing start and end time for custom
mockUrlQuery.get.mockReturnValue(null);
mockMatchPath.mockReturnValue(true);
render(<ShareURLModal />);
expect(
screen.getByText('Please select / enter valid relative time to toggle.'),
).toBeInTheDocument();
expect(screen.getByRole('switch')).toBeDisabled();
});
it('should process URL with absolute time for non-custom time', async () => {
const user = userEvent.setup();
mockMatchPath.mockReturnValue(true);
mockUseSelector.mockReturnValue({
selectedTime: '5min',
});
render(<ShareURLModal />);
// Absolute time should be enabled by default for non-custom time
// Click copy button directly
const copyButton = screen.getByRole('button', { name: /copy page link/i });
await user.click(copyButton);
expect(mockUrlQuery.set).toHaveBeenCalledWith('startTime', '1000000');
expect(mockUrlQuery.set).toHaveBeenCalledWith('endTime', '2000000');
expect(mockUrlQuery.delete).toHaveBeenCalledWith('relativeTime');
});
it('should process URL with custom time parameters', async () => {
const user = userEvent.setup();
mockMatchPath.mockReturnValue(true);
mockUseSelector.mockReturnValue({
selectedTime: 'custom',
});
mockUrlQuery.get.mockImplementation((key: string) => {
switch (key) {
case 'startTime':
return '1500000';
case 'endTime':
return '1600000';
default:
return null;
}
});
render(<ShareURLModal />);
// Should be enabled by default for custom time
const copyButton = screen.getByRole('button', { name: /copy page link/i });
await user.click(copyButton);
expect(mockUrlQuery.set).toHaveBeenCalledWith('startTime', '1500000');
expect(mockUrlQuery.set).toHaveBeenCalledWith('endTime', '1600000');
});
it('should process URL with relative time when absolute time is disabled', async () => {
const user = userEvent.setup();
mockMatchPath.mockReturnValue(true);
mockUseSelector.mockReturnValue({
selectedTime: '5min',
});
render(<ShareURLModal />);
// Disable absolute time first (it's enabled by default for non-custom time)
const toggleSwitch = screen.getByRole('switch');
await user.click(toggleSwitch);
const copyButton = screen.getByRole('button', { name: /copy page link/i });
await user.click(copyButton);
expect(mockUrlQuery.delete).toHaveBeenCalledWith('startTime');
expect(mockUrlQuery.delete).toHaveBeenCalledWith('endTime');
expect(mockUrlQuery.set).toHaveBeenCalledWith('relativeTime', '5min');
});
it('should handle routes that should be shared with time', async () => {
const user = userEvent.setup();
mockUseLocation.mockReturnValue({
pathname: ROUTES.LOGS_EXPLORER,
});
mockMatchPath.mockImplementation(
(pathname: string, options: any) => options.path === ROUTES.LOGS_EXPLORER,
);
render(<ShareURLModal />);
expect(screen.getByText(ENABLE_ABSOLUTE_TIME_TEXT)).toBeInTheDocument();
expect(screen.getByRole('switch')).toBeChecked();
// on clicking copy page link, the copied url should have startTime and endTime
const copyButton = screen.getByRole('button', { name: /copy page link/i });
await user.click(copyButton);
expect(mockUrlQuery.set).toHaveBeenCalledWith('startTime', '1000000');
expect(mockUrlQuery.set).toHaveBeenCalledWith('endTime', '2000000');
expect(mockUrlQuery.delete).toHaveBeenCalledWith('relativeTime');
// toggle the switch to share url with relative time
const toggleSwitch = screen.getByRole('switch');
await user.click(toggleSwitch);
await user.click(copyButton);
expect(mockUrlQuery.delete).toHaveBeenCalledWith('startTime');
expect(mockUrlQuery.delete).toHaveBeenCalledWith('endTime');
expect(mockUrlQuery.set).toHaveBeenCalledWith('relativeTime', '5min');
});
});

View File

@@ -80,20 +80,24 @@ const stopEventsExtension = EditorView.domEventHandlers({
});
function QuerySearch({
placeholder,
onChange,
queryData,
dataSource,
onRun,
signalSource,
hardcodedAttributeKeys,
}: {
placeholder?: string;
onChange: (value: string) => void;
queryData: IBuilderQuery;
dataSource: DataSource;
signalSource?: string;
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
onRun?: (query: string) => void;
}): JSX.Element {
const isDarkMode = useIsDarkMode();
const [query, setQuery] = useState<string>(queryData.filter?.expression || '');
const [query, setQuery] = useState<string>('');
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
const [activeKey, setActiveKey] = useState<string>('');
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
@@ -104,6 +108,10 @@ function QuerySearch({
errors: [],
});
const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 });
const [isFocused, setIsFocused] = useState(false);
const [hasInteractedWithQB, setHasInteractedWithQB] = useState(false);
const handleQueryValidation = (newQuery: string): void => {
try {
const validationResponse = validateQuery(newQuery);
@@ -123,13 +131,28 @@ function QuerySearch({
useEffect(() => {
const newQuery = queryData.filter?.expression || '';
// Only mark as external change if the query actually changed from external source
// Only update query from external source when editor is not focused
// When focused, just update the lastExternalQuery to track changes
if (newQuery !== lastExternalQuery) {
setQuery(newQuery);
setIsExternalQueryChange(true);
setLastExternalQuery(newQuery);
}
}, [queryData.filter?.expression, lastExternalQuery]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryData.filter?.expression]);
useEffect(() => {
// Update the query when the editor is blurred and the query has changed
// Only call onChange if the editor has been focused before (not on initial mount)
if (
!isFocused &&
hasInteractedWithQB &&
query !== queryData.filter?.expression
) {
onChange(query);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFocused]);
// Validate query when it changes externally (from queryData)
useEffect(() => {
@@ -145,9 +168,6 @@ function QuerySearch({
const [showExamples] = useState(false);
const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 });
const [isFocused, setIsFocused] = useState(false);
const [
isFetchingCompleteValuesList,
setIsFetchingCompleteValuesList,
@@ -161,6 +181,9 @@ function QuerySearch({
const lastFetchedKeyRef = useRef<string>('');
const lastValueRef = useRef<string>('');
const isMountedRef = useRef<boolean>(true);
const [shouldRunQueryPostUpdate, setShouldRunQueryPostUpdate] = useState(
false,
);
const { handleRunQuery } = useQueryBuilder();
@@ -206,6 +229,7 @@ function QuerySearch({
return (): void => clearTimeout(timeoutId);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isFocused],
);
@@ -219,6 +243,11 @@ function QuerySearch({
return;
}
if (hardcodedAttributeKeys) {
setKeySuggestions(hardcodedAttributeKeys);
return;
}
lastFetchedKeyRef.current = searchText || '';
const response = await getKeySuggestions({
@@ -254,6 +283,7 @@ function QuerySearch({
toggleSuggestions,
queryData.aggregateAttribute?.key,
signalSource,
hardcodedAttributeKeys,
],
);
@@ -545,7 +575,6 @@ function QuerySearch({
const handleChange = (value: string): void => {
setQuery(value);
onChange(value);
// Mark as internal change to avoid triggering external validation
setIsExternalQueryChange(false);
// Update lastExternalQuery to prevent external validation trigger
@@ -1209,6 +1238,25 @@ function QuerySearch({
</div>
);
// Effect to handle query run after update
useEffect(
() => {
// Only run the query post updating the filter expression.
// This runs the query in the next update cycle of react, when it's guaranteed that the query is updated.
// Because both the things are sequential and react batches the updates so it was still taking the old query.
if (shouldRunQueryPostUpdate) {
if (onRun && typeof onRun === 'function') {
onRun(query);
} else {
handleRunQuery();
}
setShouldRunQueryPostUpdate(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[shouldRunQueryPostUpdate, handleRunQuery, onRun],
);
return (
<div className="code-mirror-where-clause">
{editingMode && (
@@ -1283,6 +1331,7 @@ function QuerySearch({
theme={isDarkMode ? copilot : githubLight}
onChange={handleChange}
onUpdate={handleUpdate}
data-testid="query-where-clause-editor"
className={cx('query-where-clause-editor', {
isValid: validation.isValid === true,
hasErrors: validation.errors.length > 0,
@@ -1319,11 +1368,14 @@ function QuerySearch({
// and instead run a custom action
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
onRun(query);
} else {
handleRunQuery();
if (
onChange &&
typeof onChange === 'function' &&
query !== queryData.filter?.expression
) {
onChange(query);
}
setShouldRunQueryPostUpdate(true);
return true;
},
},
@@ -1336,14 +1388,19 @@ function QuerySearch({
]),
),
]}
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
placeholder={placeholder}
basicSetup={{
lineNumbers: false,
}}
onFocus={(): void => {
setIsFocused(true);
setHasInteractedWithQB(true);
}}
onBlur={handleBlur}
onCreateEditor={(view: EditorView): EditorView => {
editorRef.current = view;
return view;
}}
/>
{query && validation.isValid === false && !isFocused && (
@@ -1483,6 +1540,9 @@ function QuerySearch({
QuerySearch.defaultProps = {
onRun: undefined,
signalSource: '',
hardcodedAttributeKeys: undefined,
placeholder:
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
};
export default QuerySearch;

View File

@@ -0,0 +1,380 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable import/named */
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { initialQueriesMap } from 'constants/queryBuilder';
import * as UseQBModule from 'hooks/queryBuilder/useQueryBuilder';
import React from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import type { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { DataSource } from 'types/common/queryBuilder';
import QuerySearch from '../QuerySearch/QuerySearch';
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): { selectedDashboard: undefined } => ({
selectedDashboard: undefined,
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => {
const handleRunQuery = jest.fn();
return {
__esModule: true,
useQueryBuilder: (): { handleRunQuery: () => void } => ({ handleRunQuery }),
handleRunQuery,
};
});
jest.mock('@codemirror/autocomplete', () => ({
autocompletion: (): Record<string, unknown> => ({}),
closeCompletion: (): boolean => true,
completionKeymap: [] as unknown[],
startCompletion: (): boolean => true,
}));
jest.mock('@codemirror/lang-javascript', () => ({
javascript: (): Record<string, unknown> => ({}),
}));
jest.mock('@uiw/codemirror-theme-copilot', () => ({
copilot: {},
}));
jest.mock('@uiw/codemirror-theme-github', () => ({
githubLight: {},
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn().mockResolvedValue({
data: {
data: { keys: {} as Record<string, QueryKeyDataSuggestionsProps[]> },
},
}),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn().mockResolvedValue({
data: { data: { values: { stringValues: [], numberValues: [] } } },
}),
}));
// Mock CodeMirror to a simple textarea to make it testable and call onUpdate
jest.mock(
'@uiw/react-codemirror',
(): Record<string, unknown> => {
// Minimal EditorView shape used by the component
class EditorViewMock {}
(EditorViewMock as any).domEventHandlers = (): unknown => ({} as unknown);
(EditorViewMock as any).lineWrapping = {} as unknown;
(EditorViewMock as any).editable = { of: () => ({}) } as unknown;
const keymap = { of: (arr: unknown) => arr } as unknown;
const Prec = { highest: (ext: unknown) => ext } as unknown;
type CodeMirrorProps = {
value?: string;
onChange?: (v: string) => void;
onFocus?: () => void;
onBlur?: () => void;
placeholder?: string;
onCreateEditor?: (view: unknown) => unknown;
onUpdate?: (arg: {
view: {
state: {
selection: { main: { head: number } };
doc: {
toString: () => string;
lineAt: (
_pos: number,
) => { number: number; from: number; to: number; text: string };
};
};
};
}) => void;
'data-testid'?: string;
extensions?: unknown[];
};
function CodeMirrorMock({
value,
onChange,
onFocus,
onBlur,
placeholder,
onCreateEditor,
onUpdate,
'data-testid': dataTestId,
extensions,
}: CodeMirrorProps): JSX.Element {
const [localValue, setLocalValue] = React.useState<string>(value ?? '');
// Provide a fake editor instance
React.useEffect(() => {
if (onCreateEditor) {
onCreateEditor(new EditorViewMock() as any);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Call onUpdate whenever localValue changes to simulate cursor and doc
React.useEffect(() => {
if (onUpdate) {
const text = String(localValue ?? '');
const head = text.length;
onUpdate({
view: {
state: {
selection: { main: { head } },
doc: {
toString: (): string => text,
lineAt: () => ({
number: 1,
from: 0,
to: text.length,
text,
}),
},
},
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localValue]);
const handleKeyDown = (
e: React.KeyboardEvent<HTMLTextAreaElement>,
): void => {
const isModEnter = e.key === 'Enter' && (e.metaKey || e.ctrlKey);
if (!isModEnter) return;
const exts: unknown[] = Array.isArray(extensions) ? extensions : [];
const flat: unknown[] = exts.flatMap((x: unknown) =>
Array.isArray(x) ? x : [x],
);
const keyBindings = flat.filter(
(x) =>
Boolean(x) &&
typeof x === 'object' &&
'key' in (x as Record<string, unknown>),
) as Array<{ key?: string; run?: () => boolean | void }>;
keyBindings
.filter((b) => b.key === 'Mod-Enter' && typeof b.run === 'function')
.forEach((b) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
b.run!();
});
};
return (
<textarea
data-testid={dataTestId || 'query-where-clause-editor'}
placeholder={placeholder}
value={localValue}
onChange={(e): void => {
setLocalValue(e.target.value);
if (onChange) {
onChange(e.target.value);
}
}}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={handleKeyDown}
style={{ width: '100%', minHeight: 80 }}
/>
);
}
return {
__esModule: true,
default: CodeMirrorMock,
EditorView: EditorViewMock,
keymap,
Prec,
};
},
);
const handleRunQueryMock = ((UseQBModule as unknown) as {
handleRunQuery: jest.MockedFunction<() => void>;
}).handleRunQuery;
const PLACEHOLDER_TEXT =
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')";
const TESTID_EDITOR = 'query-where-clause-editor';
const SAMPLE_KEY_TYPING = 'http.';
const SAMPLE_VALUE_TYPING_INCOMPLETE = " service.name = '";
const SAMPLE_VALUE_TYPING_COMPLETE = " service.name = 'frontend'";
const SAMPLE_STATUS_QUERY = " status_code = '200'";
describe('QuerySearch', () => {
it('renders with placeholder', () => {
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
/>,
);
expect(screen.getByPlaceholderText(PLACEHOLDER_TEXT)).toBeInTheDocument();
});
it('calls onChange on blur after user edits', async () => {
const handleChange = jest.fn() as jest.MockedFunction<(v: string) => void>;
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
onChange={handleChange}
queryData={initialQueriesMap.metrics.builder.queryData[0]}
dataSource={DataSource.METRICS}
/>,
);
const editor = screen.getByTestId(TESTID_EDITOR);
await user.click(editor);
await user.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
// Blur triggers validation + onChange (only if focused at least once and value changed)
editor.blur();
await waitFor(() => expect(handleChange).toHaveBeenCalledTimes(1));
expect(handleChange.mock.calls[0][0]).toContain("service.name = 'frontend'");
});
it('fetches key suggestions when typing a key (debounced)', async () => {
jest.useFakeTimers();
const advance = (ms: number): void => {
jest.advanceTimersByTime(ms);
};
const user = userEvent.setup({
advanceTimers: advance,
pointerEventsCheck: 0,
});
const mockedGetKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions
>;
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
/>,
);
const editor = screen.getByTestId(TESTID_EDITOR);
await user.type(editor, SAMPLE_KEY_TYPING);
advance(1000);
await waitFor(() => expect(mockedGetKeys).toHaveBeenCalled(), {
timeout: 3000,
});
jest.useRealTimers();
});
it('fetches value suggestions when editing value context', async () => {
jest.useFakeTimers();
const advance = (ms: number): void => {
jest.advanceTimersByTime(ms);
};
const user = userEvent.setup({
advanceTimers: advance,
pointerEventsCheck: 0,
});
const mockedGetValues = getValueSuggestions as jest.MockedFunction<
typeof getValueSuggestions
>;
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
/>,
);
const editor = screen.getByTestId(TESTID_EDITOR);
await user.type(editor, SAMPLE_VALUE_TYPING_INCOMPLETE);
advance(1000);
await waitFor(() => expect(mockedGetValues).toHaveBeenCalled(), {
timeout: 3000,
});
jest.useRealTimers();
});
it('fetches key suggestions on mount for LOGS', async () => {
jest.useFakeTimers();
const mockedGetKeysOnMount = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions
>;
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
/>,
);
jest.advanceTimersByTime(1000);
await waitFor(() => expect(mockedGetKeysOnMount).toHaveBeenCalled(), {
timeout: 3000,
});
const lastArgs = mockedGetKeysOnMount.mock.calls[
mockedGetKeysOnMount.mock.calls.length - 1
]?.[0] as { signal: unknown; searchText: string };
expect(lastArgs).toMatchObject({ signal: DataSource.LOGS, searchText: '' });
jest.useRealTimers();
});
it('calls provided onRun on Mod-Enter', async () => {
const onRun = jest.fn() as jest.MockedFunction<(q: string) => void>;
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
onRun={onRun}
/>,
);
const editor = screen.getByTestId(TESTID_EDITOR);
await user.click(editor);
await user.type(editor, SAMPLE_STATUS_QUERY);
await user.keyboard('{Meta>}{Enter}{/Meta}');
await waitFor(() => expect(onRun).toHaveBeenCalled());
});
it('calls handleRunQuery when Mod-Enter without onRun', async () => {
const mockedHandleRunQuery = handleRunQueryMock as jest.MockedFunction<
() => void
>;
mockedHandleRunQuery.mockClear();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
/>,
);
const editor = screen.getByTestId(TESTID_EDITOR);
await user.click(editor);
await user.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
await user.keyboard('{Meta>}{Enter}{/Meta}');
await waitFor(() => expect(mockedHandleRunQuery).toHaveBeenCalled());
});
});

View File

@@ -1,4 +1,5 @@
import { Tabs, TabsProps } from 'antd';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import {
generatePath,
matchPath,
@@ -17,6 +18,7 @@ function RouteTab({
activeKey,
onChangeHandler,
history,
showRightSection,
...rest
}: RouteTabProps & TabsProps): JSX.Element {
const params = useParams<Params>();
@@ -61,12 +63,22 @@ function RouteTab({
items={items}
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
tabBarExtraContent={
showRightSection && (
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
)
}
/>
);
}
RouteTab.defaultProps = {
onChangeHandler: undefined,
showRightSection: true,
};
export default RouteTab;

View File

@@ -13,4 +13,5 @@ export interface RouteTabProps {
activeKey: TabsProps['activeKey'];
onChangeHandler?: (key: string) => void;
history: History<unknown>;
showRightSection: boolean;
}

View File

@@ -18,6 +18,11 @@ import UPlot from 'uplot';
import { dataMatch, optionsUpdateState } from './utils';
// Extended uPlot interface with custom properties
interface ExtendedUPlot extends uPlot {
_legendScrollCleanup?: () => void;
}
export interface UplotProps {
options: uPlot.Options;
data: uPlot.AlignedData;
@@ -66,6 +71,12 @@ const Uplot = forwardRef<ToggleGraphProps | undefined, UplotProps>(
const destroy = useCallback((chart: uPlot | null) => {
if (chart) {
// Clean up legend scroll event listener
const extendedChart = chart as ExtendedUPlot;
if (extendedChart._legendScrollCleanup) {
extendedChart._legendScrollCleanup();
}
onDeleteRef.current?.(chart);
chart.destroy();
chartRef.current = null;

View File

@@ -125,7 +125,7 @@ export const queryFunctionsTypesConfig: QueryFunctionConfigType = {
log10: {
showInput: false,
},
cumSum: {
cumulativeSum: {
showInput: false,
},
ewma3: {

View File

@@ -157,9 +157,12 @@ function DomainDetails({
<div className="domain-details-drawer-header">
<div className="domain-details-drawer-header-title">
<Divider type="vertical" />
<Typography.Text className="title">
{domainData.domainName}
</Typography.Text>
{domainData?.domainName && (
<Typography.Text className="title">
{domainData.domainName}
</Typography.Text>
)}
</div>
<div className="domain-details-drawer-header-right-container">
<DateTimeSelectionV2

View File

@@ -2,36 +2,29 @@ import '../Explorer.styles.scss';
import { LoadingOutlined } from '@ant-design/icons';
import { Spin, Table, Typography } from 'antd';
import axios from 'api';
import logEvent from 'api/common/logEvent';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import cx from 'classnames';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { initialQueriesMap } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import Toolbar from 'container/Toolbar/Toolbar';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useListOverview } from 'hooks/thirdPartyApis/useListOverview';
import { get } from 'lodash-es';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { ApiMonitoringHardcodedAttributeKeys } from '../../constants';
import { DEFAULT_PARAMS, useApiMonitoringParams } from '../../queryParams';
import {
columnsConfig,
formatDataForTable,
hardcodedAttributeKeys,
} from '../../utils';
import { columnsConfig, formatDataForTable } from '../../utils';
import DomainDetails from './DomainDetails/DomainDetails';
function DomainList(): JSX.Element {
@@ -53,6 +46,21 @@ function DomainList(): JSX.Element {
entityVersion: '',
});
const compositeData = useGetCompositeQueryParam();
const { data, isLoading, isFetching } = useListOverview({
start: minTime,
end: maxTime,
show_ip: Boolean(showIP),
filter: {
expression: `kind_string = 'Client' ${get(
compositeData,
'builder.queryData[0].filter.expression',
'',
)}`,
},
});
// initialise tab with default query.
useShareBuilderUrl({
defaultValue: {
@@ -74,63 +82,21 @@ function DomainList(): JSX.Element {
},
});
const compositeData = useGetCompositeQueryParam();
const handleChangeTagFilters = useCallback(
(value: IBuilderQuery['filters']) => {
handleChangeQueryData('filters', value);
const handleSearchChange = useCallback(
(value: string) => {
(handleChangeQueryData as HandleChangeQueryDataV5)('filter', {
expression: value,
});
},
[handleChangeQueryData],
);
const fetchApiOverview = async (): Promise<
SuccessResponse<any> | ErrorResponse
> => {
const requestBody = {
start: minTime,
end: maxTime,
show_ip: showIP,
filters: {
op: 'AND',
items: [
{
id: '212678b9',
key: {
key: 'kind_string',
dataType: 'string',
type: '',
},
op: '=',
value: 'Client',
},
...(compositeData?.builder?.queryData[0]?.filters?.items || []),
],
},
};
try {
const response = await axios.post(
'/third-party-apis/overview/list',
requestBody,
);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
const { data, isLoading, isFetching } = useQuery(
[REACT_QUERY_KEY.GET_DOMAINS_LIST, minTime, maxTime, compositeData, showIP],
fetchApiOverview,
);
const formattedDataForTable = useMemo(
() => formatDataForTable(data?.payload?.data?.result[0]?.table?.rows),
() =>
formatDataForTable(
data?.data?.data?.data.results[0]?.data || [],
data?.data?.data?.data.results[0]?.columns || [],
),
[data],
);
@@ -150,13 +116,13 @@ function DomainList(): JSX.Element {
showAutoRefresh={false}
rightActions={<RightToolbarActions onStageRunQuery={handleRunQuery} />}
/>
{/* add bottom border here */}
<div className={cx('api-monitoring-list-header')}>
<QueryBuilderSearchV2
query={query}
onChange={handleChangeTagFilters}
placeholder="Search filters..."
hardcodedAttributeKeys={hardcodedAttributeKeys}
<QuerySearch
dataSource={DataSource.TRACES}
queryData={query}
onChange={handleSearchChange}
placeholder="Enter your filter query (e.g., deployment.environment = 'otel-demo' AND service.name = 'frontend')"
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
/>
</div>
<Table

View File

@@ -3,10 +3,11 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from './Explorer/Domains/DomainDetails/constants';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
import {
endPointStatusCodeColumns,
extractPortAndEndpoint,
formatDataForTable,
formatTopErrorsDataForTable,
getAllEndpointsWidgetData,
getCustomFiltersForBarChart,
@@ -24,7 +25,8 @@ import {
getTopErrorsCoRelationQueryFilters,
getTopErrorsQueryPayload,
TopErrorsResponseRow,
} from './utils';
} from '../utils';
import { APIMonitoringColumnsMock } from './mock';
// Mock or define DataTypes since it seems to be missing from imports
const DataTypes = {
@@ -34,9 +36,9 @@ const DataTypes = {
};
// Mock the external utils dependencies that are used within our tested functions
jest.mock('./utils', () => {
jest.mock('../utils', () => {
// Import the actual module to partial mock
const originalModule = jest.requireActual('./utils');
const originalModule = jest.requireActual('../utils');
// Return a mocked version
return {
@@ -157,6 +159,54 @@ describe('API Monitoring Utils', () => {
});
});
// New tests for formatDataForTable
describe('formatDataForTable', () => {
it('should format rows correctly with valid data', () => {
const columns = APIMonitoringColumnsMock;
const data = [
[
'test-domain', // domainName
'10', // endpoints
'25', // rps
'2.5', // error_rate
'15000000', // p99 (ns) -> 15 ms
'2025-09-17T12:54:17.040Z', // lastseen
],
];
const result = formatDataForTable(data as any, columns as any);
expect(result).toHaveLength(1);
expect(result[0].domainName).toBe('test-domain');
expect(result[0].endpointCount).toBe('10');
expect(result[0].rate).toBe('25');
expect(result[0].errorRate).toBe('2.5');
expect(result[0].latency).toBe(15);
expect(result[0].lastUsed).toBe('2025-09-17T12:54:17.040Z');
});
it('should handle n/a and undefined values', () => {
const columns = APIMonitoringColumnsMock;
const data = [
[
'test-domain',
'n/a', // endpoints -> 0
'n/a', // rps -> '-'
'n/a', // error_rate -> 0
'n/a', // p99 -> '-'
'n/a', // lastseen -> '-'
],
];
const result = formatDataForTable(data as any, columns as any);
expect(result[0].endpointCount).toBe(0);
expect(result[0].rate).toBe('-');
expect(result[0].errorRate).toBe(0);
expect(result[0].latency).toBe('-');
expect(result[0].lastUsed).toBe('-');
});
});
describe('getGroupByFiltersFromGroupByValues', () => {
it('should convert row data to filters correctly', () => {
// Arrange
@@ -1288,7 +1338,7 @@ describe('API Monitoring Utils', () => {
// Setup a mock
jest
.spyOn(
jest.requireActual('./utils'),
jest.requireActual('../utils'),
'getFormattedEndPointStatusCodeChartData',
)
.mockReturnValue({

View File

@@ -0,0 +1,65 @@
import { domainNameKey } from '../constants';
import { APIMonitoringResponseColumn } from '../types';
export const APIMonitoringColumnsMock: APIMonitoringResponseColumn[] = [
{
name: domainNameKey,
signal: 'traces',
fieldContext: '',
fieldDataType: 'string',
queryName: '',
aggregationIndex: 0,
meta: {},
columnType: 'attribute',
},
{
name: 'endpoints',
signal: 'traces',
fieldContext: '',
fieldDataType: 'number',
queryName: 'endpoints',
aggregationIndex: 0,
meta: {},
columnType: 'metric',
},
{
name: 'rps',
signal: 'traces',
fieldContext: '',
fieldDataType: 'number',
queryName: 'rps',
aggregationIndex: 0,
meta: {},
columnType: 'metric',
},
{
name: 'error_rate',
signal: 'traces',
fieldContext: '',
fieldDataType: 'number',
queryName: 'error_rate',
aggregationIndex: 0,
meta: {},
columnType: 'metric',
},
{
name: 'p99',
signal: 'traces',
fieldContext: '',
fieldDataType: 'number',
queryName: 'p99',
aggregationIndex: 0,
meta: {},
columnType: 'metric',
},
{
name: 'lastseen',
signal: 'traces',
fieldContext: '',
fieldDataType: 'number',
queryName: 'lastseen',
aggregationIndex: 0,
meta: {},
columnType: 'metric',
},
];

View File

@@ -0,0 +1,30 @@
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { SPAN_ATTRIBUTES } from './Explorer/Domains/DomainDetails/constants';
export const ApiMonitoringHardcodedAttributeKeys: QueryKeyDataSuggestionsProps[] = [
{
label: 'deployment.environment',
type: 'resource',
name: 'deployment.environment',
signal: 'traces',
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
},
{
label: 'service.name',
type: 'resource',
name: 'service.name',
signal: 'traces',
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
},
{
label: 'rpc.method',
type: 'tag',
name: 'rpc.method',
signal: 'traces',
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
},
];
export const domainNameKey = SPAN_ATTRIBUTES.SERVER_NAME;

View File

@@ -0,0 +1,39 @@
import { domainNameKey } from './constants';
export interface APIMonitoringResponseRow {
data: {
endpoints: number | string;
error_rate: number | string;
lastseen: number | string;
[domainNameKey]: string;
p99: number | string;
rps: number | string;
};
}
export interface APIMonitoringResponseColumn {
name: string;
signal: string;
fieldContext: string;
fieldDataType: string;
queryName: string;
aggregationIndex: number;
meta: Record<string, any>;
columnType: string;
}
export interface EndPointsResponseRow {
data: {
[key: string]: string | number | undefined;
};
}
export interface APIDomainsRowData {
key: string;
domainName: string;
endpointCount: number | string;
rate: number | string;
errorRate: number | string;
latency: number | string;
lastUsed: string;
}

View File

@@ -32,7 +32,13 @@ import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { domainNameKey } from './constants';
import { SPAN_ATTRIBUTES } from './Explorer/Domains/DomainDetails/constants';
import {
APIDomainsRowData,
APIMonitoringResponseColumn,
EndPointsResponseRow,
} from './types';
export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
{
@@ -243,84 +249,47 @@ export const columnsConfig: ColumnType<APIDomainsRowData>[] = [
},
];
// Rename this to a proper name
export const hardcodedAttributeKeys: BaseAutocompleteData[] = [
{
key: 'deployment.environment',
dataType: DataTypes.String,
type: 'resource',
},
{
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
{
key: 'rpc.method',
dataType: DataTypes.String,
type: 'tag',
},
];
const domainNameKey = SPAN_ATTRIBUTES.SERVER_NAME;
interface APIMonitoringResponseRow {
data: {
endpoints: number | string;
error_rate: number | string;
lastseen: number | string;
[domainNameKey]: string;
p99: number | string;
rps: number | string;
};
}
interface EndPointsResponseRow {
data: {
[key: string]: string | number | undefined;
};
}
export interface APIDomainsRowData {
key: string;
domainName: string;
endpointCount: number | string;
rate: number | string;
errorRate: number | string;
latency: number | string;
lastUsed: string;
}
// Rename this to a proper name
export const formatDataForTable = (
data: APIMonitoringResponseRow[],
): APIDomainsRowData[] =>
data?.map((domain) => ({
key: v4(),
domainName: domain?.data[domainNameKey] || '-',
endpointCount:
domain?.data?.endpoints === 'n/a' || domain?.data?.endpoints === undefined
? 0
: domain?.data?.endpoints,
rate:
domain?.data?.rps === 'n/a' || domain?.data?.rps === undefined
? '-'
: domain?.data?.rps,
errorRate:
domain?.data?.error_rate === 'n/a' || domain?.data?.error_rate === undefined
? 0
: domain?.data?.error_rate,
latency:
domain?.data?.p99 === 'n/a' || domain?.data?.p99 === undefined
? '-'
: Math.round(Number(domain?.data?.p99) / 1000000), // Convert from nanoseconds to milliseconds
lastUsed:
domain?.data?.lastseen === 'n/a' || domain?.data?.lastseen === undefined
? '-'
: new Date(
Math.floor(Number(domain?.data?.lastseen) / 1000000),
).toISOString(), // Convert from nanoseconds to milliseconds
}));
data: string[][],
columns: APIMonitoringResponseColumn[],
): APIDomainsRowData[] => {
const indexMap = columns.reduce((acc, column, index) => {
if (column.name === domainNameKey) {
acc[column.name] = index;
} else {
acc[column.queryName] = index;
}
return acc;
}, {} as Record<string, number>);
return data.map((row) => {
const rowData: APIDomainsRowData = {
key: v4(),
domainName: row[indexMap[domainNameKey]],
endpointCount:
row[indexMap.endpoints] === 'n/a' || row[indexMap.endpoints] === undefined
? 0
: row[indexMap.endpoints],
rate:
row[indexMap.rps] === 'n/a' || row[indexMap.rps] === undefined
? '-'
: row[indexMap.rps],
errorRate:
row[indexMap.error_rate] === 'n/a' || row[indexMap.error_rate] === undefined
? 0
: row[indexMap.error_rate],
latency:
row[indexMap.p99] === 'n/a' || row[indexMap.p99] === undefined
? '-'
: Math.round(Number(row[indexMap.p99]) / 1000000),
lastUsed:
row[indexMap.lastseen] === 'n/a' || row[indexMap.lastseen] === undefined
? '-'
: new Date(row[indexMap.lastseen]).toISOString(),
};
return rowData;
});
};
export const getDomainMetricsQueryPayload = (
domainName: string,

View File

@@ -6,13 +6,16 @@ import { Activity, ChartLine } from 'lucide-react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { useCreateAlertState } from '../context';
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
import Stepper from '../Stepper';
import { showCondensedLayout } from '../utils';
import AlertThreshold from './AlertThreshold';
import AnomalyThreshold from './AnomalyThreshold';
import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants';
function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
const showCondensedLayoutFlag = showCondensedLayout();
const showMultipleTabs =
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
@@ -75,6 +78,11 @@ function AlertCondition(): JSX.Element {
</div>
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && <AlertThreshold />}
{alertType === AlertTypes.ANOMALY_BASED_ALERT && <AnomalyThreshold />}
{showCondensedLayoutFlag ? (
<div className="condensed-advanced-options-container">
<AdvancedOptions />
</div>
) : null}
</div>
);
}

View File

@@ -2,6 +2,7 @@ import './styles.scss';
import { Button, Select, Typography } from 'antd';
import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Plus } from 'lucide-react';
import { useQuery } from 'react-query';
@@ -17,6 +18,8 @@ import {
THRESHOLD_MATCH_TYPE_OPTIONS,
THRESHOLD_OPERATOR_OPTIONS,
} from '../context/constants';
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
import { showCondensedLayout } from '../utils';
import ThresholdItem from './ThresholdItem';
import { UpdateThreshold } from './types';
import {
@@ -37,6 +40,7 @@ function AlertThreshold(): JSX.Element {
>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const showCondensedLayoutFlag = showCondensedLayout();
const channels = data?.data || [];
const { currentQuery } = useQueryBuilder();
@@ -81,8 +85,18 @@ function AlertThreshold(): JSX.Element {
});
};
const evaluationWindowContext = showCondensedLayoutFlag ? (
<EvaluationSettings />
) : (
<strong>Evaluation Window.</strong>
);
return (
<div className="alert-threshold-container">
<div
className={classNames('alert-threshold-container', {
'condensed-alert-threshold-container': showCondensedLayoutFlag,
})}
>
{/* Main condition sentence */}
<div className="alert-condition-sentences">
<div className="alert-condition-sentence">
@@ -128,7 +142,7 @@ function AlertThreshold(): JSX.Element {
options={THRESHOLD_MATCH_TYPE_OPTIONS}
/>
<Typography.Text className="sentence-text">
during the <strong>Evaluation Window.</strong>
during the {evaluationWindowContext}
</Typography.Text>
</div>
</div>

View File

@@ -84,6 +84,9 @@
color: var(--text-vanilla-400);
font-size: 14px;
line-height: 1.5;
display: flex;
align-items: center;
gap: 8px;
}
.ant-select {
@@ -275,3 +278,43 @@
}
}
}
.condensed-alert-threshold-container,
.condensed-anomaly-threshold-container {
width: 100%;
}
.condensed-advanced-options-container {
margin-top: 16px;
width: fit-parent;
}
.condensed-evaluation-settings-container {
.ant-btn {
display: flex;
align-items: center;
width: 240px;
justify-content: space-between;
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
.evaluate-alert-conditions-button-left {
color: var(--bg-vanilla-400);
font-size: 12px;
}
.evaluate-alert-conditions-button-right {
display: flex;
align-items: center;
color: var(--bg-vanilla-400);
gap: 8px;
.evaluate-alert-conditions-button-right-text {
font-size: 12px;
font-weight: 500;
background-color: var(--bg-slate-400);
padding: 1px 4px;
}
}
}
}

View File

@@ -7,7 +7,10 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
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';
function CreateAlertV2({
initialQuery = initialQueriesMap.metrics,
@@ -16,14 +19,18 @@ function CreateAlertV2({
}): JSX.Element {
useShareBuilderUrl({ defaultValue: initialQuery });
const showCondensedLayoutFlag = showCondensedLayout();
return (
<div className="create-alert-v2-container">
<CreateAlertProvider>
<CreateAlertProvider>
<div className="create-alert-v2-container">
<CreateAlertHeader />
<QuerySection />
<AlertCondition />
</CreateAlertProvider>
</div>
{!showCondensedLayoutFlag ? <EvaluationSettings /> : null}
<NotificationSettings />
</div>
</CreateAlertProvider>
);
}

View File

@@ -0,0 +1,51 @@
import './styles.scss';
import { Switch, Tooltip, Typography } from 'antd';
import { Info } from 'lucide-react';
import { useState } from 'react';
import { IAdvancedOptionItemProps } from '../types';
function AdvancedOptionItem({
title,
description,
input,
tooltipText,
onToggle,
}: IAdvancedOptionItemProps): JSX.Element {
const [showInput, setShowInput] = useState<boolean>(false);
const handleOnToggle = (): void => {
onToggle?.();
setShowInput((currentShowInput) => !currentShowInput);
};
return (
<div className="advanced-option-item">
<div className="advanced-option-item-left-content">
<Typography.Text className="advanced-option-item-title">
{title}
{tooltipText && (
<Tooltip title={tooltipText}>
<Info data-testid="tooltip-icon" size={16} />
</Tooltip>
)}
</Typography.Text>
<Typography.Text className="advanced-option-item-description">
{description}
</Typography.Text>
</div>
<div className="advanced-option-item-right-content">
<div
className="advanced-option-item-input"
style={{ display: showInput ? 'block' : 'none' }}
>
{input}
</div>
<Switch onChange={handleOnToggle} />
</div>
</div>
);
}
export default AdvancedOptionItem;

View File

@@ -0,0 +1,3 @@
import AdvancedOptionItem from './AdvancedOptionItem';
export default AdvancedOptionItem;

View File

@@ -0,0 +1,250 @@
.advanced-option-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px;
border-bottom: 1px solid var(--bg-slate-500);
.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;
display: flex;
align-items: center;
gap: 8px;
}
.advanced-option-item-description {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-weight: 400;
}
.advanced-option-item-input {
margin-top: 16px;
.ant-input {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
height: 32px;
&::placeholder {
font-family: 'Space Mono';
}
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
}
.ant-select {
.ant-select-selector {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
height: 32px;
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
.ant-select-selection-placeholder {
font-family: 'Space Mono';
}
}
.ant-select-selection-item {
color: var(--bg-vanilla-100);
}
.ant-select-arrow {
color: var(--bg-vanilla-400);
}
}
}
}
.advanced-option-item-right-content {
display: flex;
align-items: flex-start;
gap: 16px;
.advanced-option-item-input-group {
display: flex;
align-items: center;
gap: 8px;
.ant-input {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
}
.ant-select {
.ant-select-selector {
background-color: var(--bg-ink-400);
color: var(--bg-vanilla-100);
height: 32px;
border: 1px solid var(--bg-slate-400);
.ant-select-selection-placeholder {
font-family: 'Space Mono';
}
}
.ant-select-selection-item {
color: var(--bg-vanilla-100);
}
.ant-select-arrow {
color: var(--bg-vanilla-400);
}
}
}
.advanced-option-item-button {
display: flex;
align-items: center;
gap: 8px;
background-color: var(--bg-ink-200);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-400);
border-radius: 4px;
}
}
}
.lightMode {
.advanced-option-item {
border-bottom: 1px solid var(--bg-vanilla-300);
.advanced-option-item-left-content {
.advanced-option-item-title {
color: var(--bg-ink-300);
}
.advanced-option-item-description {
color: var(--bg-ink-400);
}
.advanced-option-item-input {
.ant-input {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&: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);
}
}
.ant-select {
.ant-select-selector {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&: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);
}
}
.ant-select-selection-item {
color: var(--bg-ink-400);
}
.ant-select-arrow {
color: var(--bg-ink-400);
}
}
}
}
.advanced-option-item-right-content {
.advanced-option-item-input-group {
.ant-input {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&: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);
}
}
.ant-select {
.ant-select-selector {
background-color: var(--bg-vanilla-200);
color: var(--bg-ink-400);
border: 1px solid var(--bg-vanilla-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);
}
}
.ant-select-selection-item {
color: var(--bg-ink-400);
}
.ant-select-arrow {
color: var(--bg-ink-400);
}
}
}
.advanced-option-item-button {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
}
}
}

View File

@@ -0,0 +1,129 @@
import { Collapse, Input, Select, Typography } from 'antd';
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
import { useCreateAlertState } from '../context';
import AdvancedOptionItem from './AdvancedOptionItem';
import EvaluationCadence from './EvaluationCadence';
function AdvancedOptions(): JSX.Element {
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
const timeOptions = Y_AXIS_CATEGORIES.find(
(category) => category.name === 'Time',
)?.units.map((unit) => ({ label: unit.name, value: unit.id }));
return (
<div className="advanced-options-container">
<Collapse bordered={false}>
<Collapse.Panel header="ADVANCED OPTIONS" key="1">
<EvaluationCadence />
<AdvancedOptionItem
title="Alert when data stops coming"
description="Send notification if no data is received for a specified time period."
tooltipText="Useful for monitoring data pipelines or services that should continuously send data. For example, alert if no logs are received for 10 minutes"
input={
<div className="advanced-option-item-input-group">
<Input
placeholder="Enter tolerance limit..."
type="number"
style={{ width: 100 }}
onChange={(e): void =>
setAdvancedOptions({
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
payload: {
toleranceLimit: Number(e.target.value),
timeUnit: advancedOptions.sendNotificationIfDataIsMissing.timeUnit,
},
})
}
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
/>
<Select
style={{ width: 120 }}
options={timeOptions}
placeholder="Select time unit"
onChange={(value): void =>
setAdvancedOptions({
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
payload: {
toleranceLimit:
advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit,
timeUnit: value as string,
},
})
}
value={advancedOptions.sendNotificationIfDataIsMissing.timeUnit}
/>
</div>
}
/>
<AdvancedOptionItem
title="Minimum data required"
description="Only trigger alert when there are enough data points to make a reliable decision."
tooltipText="Prevents false alarms when there's insufficient data. For example, require at least 5 data points before checking if CPU usage is above 80%."
input={
<div className="advanced-option-item-input-group">
<Input
placeholder="Enter minimum datapoints..."
style={{ width: 100 }}
type="number"
onChange={(e): void =>
setAdvancedOptions({
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS',
payload: {
minimumDatapoints: Number(e.target.value),
},
})
}
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
/>
<Typography.Text>Datapoints</Typography.Text>
</div>
}
/>
<AdvancedOptionItem
title="Account for data delay"
description="Shift the evaluation window backwards to account for data processing delays."
tooltipText="Use when your data takes time to arrive on the platform. For example, if logs typically arrive 5 minutes late, set a 5-minute delay so the alert checks the correct time window."
input={
<div className="advanced-option-item-input-group">
<Input
placeholder="Enter delay..."
style={{ width: 100 }}
type="number"
onChange={(e): void =>
setAdvancedOptions({
type: 'SET_DELAY_EVALUATION',
payload: {
delay: Number(e.target.value),
timeUnit: advancedOptions.delayEvaluation.timeUnit,
},
})
}
value={advancedOptions.delayEvaluation.delay}
/>
<Select
style={{ width: 120 }}
options={timeOptions}
placeholder="Select time unit"
onChange={(value): void =>
setAdvancedOptions({
type: 'SET_DELAY_EVALUATION',
payload: {
delay: advancedOptions.delayEvaluation.delay,
timeUnit: value as string,
},
})
}
value={advancedOptions.delayEvaluation.timeUnit}
/>
</div>
}
/>
</Collapse.Panel>
</Collapse>
</div>
);
}
export default AdvancedOptions;

View File

@@ -0,0 +1,104 @@
import { Button, Typography } from 'antd';
import { useCreateAlertState } from 'container/CreateAlertV2/context';
import { INITIAL_ADVANCED_OPTIONS_STATE } from 'container/CreateAlertV2/context/constants';
import { IEditCustomScheduleProps } from 'container/CreateAlertV2/EvaluationSettings/types';
import { Calendar1, Edit, Trash } from 'lucide-react';
import { useMemo } from 'react';
function EditCustomSchedule({
setIsEvaluationCadenceDetailsVisible,
setIsPreviewVisible,
}: IEditCustomScheduleProps): JSX.Element {
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
const displayText = useMemo(() => {
if (advancedOptions.evaluationCadence.mode === 'custom') {
return (
<Typography.Text>
<Typography.Text>Every</Typography.Text>
<Typography.Text className="highlight">
{advancedOptions.evaluationCadence.custom.repeatEvery
.charAt(0)
.toUpperCase() +
advancedOptions.evaluationCadence.custom.repeatEvery.slice(1)}
</Typography.Text>
{advancedOptions.evaluationCadence.custom.repeatEvery !== 'day' && (
<>
<Typography.Text>on</Typography.Text>
<Typography.Text className="highlight">
{advancedOptions.evaluationCadence.custom.occurence
.map(
(occurence) => occurence.charAt(0).toUpperCase() + occurence.slice(1),
)
.join(', ')}
</Typography.Text>
</>
)}
<Typography.Text>at</Typography.Text>
<Typography.Text className="highlight">
{advancedOptions.evaluationCadence.custom.startAt}
</Typography.Text>
</Typography.Text>
);
}
return (
<Typography.Text>
<Typography.Text>Starting on</Typography.Text>
<Typography.Text className="highlight">
{advancedOptions.evaluationCadence.rrule.date?.format('DD/MM/YYYY')}
</Typography.Text>
<Typography.Text>at</Typography.Text>
<Typography.Text className="highlight">
{advancedOptions.evaluationCadence.rrule.startAt}
</Typography.Text>
</Typography.Text>
);
}, [advancedOptions.evaluationCadence]);
const handleEdit = (): void => {
setIsEvaluationCadenceDetailsVisible(true);
};
const handlePreview = (): void => {
setIsPreviewVisible(true);
};
const handleDiscard = (): void => {
setIsEvaluationCadenceDetailsVisible(false);
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE',
payload: INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
});
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE_MODE',
payload: 'default',
});
};
return (
<div className="edit-custom-schedule">
{displayText}
<div className="button-row">
<Button.Group>
<Button type="default" onClick={handleEdit}>
<Edit size={12} />
<Typography.Text>Edit custom schedule</Typography.Text>
</Button>
<Button type="default" onClick={handlePreview}>
<Calendar1 size={12} />
<Typography.Text>Preview</Typography.Text>
</Button>
<Button
data-testid="discard-button"
type="default"
onClick={handleDiscard}
>
<Trash size={12} />
</Button>
</Button.Group>
</div>
</div>
);
}
export default EditCustomSchedule;

View File

@@ -0,0 +1,134 @@
import './styles.scss';
import '../AdvancedOptionItem/styles.scss';
import { Button, Input, Select, Tooltip, Typography } from 'antd';
import { Info, Plus } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useCreateAlertState } from '../../context';
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../../context/constants';
import EditCustomSchedule from './EditCustomSchedule';
import EvaluationCadenceDetails from './EvaluationCadenceDetails';
import EvaluationCadencePreview from './EvaluationCadencePreview';
function EvaluationCadence(): JSX.Element {
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
const [
isEvaluationCadenceDetailsVisible,
setIsEvaluationCadenceDetailsVisible,
] = useState(false);
const [
isCustomScheduleButtonVisible,
setIsCustomScheduleButtonVisible,
] = useState(true);
const [
isEvaluationCadencePreviewVisible,
setIsEvaluationCadencePreviewVisible,
] = useState(false);
const [isEditCustomScheduleVisible, setIsEditCustomScheduleVisible] = useState(
() => advancedOptions.evaluationCadence.mode !== 'default',
);
useEffect(() => {
setIsEditCustomScheduleVisible(
advancedOptions.evaluationCadence.mode !== 'default',
);
}, [advancedOptions.evaluationCadence.mode]);
const showCustomSchedule = (): void => {
setIsEvaluationCadenceDetailsVisible(true);
setIsCustomScheduleButtonVisible(false);
};
return (
<div className="evaluation-cadence-container">
<div className="advanced-option-item evaluation-cadence-item">
<div className="advanced-option-item-left-content">
<Typography.Text className="advanced-option-item-title">
How often to check
<Tooltip title="Controls how frequently the alert evaluates your conditions. For most alerts, 1-5 minutes is sufficient.">
<Info data-testid="evaluation-cadence-tooltip-icon" size={16} />
</Tooltip>
</Typography.Text>
<Typography.Text className="advanced-option-item-description">
How frequently this alert checks your data. Default: Every 1 minute
</Typography.Text>
</div>
{isCustomScheduleButtonVisible && (
<div
className="advanced-option-item-right-content"
data-testid="evaluation-cadence-input-group"
>
<Input.Group className="advanced-option-item-input-group">
<Input
type="number"
placeholder="Enter time"
style={{ width: 180 }}
value={advancedOptions.evaluationCadence.default.value}
onChange={(value): void =>
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE',
payload: {
...advancedOptions.evaluationCadence,
default: {
...advancedOptions.evaluationCadence.default,
value: Number(value.target.value),
},
},
})
}
/>
<Select
options={ADVANCED_OPTIONS_TIME_UNIT_OPTIONS}
placeholder="Select time unit"
style={{ width: 120 }}
value={advancedOptions.evaluationCadence.default.timeUnit}
onChange={(value): void =>
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE',
payload: {
...advancedOptions.evaluationCadence,
default: {
...advancedOptions.evaluationCadence.default,
timeUnit: value,
},
},
})
}
/>
</Input.Group>
<Button
className="advanced-option-item-button"
onClick={showCustomSchedule}
>
<Plus size={12} />
<Typography.Text>Add custom schedule</Typography.Text>
</Button>
</div>
)}
</div>
{isEditCustomScheduleVisible && (
<EditCustomSchedule
setIsEvaluationCadenceDetailsVisible={setIsEvaluationCadenceDetailsVisible}
setIsPreviewVisible={setIsEvaluationCadencePreviewVisible}
/>
)}
{isEvaluationCadenceDetailsVisible && (
<EvaluationCadenceDetails
isOpen={isEvaluationCadenceDetailsVisible}
setIsOpen={setIsEvaluationCadenceDetailsVisible}
setIsCustomScheduleButtonVisible={setIsCustomScheduleButtonVisible}
/>
)}
{isEvaluationCadencePreviewVisible && (
<EvaluationCadencePreview
isOpen={isEvaluationCadencePreviewVisible}
setIsOpen={setIsEvaluationCadencePreviewVisible}
/>
)}
</div>
);
}
export default EvaluationCadence;

View File

@@ -0,0 +1,347 @@
import { Button, DatePicker, Select, Typography } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import classNames from 'classnames';
import { useCreateAlertState } from 'container/CreateAlertV2/context';
import { AdvancedOptionsState } from 'container/CreateAlertV2/context/types';
import dayjs from 'dayjs';
import { Code, Edit3Icon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import {
EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS,
EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS,
EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS,
TIMEZONE_DATA,
} from '../constants';
import TimeInput from '../TimeInput';
import { IEvaluationCadenceDetailsProps } from '../types';
import {
buildAlertScheduleFromCustomSchedule,
buildAlertScheduleFromRRule,
isValidRRule,
} from '../utils';
import { ScheduleList } from './EvaluationCadencePreview';
function EvaluationCadenceDetails({
setIsOpen,
setIsCustomScheduleButtonVisible,
}: IEvaluationCadenceDetailsProps): JSX.Element {
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
const [evaluationCadence, setEvaluationCadence] = useState<
AdvancedOptionsState['evaluationCadence']
>({
...advancedOptions.evaluationCadence,
mode: 'custom',
custom: {
...advancedOptions.evaluationCadence.custom,
startAt: dayjs().format('HH:mm:ss'),
},
rrule: {
...advancedOptions.evaluationCadence.rrule,
startAt: dayjs().format('HH:mm:ss'),
},
});
const [searchTimezoneString, setSearchTimezoneString] = useState('');
const [occurenceSearchString, setOccurenceSearchString] = useState('');
const [repeatEverySearchString, setRepeatEverySearchString] = useState('');
const tabs = [
{
label: 'Editor',
icon: <Edit3Icon size={14} />,
value: 'editor',
},
{
label: 'RRule',
icon: <Code size={14} />,
value: 'rrule',
},
];
const [activeTab, setActiveTab] = useState<'editor' | 'rrule'>(() =>
evaluationCadence.mode === 'custom' ? 'editor' : 'rrule',
);
const occurenceOptions =
evaluationCadence.custom.repeatEvery === 'week'
? EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS
: EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS;
useEffect(() => {
if (!evaluationCadence.custom.occurence.length) {
const today = new Date();
const dayOfWeek = today.getDay();
const dayOfMonth = today.getDate();
const occurence =
evaluationCadence.custom.repeatEvery === 'week'
? EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS[dayOfWeek].value
: dayOfMonth.toString();
setEvaluationCadence({
...evaluationCadence,
custom: {
...evaluationCadence.custom,
occurence: [occurence],
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [evaluationCadence.custom.repeatEvery]);
const EditorView = (
<div className="editor-view" data-testid="editor-view">
<div className="select-group">
<Typography.Text>REPEAT EVERY</Typography.Text>
<Select
options={EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS}
value={evaluationCadence.custom.repeatEvery || null}
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
custom: {
...evaluationCadence.custom,
repeatEvery: value,
occurence: [],
},
})
}
placeholder="Select repeat every"
showSearch
searchValue={repeatEverySearchString}
onSearch={setRepeatEverySearchString}
/>
</div>
{evaluationCadence.custom.repeatEvery !== 'day' && (
<div className="select-group">
<Typography.Text>ON DAY(S)</Typography.Text>
<Select
options={occurenceOptions}
value={evaluationCadence.custom.occurence || null}
mode="multiple"
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
custom: {
...evaluationCadence.custom,
occurence: value,
},
})
}
placeholder="Select day(s)"
showSearch
searchValue={occurenceSearchString}
onSearch={setOccurenceSearchString}
/>
</div>
)}
<div className="select-group">
<Typography.Text>AT</Typography.Text>
<TimeInput
value={evaluationCadence.custom.startAt}
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
custom: {
...evaluationCadence.custom,
startAt: value,
},
})
}
/>
</div>
<div className="select-group">
<Typography.Text>TIMEZONE</Typography.Text>
<Select
options={TIMEZONE_DATA}
value={evaluationCadence.custom.timezone || null}
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
custom: {
...evaluationCadence.custom,
timezone: value,
},
})
}
placeholder="Select timezone"
onSearch={setSearchTimezoneString}
searchValue={searchTimezoneString}
showSearch
/>
</div>
</div>
);
const RRuleView = (
<div className="rrule-view" data-testid="rrule-view">
<div className="select-group">
<Typography.Text>STARTING ON</Typography.Text>
<DatePicker
value={evaluationCadence.rrule.date}
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
rrule: {
...evaluationCadence.rrule,
date: value,
},
})
}
placeholder="Select date"
/>
</div>
<div className="select-group">
<Typography.Text>AT</Typography.Text>
<TimeInput
value={evaluationCadence.rrule.startAt}
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
rrule: {
...evaluationCadence.rrule,
startAt: value,
},
})
}
/>
</div>
<TextArea
value={evaluationCadence.rrule.rrule}
placeholder="Enter RRule"
onChange={(value): void =>
setEvaluationCadence({
...evaluationCadence,
rrule: {
...evaluationCadence.rrule,
rrule: value.target.value,
},
})
}
/>
</div>
);
const handleDiscard = (): void => {
setIsOpen(false);
setIsCustomScheduleButtonVisible(true);
};
const handleSaveCustomSchedule = (): void => {
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE',
payload: {
...advancedOptions.evaluationCadence,
custom: evaluationCadence.custom,
rrule: evaluationCadence.rrule,
},
});
setAdvancedOptions({
type: 'SET_EVALUATION_CADENCE_MODE',
payload: evaluationCadence.mode,
});
setIsOpen(false);
};
const disableSaveButton = useMemo(() => {
if (activeTab === 'editor') {
if (evaluationCadence.custom.repeatEvery === 'day') {
return (
!evaluationCadence.custom.repeatEvery ||
!evaluationCadence.custom.startAt ||
!evaluationCadence.custom.timezone
);
}
return (
!evaluationCadence.custom.repeatEvery ||
!evaluationCadence.custom.occurence.length ||
!evaluationCadence.custom.startAt ||
!evaluationCadence.custom.timezone
);
}
return (
!evaluationCadence.rrule.rrule ||
!evaluationCadence.rrule.date ||
!evaluationCadence.rrule.startAt ||
!isValidRRule(evaluationCadence.rrule.rrule)
);
}, [evaluationCadence, activeTab]);
const schedule = useMemo(() => {
if (activeTab === 'rrule') {
return buildAlertScheduleFromRRule(
evaluationCadence.rrule.rrule,
evaluationCadence.rrule.date,
evaluationCadence.rrule.startAt,
15,
);
}
return buildAlertScheduleFromCustomSchedule(
evaluationCadence.custom.repeatEvery,
evaluationCadence.custom.occurence,
evaluationCadence.custom.startAt,
15,
);
}, [evaluationCadence, activeTab]);
const handleChangeTab = (tab: 'editor' | 'rrule'): void => {
setActiveTab(tab);
const mode = tab === 'editor' ? 'custom' : 'rrule';
setEvaluationCadence({
...evaluationCadence,
mode,
});
};
return (
<div className="evaluation-cadence-details">
<Typography.Text className="evaluation-cadence-details-title">
Add Custom Schedule
</Typography.Text>
<div className="evaluation-cadence-details-content">
<div className="evaluation-cadence-details-content-row">
<div className="query-section-tabs">
<div className="query-section-query-actions">
{tabs.map((tab) => (
<Button
key={tab.value}
className={classNames('list-view-tab', 'explorer-view-option', {
'active-tab': activeTab === tab.value,
})}
onClick={(): void => {
handleChangeTab(tab.value as 'editor' | 'rrule');
}}
>
{tab.icon}
{tab.label}
</Button>
))}
</div>
</div>
{activeTab === 'editor' && EditorView}
{activeTab === 'rrule' && RRuleView}
<div className="buttons-row">
<Button type="default" onClick={handleDiscard}>
Discard
</Button>
<Button
type="primary"
onClick={handleSaveCustomSchedule}
disabled={disableSaveButton}
>
Save Custom Schedule
</Button>
</div>
</div>
<div className="evaluation-cadence-details-content-row">
<ScheduleList
schedule={schedule}
currentTimezone={evaluationCadence.custom.timezone}
/>
</div>
</div>
</div>
);
}
export default EvaluationCadenceDetails;

View File

@@ -0,0 +1,118 @@
import { Modal, Typography } from 'antd';
import { Calendar, Info } from 'lucide-react';
import { useMemo } from 'react';
import { useCreateAlertState } from '../../context';
import { TIMEZONE_DATA } from '../constants';
import { IEvaluationCadencePreviewProps, IScheduleListProps } from '../types';
import {
buildAlertScheduleFromCustomSchedule,
buildAlertScheduleFromRRule,
} from '../utils';
export function ScheduleList({
schedule,
currentTimezone,
}: IScheduleListProps): JSX.Element {
if (schedule && schedule.length > 0) {
return (
<div className="schedule-preview" data-testid="schedule-preview">
<div className="schedule-preview-header">
<Calendar size={16} />
<Typography.Text className="schedule-preview-title">
Schedule Preview
</Typography.Text>
</div>
<div className="schedule-preview-list">
{schedule.map((date) => (
<div key={date.toISOString()} className="schedule-preview-item">
<div className="schedule-preview-timeline">
<div className="schedule-preview-timeline-line" />
</div>
<div className="schedule-preview-content">
<div className="schedule-preview-date">
{date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
})}
,{' '}
{date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</div>
<div className="schedule-preview-separator" />
<div className="schedule-preview-timezone">
{
TIMEZONE_DATA.find((timezone) => timezone.value === currentTimezone)
?.label
}
</div>
</div>
</div>
))}
</div>
</div>
);
}
return (
<div className="no-schedule" data-testid="no-schedule">
<Info size={32} />
<Typography.Text>
Please fill the relevant information to generate a schedule
</Typography.Text>
</div>
);
}
function EvaluationCadencePreview({
isOpen,
setIsOpen,
}: IEvaluationCadencePreviewProps): JSX.Element {
const { advancedOptions } = useCreateAlertState();
const schedule = useMemo(() => {
if (advancedOptions.evaluationCadence.mode === 'rrule') {
return buildAlertScheduleFromRRule(
advancedOptions.evaluationCadence.rrule.rrule,
advancedOptions.evaluationCadence.rrule.date,
advancedOptions.evaluationCadence.rrule.startAt,
15,
);
}
return buildAlertScheduleFromCustomSchedule(
advancedOptions.evaluationCadence.custom.repeatEvery,
advancedOptions.evaluationCadence.custom.occurence,
advancedOptions.evaluationCadence.custom.startAt,
15,
);
}, [advancedOptions.evaluationCadence]);
return (
<Modal
open={isOpen}
onCancel={(): void => setIsOpen(false)}
footer={null}
className="evaluation-cadence-preview-modal"
width={800}
centered
>
<div className="evaluation-cadence-details evaluation-cadence-preview">
<div className="evaluation-cadence-details-content">
<div className="evaluation-cadence-details-content-row">
<ScheduleList
schedule={schedule}
currentTimezone={advancedOptions.evaluationCadence.custom.timezone}
/>
</div>
</div>
</div>
</Modal>
);
}
export default EvaluationCadencePreview;

View File

@@ -0,0 +1,5 @@
import './styles.scss';
import EvaluationCadence from './EvaluationCadence';
export default EvaluationCadence;

View File

@@ -0,0 +1,700 @@
.evaluation-cadence-container {
border-bottom: 1px solid var(--bg-slate-500);
.evaluation-cadence-item {
border-bottom: none !important;
}
.edit-custom-schedule {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px;
.ant-typography {
color: var(--bg-vanilla-100);
font-size: 13px;
.highlight {
background-color: var(--bg-slate-500);
padding: 4px 8px;
border-radius: 4px;
color: var(--bg-vanilla-400);
font-weight: 500;
margin: 0 4px;
font-size: 14px;
}
}
.ant-btn-group {
.ant-btn {
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-400);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
}
}
}
.evaluation-cadence-details {
margin: 16px;
display: flex;
flex-direction: column;
gap: 16px;
border: 1px solid var(--bg-slate-500);
.evaluation-cadence-details-title {
color: var(--bg-vanilla-100);
font-size: 14px;
font-weight: 500;
padding-left: 16px;
padding-top: 16px;
}
.query-section-tabs {
display: flex;
align-items: center;
.query-section-query-actions {
display: flex;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
flex-direction: row;
border-bottom: none;
margin-bottom: -1px;
.explorer-view-option {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
border: none;
padding: 9px;
box-shadow: none;
border-radius: 0px;
border-left: 0.5px solid var(--bg-slate-400);
border-bottom: 0.5px solid var(--bg-slate-400);
width: 120px;
height: 36px;
gap: 8px;
&.active-tab {
background-color: var(--bg-ink-500);
border-bottom: none;
&:hover {
background-color: var(--bg-ink-500) !important;
}
}
&:disabled {
background-color: var(--bg-ink-300);
opacity: 0.6;
}
&:first-child {
border-left: 1px solid transparent;
}
&:hover {
background-color: transparent !important;
border-left: 1px solid transparent !important;
color: var(--bg-vanilla-100);
}
}
}
}
.evaluation-cadence-details-content {
display: flex;
gap: 16px;
border-top: 1px solid var(--bg-slate-500);
padding: 16px;
.evaluation-cadence-details-content-row {
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
height: 500px;
overflow-y: scroll;
padding-right: 16px;
.editor-view,
.rrule-view {
display: flex;
flex-direction: column;
gap: 16px;
textarea {
height: 200px;
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
border-radius: 4px;
color: var(--bg-vanilla-400) !important;
font-family: 'Space Mono';
font-size: 14px;
&::placeholder {
font-family: 'Space Mono';
color: var(--bg-vanilla-400) !important;
}
}
.select-group {
display: flex;
flex-direction: column;
gap: 4px;
.ant-typography {
color: var(--bg-vanilla-100);
font-size: 13px;
font-weight: 500;
}
.ant-select {
border: 1px solid var(--bg-slate-400);
.ant-select-selector {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
}
}
.ant-picker {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
.ant-picker-input {
background-color: var(--bg-ink-300);
color: var(--bg-vanilla-100);
}
}
}
}
.buttons-row {
display: flex;
align-items: center;
gap: 16px;
margin-top: 16px;
}
.no-schedule {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
height: 100%;
color: var(--bg-vanilla-100);
font-size: 14px;
}
.schedule-preview {
display: flex;
flex-direction: column;
width: 100%;
flex: 1;
min-height: 0;
.schedule-preview-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
background-color: var(--bg-ink-400);
position: sticky;
top: 0;
z-index: 1;
border-bottom: 1px solid var(--bg-slate-500);
.schedule-preview-title {
color: var(--bg-vanilla-300);
font-size: 14px;
font-weight: 500;
}
}
.schedule-preview-list {
display: flex;
flex-direction: column;
gap: 0;
flex: 1;
overflow-y: auto;
padding-top: 8px;
&::-webkit-scrollbar {
width: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-200);
}
.schedule-preview-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
.schedule-preview-timeline {
display: flex;
flex-direction: column;
align-items: center;
min-width: 20px;
.schedule-preview-timeline-line {
width: 1px;
height: 20px;
background-color: var(--bg-slate-400);
}
}
.schedule-preview-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
.schedule-preview-date {
color: var(--bg-vanilla-300);
font-size: 14px;
font-weight: 400;
white-space: nowrap;
}
.schedule-preview-separator {
flex: 1;
height: 1px;
border-top: 1px dashed var(--bg-slate-400);
}
.schedule-preview-timezone {
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 400;
white-space: nowrap;
}
}
}
}
}
}
}
}
.ant-picker-date-panel {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-300);
}
.ant-picker-date-panel-layout {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-300);
}
.ant-picker-date-panel-header {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-300);
}
// Custom modal styles for preview
.evaluation-cadence-preview-modal {
.ant-modal-content {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-500);
border-radius: 8px;
}
.ant-modal-header {
background-color: var(--bg-ink-400);
border-bottom: 1px solid var(--bg-slate-500);
padding: 16px 20px;
.ant-modal-title {
color: var(--bg-vanilla-100);
font-size: 16px;
font-weight: 600;
}
}
.ant-modal-close {
color: var(--bg-vanilla-400);
top: 16px;
right: 20px;
&:hover {
color: var(--bg-vanilla-100);
}
}
.ant-modal-body {
padding: 0;
background-color: var(--bg-ink-400);
}
.evaluation-cadence-details {
border: none;
margin: 0;
.evaluation-cadence-details-content {
border-top: none;
padding: 0;
.evaluation-cadence-details-content-row {
height: auto;
max-height: 60vh;
overflow-y: auto;
padding: 12px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-400);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-300);
}
.schedule-preview {
.schedule-preview-header {
background-color: var(--bg-ink-400);
border-bottom: 1px solid var(--bg-slate-500);
padding: 12px 16px;
margin: -12px -12px 16px -12px;
.schedule-preview-title {
color: var(--bg-vanilla-300);
font-size: 14px;
font-weight: 500;
}
}
.schedule-preview-list {
.schedule-preview-item {
padding: 12px 0;
border-bottom: 1px solid var(--bg-slate-500);
&:last-child {
border-bottom: none;
}
.schedule-preview-timeline {
.schedule-preview-timeline-line {
width: 2px;
height: 24px;
background-color: var(--bg-robin-500);
border-radius: 1px;
}
}
.schedule-preview-content {
.schedule-preview-date {
color: var(--bg-vanilla-300);
font-size: 14px;
font-weight: 500;
}
.schedule-preview-timezone {
background-color: var(--bg-slate-500);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
}
}
}
}
}
.no-schedule {
min-height: 300px;
padding: 40px 12px;
svg {
color: var(--bg-slate-400);
}
}
}
}
}
}
// Light mode styles
.lightMode {
.evaluation-cadence-container {
border-bottom: 1px solid var(--bg-vanilla-300);
.edit-custom-schedule {
.ant-typography {
color: var(--bg-ink-400);
.highlight {
background-color: var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
}
.ant-btn-group {
.ant-btn {
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
}
}
}
.evaluation-cadence-details {
border: 1px solid var(--bg-vanilla-300);
.evaluation-cadence-details-title {
color: var(--bg-ink-400);
}
.query-section-tabs {
.query-section-query-actions {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
.explorer-view-option {
border-left: 0.5px solid var(--bg-vanilla-300);
border-bottom: 0.5px solid var(--bg-vanilla-300);
&.active-tab {
background-color: var(--bg-vanilla-100);
&:hover {
background-color: var(--bg-vanilla-100) !important;
}
}
&:disabled {
background-color: var(--bg-vanilla-300);
}
&:hover {
color: var(--bg-ink-400);
}
}
}
}
.evaluation-cadence-details-content {
border-top: 1px solid var(--bg-vanilla-300);
.evaluation-cadence-details-content-row {
.editor-view,
.rrule-view {
textarea {
background: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400) !important;
&::placeholder {
color: var(--bg-ink-400) !important;
}
}
.select-group {
.ant-typography {
color: var(--bg-ink-400);
}
.ant-select {
border: 1px solid var(--bg-vanilla-300);
.ant-select-selector {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
}
.ant-picker {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
.ant-picker-input {
background-color: var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
}
}
}
.no-schedule {
color: var(--bg-ink-400);
}
.schedule-preview {
.schedule-preview-header {
background-color: var(--bg-vanilla-200);
border-bottom: 1px solid var(--bg-vanilla-300);
.schedule-preview-title {
color: var(--bg-ink-300);
}
}
.schedule-preview-list {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-400);
}
.schedule-preview-item {
.schedule-preview-timeline {
.schedule-preview-timeline-line {
background-color: var(--bg-vanilla-300);
}
}
.schedule-preview-content {
.schedule-preview-date {
color: var(--bg-ink-300);
}
.schedule-preview-separator {
border-top: 1px dashed var(--bg-vanilla-300);
}
.schedule-preview-timezone {
color: var(--bg-ink-400);
}
}
}
}
}
}
}
}
.ant-picker-date-panel {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
}
.ant-picker-date-panel-layout {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
}
.ant-picker-date-panel-header {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
}
// Light mode styles for preview modal
.evaluation-cadence-preview-modal {
.ant-modal-content {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
}
.ant-modal-header {
background-color: var(--bg-vanilla-200);
border-bottom: 1px solid var(--bg-vanilla-300);
.ant-modal-title {
color: var(--bg-ink-400);
}
}
.ant-modal-close {
color: var(--bg-ink-400);
&:hover {
color: var(--bg-ink-300);
}
}
.ant-modal-body {
background-color: var(--bg-vanilla-200);
}
.evaluation-cadence-details {
.evaluation-cadence-details-content {
.evaluation-cadence-details-content-row {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-400);
}
.schedule-preview {
.schedule-preview-header {
background-color: var(--bg-vanilla-200);
border-bottom: 1px solid var(--bg-vanilla-300);
.schedule-preview-title {
color: var(--bg-ink-300);
}
}
.schedule-preview-list {
.schedule-preview-item {
border-bottom: 1px solid var(--bg-vanilla-300);
.schedule-preview-timeline {
.schedule-preview-timeline-line {
background-color: var(--bg-robin-500);
}
}
.schedule-preview-content {
.schedule-preview-date {
color: var(--bg-ink-300);
}
.schedule-preview-separator {
border-top: 1px dashed var(--bg-vanilla-300);
}
.schedule-preview-timezone {
background-color: var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
}
}
}
}
.no-schedule {
color: var(--bg-ink-400);
svg {
color: var(--bg-vanilla-300);
}
}
}
}
}
}
}

View File

@@ -0,0 +1,91 @@
import './styles.scss';
import { Button, Popover, Typography } from 'antd';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { useState } from 'react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { useCreateAlertState } from '../context';
import Stepper from '../Stepper';
import { showCondensedLayout } from '../utils';
import AdvancedOptions from './AdvancedOptions';
import EvaluationWindowPopover from './EvaluationWindowPopover';
import { getEvaluationWindowTypeText, getTimeframeText } from './utils';
function EvaluationSettings(): JSX.Element {
const {
alertType,
evaluationWindow,
setEvaluationWindow,
} = useCreateAlertState();
const [
isEvaluationWindowPopoverOpen,
setIsEvaluationWindowPopoverOpen,
] = useState(false);
const showCondensedLayoutFlag = showCondensedLayout();
const popoverContent = (
<Popover
open={isEvaluationWindowPopoverOpen}
onOpenChange={(visibility: boolean): void => {
setIsEvaluationWindowPopoverOpen(visibility);
}}
content={
<EvaluationWindowPopover
evaluationWindow={evaluationWindow}
setEvaluationWindow={setEvaluationWindow}
/>
}
trigger="click"
showArrow={false}
>
<Button>
<div className="evaluate-alert-conditions-button-left">
{getTimeframeText(evaluationWindow)}
</div>
<div className="evaluate-alert-conditions-button-right">
<div className="evaluate-alert-conditions-button-right-text">
{getEvaluationWindowTypeText(evaluationWindow.windowType)}
</div>
{isEvaluationWindowPopoverOpen ? (
<ChevronUp size={16} />
) : (
<ChevronDown size={16} />
)}
</div>
</Button>
</Popover>
);
// Layout consists of only the evaluation window popover
if (showCondensedLayoutFlag) {
return (
<div
className="condensed-evaluation-settings-container"
data-testid="condensed-evaluation-settings-container"
>
{popoverContent}
</div>
);
}
// Layout consists of
// - Stepper header
// - Evaluation window popover
// - Advanced options
return (
<div className="evaluation-settings-container">
<Stepper stepNumber={3} label="Evaluation settings" />
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && (
<div className="evaluate-alert-conditions-container">
<Typography.Text>Check conditions using data from</Typography.Text>
<div className="evaluate-alert-conditions-separator" />
{popoverContent}
</div>
)}
<AdvancedOptions />
</div>
);
}
export default EvaluationSettings;

View File

@@ -0,0 +1,221 @@
import { Input, Select, Typography } from 'antd';
import { useMemo } from 'react';
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../../context/constants';
import {
CUMULATIVE_WINDOW_DESCRIPTION,
ROLLING_WINDOW_DESCRIPTION,
TIMEZONE_DATA,
} from '../constants';
import TimeInput from '../TimeInput';
import { IEvaluationWindowDetailsProps } from '../types';
import { getCumulativeWindowTimeframeText } from '../utils';
function EvaluationWindowDetails({
evaluationWindow,
setEvaluationWindow,
}: IEvaluationWindowDetailsProps): JSX.Element {
const currentHourOptions = useMemo(() => {
const options = [];
for (let i = 0; i < 60; i++) {
options.push({ label: i.toString(), value: i });
}
return options;
}, []);
const currentMonthOptions = useMemo(() => {
const options = [];
for (let i = 1; i <= 31; i++) {
options.push({ label: i.toString(), value: i });
}
return options;
}, []);
const displayText = useMemo(() => {
if (
evaluationWindow.windowType === 'rolling' &&
evaluationWindow.timeframe === 'custom'
) {
return `Last ${evaluationWindow.startingAt.number} ${
ADVANCED_OPTIONS_TIME_UNIT_OPTIONS.find(
(option) => option.value === evaluationWindow.startingAt.unit,
)?.label
}`;
}
if (evaluationWindow.windowType === 'cumulative') {
return getCumulativeWindowTimeframeText(evaluationWindow);
}
return '';
}, [evaluationWindow]);
if (
evaluationWindow.windowType === 'rolling' &&
evaluationWindow.timeframe !== 'custom'
) {
return <div />;
}
const isCurrentHour =
evaluationWindow.windowType === 'cumulative' &&
evaluationWindow.timeframe === 'currentHour';
const isCurrentDay =
evaluationWindow.windowType === 'cumulative' &&
evaluationWindow.timeframe === 'currentDay';
const isCurrentMonth =
evaluationWindow.windowType === 'cumulative' &&
evaluationWindow.timeframe === 'currentMonth';
const handleNumberChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: value,
time: evaluationWindow.startingAt.time,
timezone: evaluationWindow.startingAt.timezone,
unit: evaluationWindow.startingAt.unit,
},
});
};
const handleTimeChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: evaluationWindow.startingAt.number,
time: value,
timezone: evaluationWindow.startingAt.timezone,
unit: evaluationWindow.startingAt.unit,
},
});
};
const handleUnitChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: evaluationWindow.startingAt.number,
time: evaluationWindow.startingAt.time,
timezone: evaluationWindow.startingAt.timezone,
unit: value,
},
});
};
const handleTimezoneChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: evaluationWindow.startingAt.number,
time: evaluationWindow.startingAt.time,
timezone: value,
unit: evaluationWindow.startingAt.unit,
},
});
};
if (isCurrentHour) {
return (
<div className="evaluation-window-details">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group">
<Typography.Text>STARTING AT MINUTE</Typography.Text>
<Select
options={currentHourOptions}
value={evaluationWindow.startingAt.number || null}
onChange={handleNumberChange}
placeholder="Select starting at"
/>
</div>
</div>
);
}
if (isCurrentDay) {
return (
<div className="evaluation-window-details">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group time-select-group">
<Typography.Text>STARTING AT</Typography.Text>
<TimeInput
value={evaluationWindow.startingAt.time}
onChange={handleTimeChange}
/>
</div>
<div className="select-group">
<Typography.Text>SELECT TIMEZONE</Typography.Text>
<Select
options={TIMEZONE_DATA}
value={evaluationWindow.startingAt.timezone || null}
onChange={handleTimezoneChange}
placeholder="Select timezone"
/>
</div>
</div>
);
}
if (isCurrentMonth) {
return (
<div className="evaluation-window-details">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group">
<Typography.Text>STARTING ON DAY</Typography.Text>
<Select
options={currentMonthOptions}
value={evaluationWindow.startingAt.number || null}
onChange={handleNumberChange}
placeholder="Select starting at"
/>
</div>
<div className="select-group time-select-group">
<Typography.Text>STARTING AT</Typography.Text>
<TimeInput
value={evaluationWindow.startingAt.time}
onChange={handleTimeChange}
/>
</div>
<div className="select-group">
<Typography.Text>SELECT TIMEZONE</Typography.Text>
<Select
options={TIMEZONE_DATA}
value={evaluationWindow.startingAt.timezone || null}
onChange={handleTimezoneChange}
placeholder="Select timezone"
/>
</div>
</div>
);
}
return (
<div className="evaluation-window-details">
<Typography.Text>{ROLLING_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>Specify custom duration</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group">
<Typography.Text>VALUE</Typography.Text>
<Input
name="value"
type="number"
value={evaluationWindow.startingAt.number}
onChange={(e): void => handleNumberChange(e.target.value)}
placeholder="Enter value"
/>
</div>
<div className="select-group time-select-group">
<Typography.Text>UNIT</Typography.Text>
<Select
options={ADVANCED_OPTIONS_TIME_UNIT_OPTIONS}
value={evaluationWindow.startingAt.unit || null}
onChange={handleUnitChange}
placeholder="Select unit"
/>
</div>
</div>
);
}
export default EvaluationWindowDetails;

View File

@@ -0,0 +1,161 @@
import { Button, Typography } from 'antd';
import classNames from 'classnames';
import { Check } from 'lucide-react';
import {
CUMULATIVE_WINDOW_DESCRIPTION,
EVALUATION_WINDOW_TIMEFRAME,
EVALUATION_WINDOW_TYPE,
ROLLING_WINDOW_DESCRIPTION,
} from '../constants';
import {
CumulativeWindowTimeframes,
IEvaluationWindowPopoverProps,
RollingWindowTimeframes,
} from '../types';
import EvaluationWindowDetails from './EvaluationWindowDetails';
import { useKeyboardNavigationForEvaluationWindowPopover } from './useKeyboardNavigation';
function EvaluationWindowPopover({
evaluationWindow,
setEvaluationWindow,
}: IEvaluationWindowPopoverProps): JSX.Element {
const {
containerRef,
firstItemRef,
} = useKeyboardNavigationForEvaluationWindowPopover({
onSelect: (value: string, sectionId: string): void => {
if (sectionId === 'window-type') {
setEvaluationWindow({
type: 'SET_WINDOW_TYPE',
payload: value as 'rolling' | 'cumulative',
});
} else if (sectionId === 'timeframe') {
setEvaluationWindow({
type: 'SET_TIMEFRAME',
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
});
}
},
onEscape: (): void => {
const triggerElement = document.querySelector(
'[aria-haspopup="true"]',
) as HTMLElement;
triggerElement?.focus();
},
});
const renderEvaluationWindowContent = (
label: string,
contentOptions: Array<{ label: string; value: string }>,
currentValue: string,
onChange: (value: string) => void,
sectionId: string,
): JSX.Element => (
<div className="evaluation-window-content-item" data-section-id={sectionId}>
<Typography.Text className="evaluation-window-content-item-label">
{label}
</Typography.Text>
<div className="evaluation-window-content-list">
{contentOptions.map((option, index) => (
<div
className={classNames('evaluation-window-content-list-item', {
active: currentValue === option.value,
})}
key={option.value}
role="button"
tabIndex={0}
data-value={option.value}
data-section-id={sectionId}
onClick={(): void => onChange(option.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onChange(option.value);
}
}}
ref={index === 0 ? firstItemRef : undefined}
>
<Typography.Text>{option.label}</Typography.Text>
{currentValue === option.value && <Check size={12} />}
</div>
))}
</div>
</div>
);
const renderSelectionContent = (): JSX.Element => {
if (evaluationWindow.windowType === 'rolling') {
if (evaluationWindow.timeframe === 'custom') {
return (
<EvaluationWindowDetails
evaluationWindow={evaluationWindow}
setEvaluationWindow={setEvaluationWindow}
/>
);
}
return (
<div className="selection-content">
<Typography.Text>{ROLLING_WINDOW_DESCRIPTION}</Typography.Text>
<Button type="link">Read the docs</Button>
</div>
);
}
if (
evaluationWindow.windowType === 'cumulative' &&
!evaluationWindow.timeframe
) {
return (
<div className="selection-content">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Button type="link">Read the docs</Button>
</div>
);
}
return (
<EvaluationWindowDetails
evaluationWindow={evaluationWindow}
setEvaluationWindow={setEvaluationWindow}
/>
);
};
return (
<div
className="evaluation-window-popover"
ref={containerRef}
role="menu"
aria-label="Evaluation window options"
>
<div className="evaluation-window-content">
{renderEvaluationWindowContent(
'EVALUATION WINDOW',
EVALUATION_WINDOW_TYPE,
evaluationWindow.windowType,
(value: string): void =>
setEvaluationWindow({
type: 'SET_WINDOW_TYPE',
payload: value as 'rolling' | 'cumulative',
}),
'window-type',
)}
{renderEvaluationWindowContent(
'TIMEFRAME',
EVALUATION_WINDOW_TIMEFRAME[evaluationWindow.windowType],
evaluationWindow.timeframe,
(value: string): void =>
setEvaluationWindow({
type: 'SET_TIMEFRAME',
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
}),
'timeframe',
)}
{renderSelectionContent()}
</div>
</div>
);
}
export default EvaluationWindowPopover;

View File

@@ -0,0 +1,3 @@
import EvaluationWindowPopover from './EvaluationWindowPopover';
export default EvaluationWindowPopover;

View File

@@ -0,0 +1,180 @@
import React, { useCallback, useEffect, useRef } from 'react';
interface UseKeyboardNavigationOptions {
onSelect?: (value: string, sectionId: string) => void;
onEscape?: () => void;
}
export const useKeyboardNavigationForEvaluationWindowPopover = ({
onSelect,
onEscape,
}: UseKeyboardNavigationOptions = {}): {
containerRef: React.RefObject<HTMLDivElement>;
firstItemRef: React.RefObject<HTMLDivElement>;
} => {
const containerRef = useRef<HTMLDivElement>(null);
const firstItemRef = useRef<HTMLDivElement>(null);
const getFocusableItems = useCallback((): HTMLElement[] => {
if (!containerRef.current) return [];
return Array.from(
containerRef.current.querySelectorAll(
'.evaluation-window-content-list-item[tabindex="0"]',
),
) as HTMLElement[];
}, []);
const getInteractiveElements = useCallback((): HTMLElement[] => {
if (!containerRef.current) return [];
const detailsSection = containerRef.current.querySelector(
'.evaluation-window-details',
);
if (!detailsSection) return [];
return Array.from(
detailsSection.querySelectorAll(
'input, select, button, [tabindex="0"], [tabindex="-1"]',
),
) as HTMLElement[];
}, []);
const getCurrentIndex = useCallback((items: HTMLElement[]): number => {
const activeElement = document.activeElement as HTMLElement;
return items.findIndex((item) => item === activeElement);
}, []);
const navigateWithinSection = useCallback(
(direction: 'up' | 'down'): void => {
const items = getFocusableItems();
if (items.length === 0) return;
const currentIndex = getCurrentIndex(items);
let nextIndex: number;
if (direction === 'down') {
nextIndex = (currentIndex + 1) % items.length;
} else {
nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
}
items[nextIndex]?.focus();
},
[getFocusableItems, getCurrentIndex],
);
const navigateToDetails = useCallback((): void => {
const interactiveElements = getInteractiveElements();
interactiveElements[0]?.focus();
}, [getInteractiveElements]);
const navigateBackToSection = useCallback((): void => {
const items = getFocusableItems();
items[0]?.focus();
}, [getFocusableItems]);
const navigateBetweenSections = useCallback(
(direction: 'left' | 'right'): void => {
const activeElement = document.activeElement as HTMLElement;
const isInDetails = activeElement?.closest('.evaluation-window-details');
if (isInDetails && direction === 'left') {
navigateBackToSection();
return;
}
const items = getFocusableItems();
if (items.length === 0) return;
const currentIndex = getCurrentIndex(items);
const DATA_ATTR = 'data-section-id';
const currentSectionId = items[currentIndex]?.getAttribute(DATA_ATTR);
if (currentSectionId === 'window-type' && direction === 'right') {
const timeframeItem = items.find(
(item) => item.getAttribute(DATA_ATTR) === 'timeframe',
);
timeframeItem?.focus();
} else if (currentSectionId === 'timeframe' && direction === 'left') {
const windowTypeItem = items.find(
(item) => item.getAttribute(DATA_ATTR) === 'window-type',
);
windowTypeItem?.focus();
} else if (currentSectionId === 'timeframe' && direction === 'right') {
navigateToDetails();
}
},
[
navigateBackToSection,
navigateToDetails,
getFocusableItems,
getCurrentIndex,
],
);
const handleSelection = useCallback((): void => {
const activeElement = document.activeElement as HTMLElement;
if (!activeElement || !onSelect) return;
const value = activeElement.getAttribute('data-value');
const sectionId = activeElement.getAttribute('data-section-id');
if (value && sectionId) {
onSelect(value, sectionId);
}
}, [onSelect]);
const handleKeyDown = useCallback(
(event: KeyboardEvent): void => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
navigateWithinSection('down');
break;
case 'ArrowUp':
event.preventDefault();
navigateWithinSection('up');
break;
case 'ArrowLeft':
event.preventDefault();
navigateBetweenSections('left');
break;
case 'ArrowRight':
event.preventDefault();
navigateBetweenSections('right');
break;
case 'Enter':
case ' ':
event.preventDefault();
handleSelection();
break;
case 'Escape':
event.preventDefault();
onEscape?.();
break;
default:
break;
}
},
[navigateWithinSection, navigateBetweenSections, handleSelection, onEscape],
);
useEffect((): (() => void) | undefined => {
const container = containerRef.current;
if (!container) return undefined;
container.addEventListener('keydown', handleKeyDown);
return (): void => container.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
useEffect((): void => {
if (firstItemRef.current) {
firstItemRef.current.focus();
}
}, []);
return {
containerRef: containerRef as React.RefObject<HTMLDivElement>,
firstItemRef: firstItemRef as React.RefObject<HTMLDivElement>,
};
};

View File

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

View File

@@ -0,0 +1,168 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AdvancedOptionItem from '../AdvancedOptionItem/AdvancedOptionItem';
const TEST_INPUT_PLACEHOLDER = 'Test input';
const TEST_TITLE = 'Test Title';
const TEST_DESCRIPTION = 'Test Description';
const TEST_VALUE = 'test value';
const TEST_INPUT_TEST_ID = 'test-input';
describe('AdvancedOptionItem', () => {
const mockInput = (
<input
data-testid={TEST_INPUT_TEST_ID}
placeholder={TEST_INPUT_PLACEHOLDER}
/>
);
const defaultProps = {
title: TEST_TITLE,
description: TEST_DESCRIPTION,
input: mockInput,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('should render title, description and switch', () => {
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
/>,
);
expect(screen.getByText(TEST_TITLE)).toBeInTheDocument();
expect(screen.getByText(TEST_DESCRIPTION)).toBeInTheDocument();
const switchElement = screen.getByRole('switch');
expect(switchElement).toBeInTheDocument();
expect(switchElement).not.toBeChecked();
});
it('should not show input initially', () => {
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
/>,
);
const inputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(inputElement).toBeInTheDocument();
expect(inputElement).not.toBeVisible();
});
it('should show input when switch is toggled on', async () => {
const user = userEvent.setup();
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
/>,
);
const initialInputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(initialInputElement).toBeInTheDocument();
expect(initialInputElement).not.toBeVisible();
const switchElement = screen.getByRole('switch');
await user.click(switchElement);
expect(switchElement).toBeChecked();
const visibleInputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(visibleInputElement).toBeInTheDocument();
expect(visibleInputElement).toBeVisible();
});
it('should hide input when switch is toggled off', async () => {
const user = userEvent.setup();
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
/>,
);
const switchElement = screen.getByRole('switch');
const initialInputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(initialInputElement).toBeInTheDocument();
expect(initialInputElement).not.toBeVisible();
// First toggle on
await user.click(switchElement);
const inputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(inputElement).toBeInTheDocument();
expect(inputElement).toBeVisible();
// Then toggle off - input should be hidden but still in DOM
await user.click(switchElement);
const hiddenInputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(hiddenInputElement).toBeInTheDocument();
expect(hiddenInputElement).not.toBeVisible();
});
it('should maintain input state when toggling', async () => {
const user = userEvent.setup();
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
/>,
);
const switchElement = screen.getByRole('switch');
// Toggle on and interact with input
await user.click(switchElement);
const inputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
await user.type(inputElement, TEST_VALUE);
expect(inputElement).toHaveValue(TEST_VALUE);
// Toggle off - input should still be in DOM but hidden
await user.click(switchElement);
const hiddenInputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(hiddenInputElement).toBeInTheDocument();
expect(hiddenInputElement).not.toBeVisible();
// Toggle back on - input should maintain its previous state
await user.click(switchElement);
const inputElementAgain = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(inputElementAgain).toHaveValue(TEST_VALUE); // State preserved!
});
it('should not render tooltip icon if tooltipText is not provided', () => {
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
/>,
);
const tooltipIcon = screen.queryByTestId('tooltip-icon');
expect(tooltipIcon).not.toBeInTheDocument();
});
it('should render tooltip icon if tooltipText is provided', () => {
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
tooltipText="mock tooltip text"
/>,
);
const tooltipIcon = screen.getByTestId('tooltip-icon');
expect(tooltipIcon).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,141 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as alertState from 'container/CreateAlertV2/context';
import AdvancedOptions from '../AdvancedOptions';
import { createMockAlertContextState } from './testUtils';
const mockSetAdvancedOptions = jest.fn();
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
setAdvancedOptions: mockSetAdvancedOptions,
}),
);
const ALERT_WHEN_DATA_STOPS_COMING_TEXT = 'Alert when data stops coming';
const MINIMUM_DATA_REQUIRED_TEXT = 'Minimum data required';
const ACCOUNT_FOR_DATA_DELAY_TEXT = 'Account for data delay';
const ADVANCED_OPTION_ITEM_CLASS = '.advanced-option-item';
const SWITCH_ROLE_SELECTOR = '[role="switch"]';
describe('AdvancedOptions', () => {
it('should render evaluation cadence and the advanced options minimized by default', () => {
render(<AdvancedOptions />);
expect(screen.getByText('ADVANCED OPTIONS')).toBeInTheDocument();
expect(screen.queryByText('How often to check')).not.toBeInTheDocument();
expect(
screen.queryByText(ALERT_WHEN_DATA_STOPS_COMING_TEXT),
).not.toBeInTheDocument();
expect(
screen.queryByText(MINIMUM_DATA_REQUIRED_TEXT),
).not.toBeInTheDocument();
expect(
screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
).not.toBeInTheDocument();
});
it('should be able to expand the advanced options', () => {
render(<AdvancedOptions />);
expect(
screen.queryByText(ALERT_WHEN_DATA_STOPS_COMING_TEXT),
).not.toBeInTheDocument();
expect(
screen.queryByText(MINIMUM_DATA_REQUIRED_TEXT),
).not.toBeInTheDocument();
expect(
screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
).not.toBeInTheDocument();
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
fireEvent.click(collapse);
expect(screen.getByText('How often to check')).toBeInTheDocument();
expect(screen.getByText('Alert when data stops coming')).toBeInTheDocument();
expect(screen.getByText('Minimum data required')).toBeInTheDocument();
expect(screen.getByText('Account for data delay')).toBeInTheDocument();
});
it('"Alert when data stops coming" works as expected', () => {
render(<AdvancedOptions />);
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
fireEvent.click(collapse);
const alertWhenDataStopsComingContainer = screen
.getByText(ALERT_WHEN_DATA_STOPS_COMING_TEXT)
.closest(ADVANCED_OPTION_ITEM_CLASS);
const alertWhenDataStopsComingSwitch = alertWhenDataStopsComingContainer?.querySelector(
SWITCH_ROLE_SELECTOR,
) as HTMLElement;
fireEvent.click(alertWhenDataStopsComingSwitch);
const toleranceInput = screen.getByPlaceholderText(
'Enter tolerance limit...',
);
fireEvent.change(toleranceInput, { target: { value: '10' } });
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
payload: {
toleranceLimit: 10,
timeUnit: 'min',
},
});
});
it('"Minimum data required" works as expected', () => {
render(<AdvancedOptions />);
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
fireEvent.click(collapse);
const minimumDataRequiredContainer = screen
.getByText(MINIMUM_DATA_REQUIRED_TEXT)
.closest(ADVANCED_OPTION_ITEM_CLASS);
const minimumDataRequiredSwitch = minimumDataRequiredContainer?.querySelector(
SWITCH_ROLE_SELECTOR,
) as HTMLElement;
fireEvent.click(minimumDataRequiredSwitch);
const minimumDataRequiredInput = screen.getByPlaceholderText(
'Enter minimum datapoints...',
);
fireEvent.change(minimumDataRequiredInput, { target: { value: '10' } });
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS',
payload: {
minimumDatapoints: 10,
},
});
});
it('"Account for data delay" works as expected', () => {
render(<AdvancedOptions />);
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
fireEvent.click(collapse);
const accountForDataDelayContainer = screen
.getByText(ACCOUNT_FOR_DATA_DELAY_TEXT)
.closest(ADVANCED_OPTION_ITEM_CLASS);
const accountForDataDelaySwitch = accountForDataDelayContainer?.querySelector(
SWITCH_ROLE_SELECTOR,
) as HTMLElement;
fireEvent.click(accountForDataDelaySwitch);
const delayInput = screen.getByPlaceholderText('Enter delay...');
fireEvent.change(delayInput, { target: { value: '10' } });
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
type: 'SET_DELAY_EVALUATION',
payload: {
delay: 10,
timeUnit: 'min',
},
});
});
});

View File

@@ -0,0 +1,155 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as alertState from 'container/CreateAlertV2/context';
import { INITIAL_ADVANCED_OPTIONS_STATE } from 'container/CreateAlertV2/context/constants';
import { TIMEZONE_DATA } from '../constants';
import EditCustomSchedule from '../EvaluationCadence/EditCustomSchedule';
import { createMockAlertContextState } from './testUtils';
const mockSetAdvancedOptions = jest.fn();
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
setAdvancedOptions: mockSetAdvancedOptions,
}),
);
const mockSetIsEvaluationCadenceDetailsVisible = jest.fn();
const mockSetIsPreviewVisible = jest.fn();
const EDIT_CUSTOM_SCHEDULE_TEST_ID = '.edit-custom-schedule';
describe('EditCustomSchedule', () => {
it('should render the correct display text for custom mode with daily occurrence', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
custom: {
repeatEvery: 'day',
startAt: '00:00:00',
occurence: [],
timezone: TIMEZONE_DATA[0].value,
},
},
},
}),
);
render(
<EditCustomSchedule
setIsEvaluationCadenceDetailsVisible={
mockSetIsEvaluationCadenceDetailsVisible
}
setIsPreviewVisible={mockSetIsPreviewVisible}
/>,
);
// Use textContent to verify the complete text across multiple Typography components
const container = screen
.getByText('Every')
.closest(EDIT_CUSTOM_SCHEDULE_TEST_ID);
expect(container).toHaveTextContent('EveryDayat00:00:00');
});
it('should render the correct display text for custom mode with weekly occurrence', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
custom: {
repeatEvery: 'week',
startAt: '00:00:00',
occurence: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'],
timezone: TIMEZONE_DATA[0].value,
},
},
},
}),
);
render(
<EditCustomSchedule
setIsEvaluationCadenceDetailsVisible={
mockSetIsEvaluationCadenceDetailsVisible
}
setIsPreviewVisible={mockSetIsPreviewVisible}
/>,
);
const container = screen
.getByText('Every')
.closest(EDIT_CUSTOM_SCHEDULE_TEST_ID);
expect(container).toHaveTextContent(
'EveryWeekonMonday, Tuesday, Wednesday, Thursday, Fridayat00:00:00',
);
});
it('should render the correct display text for custom mode with monthly occurrence', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
custom: {
repeatEvery: 'month',
startAt: '00:00:00',
occurence: ['1'],
timezone: TIMEZONE_DATA[0].value,
},
},
},
}),
);
render(
<EditCustomSchedule
setIsEvaluationCadenceDetailsVisible={
mockSetIsEvaluationCadenceDetailsVisible
}
setIsPreviewVisible={mockSetIsPreviewVisible}
/>,
);
const container = screen
.getByText('Every')
.closest(EDIT_CUSTOM_SCHEDULE_TEST_ID);
expect(container).toHaveTextContent('EveryMonthon1at00:00:00');
});
it('edit custom schedule action works correctly', () => {
render(
<EditCustomSchedule
setIsEvaluationCadenceDetailsVisible={
mockSetIsEvaluationCadenceDetailsVisible
}
setIsPreviewVisible={mockSetIsPreviewVisible}
/>,
);
fireEvent.click(screen.getByText('Edit custom schedule'));
expect(mockSetIsEvaluationCadenceDetailsVisible).toHaveBeenCalledWith(true);
expect(mockSetIsPreviewVisible).not.toHaveBeenCalled();
});
it('preview custom schedule action works correctly', () => {
render(
<EditCustomSchedule
setIsEvaluationCadenceDetailsVisible={
mockSetIsEvaluationCadenceDetailsVisible
}
setIsPreviewVisible={mockSetIsPreviewVisible}
/>,
);
fireEvent.click(screen.getByText('Preview'));
expect(mockSetIsPreviewVisible).toHaveBeenCalledWith(true);
expect(mockSetIsEvaluationCadenceDetailsVisible).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,162 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as alertState from 'container/CreateAlertV2/context';
import { INITIAL_ADVANCED_OPTIONS_STATE } from 'container/CreateAlertV2/context/constants';
import { TIMEZONE_DATA } from '../constants';
import EvaluationCadence from '../EvaluationCadence';
import { createMockAlertContextState } from './testUtils';
jest.mock('../EvaluationCadence/EditCustomSchedule', () => ({
__esModule: true,
default: ({
setIsPreviewVisible,
}: {
setIsPreviewVisible: (isPreviewVisible: boolean) => void;
}): JSX.Element => (
<div data-testid="edit-custom-schedule">
<div>EditCustomSchedule</div>
<button type="button" onClick={(): void => setIsPreviewVisible(true)}>
Preview
</button>
</div>
),
}));
jest.mock('../EvaluationCadence/EvaluationCadenceDetails', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="evaluation-cadence-details">EvaluationCadenceDetails</div>
),
}));
jest.mock('../EvaluationCadence/EvaluationCadencePreview', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="evaluation-cadence-preview">EvaluationCadencePreview</div>
),
}));
const mockSetAdvancedOptions = jest.fn();
const EVALUATION_CADENCE_DETAILS_TEST_ID = 'evaluation-cadence-details';
const ADD_CUSTOM_SCHEDULE_TEXT = 'Add custom schedule';
const EVALUATION_CADENCE_PREVIEW_TEST_ID = 'evaluation-cadence-preview';
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
setAdvancedOptions: mockSetAdvancedOptions,
}),
);
const EVALUATION_CADENCE_INPUT_GROUP = 'evaluation-cadence-input-group';
describe('EvaluationCadence', () => {
it('should render the title, description, tooltip and input group with default values', () => {
render(<EvaluationCadence />);
expect(screen.getByText('How often to check')).toBeInTheDocument();
expect(
screen.getByText(
'How frequently this alert checks your data. Default: Every 1 minute',
),
).toBeInTheDocument();
expect(
screen.getByTestId('evaluation-cadence-tooltip-icon'),
).toBeInTheDocument();
expect(
screen.getByTestId(EVALUATION_CADENCE_INPUT_GROUP),
).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter time')).toHaveValue(1);
expect(screen.getByText('Minutes')).toBeInTheDocument();
expect(screen.getByText(ADD_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
});
it('should hide the input group when add custom schedule button is clicked', () => {
render(<EvaluationCadence />);
expect(
screen.getByTestId(EVALUATION_CADENCE_INPUT_GROUP),
).toBeInTheDocument();
fireEvent.click(screen.getByText(ADD_CUSTOM_SCHEDULE_TEXT));
expect(
screen.queryByTestId(EVALUATION_CADENCE_INPUT_GROUP),
).not.toBeInTheDocument();
expect(
screen.getByTestId(EVALUATION_CADENCE_DETAILS_TEST_ID),
).toBeInTheDocument();
});
it('should not show the edit custom schedule component in default mode', () => {
render(<EvaluationCadence />);
expect(screen.queryByTestId('edit-custom-schedule')).not.toBeInTheDocument();
});
it('should show the custom schedule text when the mode is custom with selected values', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
custom: {
repeatEvery: 'day',
startAt: '00:00:00',
occurence: [],
timezone: TIMEZONE_DATA[0].value,
},
},
},
}),
);
render(<EvaluationCadence />);
expect(screen.getByTestId('edit-custom-schedule')).toBeInTheDocument();
});
it('should not show evaluation cadence details component in default mode', () => {
render(<EvaluationCadence />);
expect(
screen.queryByTestId(EVALUATION_CADENCE_DETAILS_TEST_ID),
).not.toBeInTheDocument();
});
it('should show evaluation cadence details component when clicked on add custom schedule button', () => {
render(<EvaluationCadence />);
expect(
screen.queryByTestId(EVALUATION_CADENCE_DETAILS_TEST_ID),
).not.toBeInTheDocument();
expect(screen.getByText(ADD_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
fireEvent.click(screen.getByText(ADD_CUSTOM_SCHEDULE_TEXT));
expect(
screen.getByTestId(EVALUATION_CADENCE_DETAILS_TEST_ID),
).toBeInTheDocument();
});
it('should not show evaluation cadence preview component in default mode', () => {
render(<EvaluationCadence />);
expect(
screen.queryByTestId(EVALUATION_CADENCE_PREVIEW_TEST_ID),
).not.toBeInTheDocument();
});
it('should show evaluation cadence preview component when clicked on preview button in custom mode', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
},
},
}),
);
render(<EvaluationCadence />);
expect(
screen.queryByTestId(EVALUATION_CADENCE_PREVIEW_TEST_ID),
).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Preview'));
expect(
screen.getByTestId(EVALUATION_CADENCE_PREVIEW_TEST_ID),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,316 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { fireEvent, render, screen } from '@testing-library/react';
import * as alertState from 'container/CreateAlertV2/context';
import { INITIAL_ADVANCED_OPTIONS_STATE } from 'container/CreateAlertV2/context/constants';
import { AdvancedOptionsState } from 'container/CreateAlertV2/context/types';
import EvaluationCadenceDetails from '../EvaluationCadence/EvaluationCadenceDetails';
import { createMockAlertContextState } from './testUtils';
const ENTER_RRULE_PLACEHOLDER = 'Enter RRule';
jest.mock('dayjs', () => {
const actualDayjs = jest.requireActual('dayjs');
const mockDayjs = (date?: any): any => {
if (date) {
return actualDayjs(date);
}
// 21 Jan 2025
return actualDayjs('2025-01-21T16:31:36.982Z');
};
Object.keys(actualDayjs).forEach((key) => {
if (typeof (actualDayjs as any)[key] === 'function') {
(mockDayjs as any)[key] = (actualDayjs as any)[key];
}
});
(mockDayjs as any).tz = {
guess: (): string => 'Asia/Saigon',
};
return mockDayjs;
});
const INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
},
};
const mockSetAdvancedOptions = jest.fn();
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE,
setAdvancedOptions: mockSetAdvancedOptions,
}),
);
const mockSetIsOpen = jest.fn();
const mockSetIsCustomScheduleButtonVisible = jest.fn();
const SCHEDULE_PREVIEW_TEST_ID = 'schedule-preview';
const NO_SCHEDULE_TEST_ID = 'no-schedule';
const EDITOR_VIEW_TEST_ID = 'editor-view';
const RULE_VIEW_TEST_ID = 'rrule-view';
const SAVE_CUSTOM_SCHEDULE_TEXT = 'Save Custom Schedule';
describe('EvaluationCadenceDetails', () => {
it('should render the evaluation cadence details component with editor mode in daily occurence by default', () => {
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
expect(screen.getByText('Add Custom Schedule')).toBeInTheDocument();
expect(screen.getByTestId(EDITOR_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId('rrule-view')).not.toBeInTheDocument();
expect(screen.getByText('REPEAT EVERY')).toBeInTheDocument();
expect(screen.getByText('AT')).toBeInTheDocument();
expect(screen.getByText('TIMEZONE')).toBeInTheDocument();
expect(screen.getByTestId(SCHEDULE_PREVIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByText('Discard')).toBeInTheDocument();
expect(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
});
it('when switching to rrule mode, the rrule view should be rendered with no schedule preview', () => {
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
fireEvent.click(screen.getByText('RRule'));
expect(screen.getByTestId(RULE_VIEW_TEST_ID)).toBeInTheDocument();
expect(
screen.queryByTestId(SCHEDULE_PREVIEW_TEST_ID),
).not.toBeInTheDocument();
expect(screen.getByTestId(NO_SCHEDULE_TEST_ID)).toBeInTheDocument();
expect(screen.getByText('STARTING ON')).toBeInTheDocument();
expect(screen.getByText('AT')).toBeInTheDocument();
expect(
screen.getByPlaceholderText(ENTER_RRULE_PLACEHOLDER),
).toBeInTheDocument();
expect(screen.getByText('Discard')).toBeInTheDocument();
expect(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT)).toBeInTheDocument();
});
it('when showing weekly occurence, the occurence options should be rendered', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence,
custom: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence
.custom,
repeatEvery: 'week',
},
},
},
}),
);
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
// Verify that the "ON DAY(S)" section is rendered for weekly occurrence
expect(screen.getByText('ON DAY(S)')).toBeInTheDocument();
// Verify that the schedule preview is shown as today is selected by default
expect(screen.getByTestId(SCHEDULE_PREVIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(NO_SCHEDULE_TEST_ID)).not.toBeInTheDocument();
});
it('render schedule preview in weekly occurence when days are selected', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence,
custom: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence
.custom,
repeatEvery: 'week',
occurence: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'],
},
},
},
}),
);
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
// Verify that the schedule preview is shown because days are selected
expect(screen.getByTestId(SCHEDULE_PREVIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(NO_SCHEDULE_TEST_ID)).not.toBeInTheDocument();
});
it('when showing monthly occurence, the occurence options should be rendered', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence,
custom: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence
.custom,
repeatEvery: 'month',
},
},
},
}),
);
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
// Verify that the "ON DAY(S)" section is rendered for monthly occurrence
expect(screen.getByText('ON DAY(S)')).toBeInTheDocument();
// Verify that the schedule preview is shown as today is selected by default
expect(screen.getByTestId(SCHEDULE_PREVIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(NO_SCHEDULE_TEST_ID)).not.toBeInTheDocument();
});
it('render schedule preview in monthly occurence when days are selected', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence,
custom: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence
.custom,
repeatEvery: 'month',
occurence: ['1'],
},
},
},
}),
);
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
// Verify that the schedule preview is shown because days are selected
expect(screen.getByTestId(SCHEDULE_PREVIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(NO_SCHEDULE_TEST_ID)).not.toBeInTheDocument();
});
it('discard action works correctly', () => {
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
fireEvent.click(screen.getByText('Discard'));
expect(mockSetIsOpen).toHaveBeenCalledWith(false);
expect(mockSetIsCustomScheduleButtonVisible).toHaveBeenCalledWith(true);
});
it('save custom schedule action works correctly', () => {
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
fireEvent.click(screen.getByText(SAVE_CUSTOM_SCHEDULE_TEXT));
expect(mockSetAdvancedOptions).toHaveBeenCalledTimes(2);
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
type: 'SET_EVALUATION_CADENCE',
payload: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence,
custom: {
...INITIAL_ADVANCED_OPTIONS_STATE_WITH_CUSTOM_SCHEDULE.evaluationCadence
.custom,
// today selected by default
occurence: [new Date().getDate().toString()],
},
},
});
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
type: 'SET_EVALUATION_CADENCE_MODE',
payload: 'custom',
});
});
describe('alert context mock state verification', () => {
it('should set the evaluation cadence tab to rrule from custom', () => {
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
// Switch to RRule tab
fireEvent.click(screen.getByText('RRule'));
expect(screen.getByTestId(RULE_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(EDITOR_VIEW_TEST_ID)).not.toBeInTheDocument();
// Type in the text box
expect(screen.getByPlaceholderText(ENTER_RRULE_PLACEHOLDER)).toHaveValue('');
fireEvent.change(screen.getByPlaceholderText(ENTER_RRULE_PLACEHOLDER), {
target: { value: 'RRULE:FREQ=DAILY' },
});
// Ensure text box content is updated
expect(screen.getByPlaceholderText(ENTER_RRULE_PLACEHOLDER)).toHaveValue(
'RRULE:FREQ=DAILY',
);
});
it('ensure rrule content is not modified by previous test', () => {
render(
<EvaluationCadenceDetails
isOpen
setIsOpen={mockSetIsOpen}
setIsCustomScheduleButtonVisible={mockSetIsCustomScheduleButtonVisible}
/>,
);
// Switch to RRule tab
fireEvent.click(screen.getByText('RRule'));
expect(screen.getByTestId(RULE_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.queryByTestId(EDITOR_VIEW_TEST_ID)).not.toBeInTheDocument();
// Verify text box content
expect(screen.getByPlaceholderText(ENTER_RRULE_PLACEHOLDER)).toHaveValue('');
});
});
});

View File

@@ -0,0 +1,88 @@
import { render, screen } from '@testing-library/react';
import * as alertState from 'container/CreateAlertV2/context';
import { INITIAL_ADVANCED_OPTIONS_STATE } from 'container/CreateAlertV2/context/constants';
import { TIMEZONE_DATA } from '../constants';
import EvaluationCadencePreview, {
ScheduleList,
} from '../EvaluationCadence/EvaluationCadencePreview';
import { createMockAlertContextState } from './testUtils';
jest
.spyOn(alertState, 'useCreateAlertState')
.mockReturnValue(createMockAlertContextState());
const mockSetIsOpen = jest.fn();
describe('EvaluationCadencePreview', () => {
it('should render list of dates when schedule is generated', () => {
render(<EvaluationCadencePreview isOpen setIsOpen={mockSetIsOpen} />);
expect(screen.getByTestId('schedule-preview')).toBeInTheDocument();
});
it('should render empty state when no schedule is generated', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
advancedOptions: {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'custom',
custom: {
repeatEvery: 'week',
startAt: '00:00:00',
occurence: [],
timezone: TIMEZONE_DATA[0].value,
},
},
},
}),
);
render(<EvaluationCadencePreview isOpen setIsOpen={mockSetIsOpen} />);
expect(screen.getByTestId('no-schedule')).toBeInTheDocument();
});
});
describe('ScheduleList', () => {
const schedule = [
new Date('2024-01-15T00:00:00Z'),
new Date('2024-01-16T00:00:00Z'),
new Date('2024-01-17T00:00:00Z'),
new Date('2024-01-18T00:00:00Z'),
new Date('2024-01-19T00:00:00Z'),
];
it('should render list of dates when schedule is generated', () => {
render(
<ScheduleList
schedule={schedule}
currentTimezone={TIMEZONE_DATA[0].value}
/>,
);
expect(
screen.queryByText(
'Please fill the relevant information to generate a schedule',
),
).not.toBeInTheDocument();
// Verify all dates are rendered correctly
schedule.forEach((date) => {
const dateString = date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
const timeString = date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const combinedString = `${dateString}, ${timeString}`;
expect(screen.getByText(combinedString)).toBeInTheDocument();
});
// Verify timezone is rendered correctly with each date
const timezoneElements = screen.getAllByText(TIMEZONE_DATA[0].label);
expect(timezoneElements).toHaveLength(schedule.length);
});
});

View File

@@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react';
import * as alertState from 'container/CreateAlertV2/context';
import * as utils from 'container/CreateAlertV2/utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import EvaluationSettings from '../EvaluationSettings';
import { createMockAlertContextState } from './testUtils';
const mockSetEvaluationWindow = jest.fn();
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
setEvaluationWindow: mockSetEvaluationWindow,
}),
);
jest.mock('../AdvancedOptions', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="advanced-options">AdvancedOptions</div>
),
}));
const EVALUATION_SETTINGS_TEXT = 'Evaluation settings';
const CHECK_CONDITIONS_USING_DATA_FROM_TEXT =
'Check conditions using data from';
describe('EvaluationSettings', () => {
it('should render the default evaluation settings layout', () => {
render(<EvaluationSettings />);
expect(screen.getByText(EVALUATION_SETTINGS_TEXT)).toBeInTheDocument();
expect(
screen.getByText(CHECK_CONDITIONS_USING_DATA_FROM_TEXT),
).toBeInTheDocument();
expect(screen.getByTestId('advanced-options')).toBeInTheDocument();
});
it('should not render evaluation window for anomaly based alert', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
alertType: AlertTypes.ANOMALY_BASED_ALERT,
}),
);
render(<EvaluationSettings />);
expect(screen.getByText(EVALUATION_SETTINGS_TEXT)).toBeInTheDocument();
expect(
screen.queryByText(CHECK_CONDITIONS_USING_DATA_FROM_TEXT),
).not.toBeInTheDocument();
});
it('should render the condensed evaluation settings layout', () => {
jest.spyOn(utils, 'showCondensedLayout').mockReturnValueOnce(true);
render(<EvaluationSettings />);
// Header, check conditions using data from and advanced options should be hidden
expect(screen.queryByText(EVALUATION_SETTINGS_TEXT)).not.toBeInTheDocument();
expect(
screen.queryByText(CHECK_CONDITIONS_USING_DATA_FROM_TEXT),
).not.toBeInTheDocument();
expect(screen.queryByTestId('advanced-options')).not.toBeInTheDocument();
// Only evaluation window popover should be visible
expect(
screen.getByTestId('condensed-evaluation-settings-container'),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,200 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import EvaluationWindowDetails from '../EvaluationWindowPopover/EvaluationWindowDetails';
import { createMockEvaluationWindowState } from './testUtils';
const mockEvaluationWindowState = createMockEvaluationWindowState();
const mockSetEvaluationWindow = jest.fn();
describe('EvaluationWindowDetails', () => {
it('should render the evaluation window details for rolling mode with custom timeframe', () => {
render(
<EvaluationWindowDetails
evaluationWindow={createMockEvaluationWindowState({
windowType: 'rolling',
timeframe: 'custom',
startingAt: {
...mockEvaluationWindowState.startingAt,
number: '5',
unit: UniversalYAxisUnit.MINUTES,
},
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
expect(
screen.getByText(
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
),
).toBeInTheDocument();
expect(screen.getByText('Specify custom duration')).toBeInTheDocument();
expect(screen.getByText('Last 5 Minutes')).toBeInTheDocument();
});
it('renders the evaluation window details for cumulative mode with current hour', () => {
render(
<EvaluationWindowDetails
evaluationWindow={createMockEvaluationWindowState({
windowType: 'cumulative',
timeframe: 'currentHour',
startingAt: {
...mockEvaluationWindowState.startingAt,
number: '1',
timezone: 'UTC',
},
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
expect(
screen.getByText('Current hour, starting at minute 1 (UTC)'),
).toBeInTheDocument();
});
it('renders the evaluation window details for cumulative mode with current day', () => {
render(
<EvaluationWindowDetails
evaluationWindow={createMockEvaluationWindowState({
windowType: 'cumulative',
timeframe: 'currentDay',
startingAt: {
...mockEvaluationWindowState.startingAt,
time: '00:00:00',
timezone: 'UTC',
},
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
expect(
screen.getByText('Current day, starting from 00:00:00 (UTC)'),
).toBeInTheDocument();
});
it('renders the evaluation window details for cumulative mode with current month', () => {
render(
<EvaluationWindowDetails
evaluationWindow={createMockEvaluationWindowState({
windowType: 'cumulative',
timeframe: 'currentMonth',
startingAt: {
...mockEvaluationWindowState.startingAt,
number: '1',
time: '00:00:00',
timezone: 'UTC',
},
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
expect(
screen.getByText('Current month, starting from day 1 at 00:00:00 (UTC)'),
).toBeInTheDocument();
});
it('should be able to change the value in rolling mode with custom timeframe', () => {
render(
<EvaluationWindowDetails
evaluationWindow={createMockEvaluationWindowState({
windowType: 'rolling',
timeframe: 'custom',
startingAt: {
...mockEvaluationWindowState.startingAt,
number: '5',
unit: UniversalYAxisUnit.MINUTES,
},
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const valueInput = screen.getByPlaceholderText('Enter value');
fireEvent.change(valueInput, { target: { value: '10' } });
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
type: 'SET_STARTING_AT',
payload: { ...mockEvaluationWindowState.startingAt, number: '10' },
});
});
it('should be able to change the value in cumulative mode with current hour', () => {
render(
<EvaluationWindowDetails
evaluationWindow={createMockEvaluationWindowState({
windowType: 'cumulative',
timeframe: 'currentHour',
startingAt: {
...mockEvaluationWindowState.startingAt,
number: '1',
timezone: 'UTC',
},
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const selectComponent = screen.getByRole('combobox');
fireEvent.mouseDown(selectComponent);
const option = screen.getByText('10');
fireEvent.click(option);
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
type: 'SET_STARTING_AT',
payload: {
...mockEvaluationWindowState.startingAt,
number: 10,
timezone: 'UTC',
},
});
});
it('should be able to change the value in cumulative mode with current day', () => {
render(
<EvaluationWindowDetails
evaluationWindow={createMockEvaluationWindowState({
windowType: 'cumulative',
timeframe: 'currentDay',
startingAt: {
...mockEvaluationWindowState.startingAt,
time: '00:00:00',
timezone: 'UTC',
},
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const timeInputs = screen.getAllByDisplayValue('00');
const hoursInput = timeInputs[0];
fireEvent.change(hoursInput, { target: { value: '10' } });
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
type: 'SET_STARTING_AT',
payload: {
...mockEvaluationWindowState.startingAt,
time: '10:00:00',
timezone: 'UTC',
},
});
});
it('should be able to change the value in cumulative mode with current month', () => {
render(
<EvaluationWindowDetails
evaluationWindow={createMockEvaluationWindowState({
windowType: 'cumulative',
timeframe: 'currentMonth',
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const comboboxes = screen.getAllByRole('combobox');
const daySelectComponent = comboboxes[0];
fireEvent.mouseDown(daySelectComponent);
const option = screen.getByText('10');
fireEvent.click(option);
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
type: 'SET_STARTING_AT',
payload: { ...mockEvaluationWindowState.startingAt, number: 10 },
});
});
});

View File

@@ -0,0 +1,298 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { EvaluationWindowState } from 'container/CreateAlertV2/context/types';
import {
EVALUATION_WINDOW_TIMEFRAME,
EVALUATION_WINDOW_TYPE,
} from '../constants';
import EvaluationWindowPopover from '../EvaluationWindowPopover';
import { createMockEvaluationWindowState } from './testUtils';
const mockEvaluationWindow: EvaluationWindowState = createMockEvaluationWindowState();
const mockSetEvaluationWindow = jest.fn();
const EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS =
'.evaluation-window-content-list-item';
const EVALUATION_WINDOW_DETAILS_TEST_ID = 'evaluation-window-details';
const ENTER_VALUE_PLACEHOLDER = 'Enter value';
const EVALUATION_WINDOW_TEXT = 'EVALUATION WINDOW';
const LAST_5_MINUTES_TEXT = 'Last 5 minutes';
jest.mock('../EvaluationWindowPopover/EvaluationWindowDetails', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid={EVALUATION_WINDOW_DETAILS_TEST_ID}>
<input placeholder={ENTER_VALUE_PLACEHOLDER} />
</div>
),
}));
describe('EvaluationWindowPopover', () => {
it('should render the evaluation window popover with 3 sections', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
expect(screen.getByText(EVALUATION_WINDOW_TEXT)).toBeInTheDocument();
});
it('should render all window type options with rolling selected', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
EVALUATION_WINDOW_TYPE.forEach((option) => {
expect(screen.getByText(option.label)).toBeInTheDocument();
});
const rollingItem = screen
.getByText('Rolling')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
expect(rollingItem).toHaveClass('active');
const cumulativeItem = screen
.getByText('Cumulative')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
expect(cumulativeItem).not.toHaveClass('active');
});
it('should render all window type options with cumulative selected', () => {
render(
<EvaluationWindowPopover
evaluationWindow={createMockEvaluationWindowState({
windowType: 'cumulative',
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
EVALUATION_WINDOW_TYPE.forEach((option) => {
expect(screen.getByText(option.label)).toBeInTheDocument();
});
const cumulativeItem = screen
.getByText('Cumulative')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
expect(cumulativeItem).toHaveClass('active');
const rollingItem = screen
.getByText('Rolling')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
expect(rollingItem).not.toHaveClass('active');
});
it('should render all timeframe options in rolling mode with last 5 minutes selected by default', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
EVALUATION_WINDOW_TIMEFRAME.rolling.forEach((option) => {
expect(screen.getByText(option.label)).toBeInTheDocument();
});
const last5MinutesItem = screen
.getByText(LAST_5_MINUTES_TEXT)
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
expect(last5MinutesItem).toHaveClass('active');
});
it('should render all timeframe options in cumulative mode with current hour selected by default', () => {
render(
<EvaluationWindowPopover
evaluationWindow={createMockEvaluationWindowState({
windowType: 'cumulative',
timeframe: 'currentHour',
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
EVALUATION_WINDOW_TIMEFRAME.cumulative.forEach((option) => {
expect(screen.getByText(option.label)).toBeInTheDocument();
});
const currentHourItem = screen
.getByText('Current hour')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
expect(currentHourItem).toHaveClass('active');
});
it('renders help text in details section for rolling mode with non-custom timeframe', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
expect(
screen.getByText(
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
),
).toBeInTheDocument();
expect(
screen.queryByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID),
).not.toBeInTheDocument();
});
it('renders EvaluationWindowDetails component in details section for rolling mode with custom timeframe', () => {
render(
<EvaluationWindowPopover
evaluationWindow={createMockEvaluationWindowState({
timeframe: 'custom',
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
expect(
screen.queryByText(
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
),
).not.toBeInTheDocument();
expect(
screen.getByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID),
).toBeInTheDocument();
});
it('renders EvaluationWindowDetails component in details section for cumulative mode', () => {
render(
<EvaluationWindowPopover
evaluationWindow={createMockEvaluationWindowState({
windowType: 'cumulative',
timeframe: 'currentHour',
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
expect(
screen.queryByText(
'A Cumulative Window has a fixed starting point and expands over time.',
),
).not.toBeInTheDocument();
expect(
screen.getByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID),
).toBeInTheDocument();
});
describe('keyboard navigation', () => {
it('should navigate down through window type options', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const rollingItem = screen
.getByText('Rolling')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
rollingItem?.focus();
fireEvent.keyDown(rollingItem, { key: 'ArrowDown' });
const cumulativeItem = screen
.getByText('Cumulative')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
expect(cumulativeItem).toHaveFocus();
});
it('should navigate up through window type options', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const cumulativeItem = screen
.getByText('Cumulative')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
cumulativeItem?.focus();
fireEvent.keyDown(cumulativeItem, { key: 'ArrowUp' });
const rollingItem = screen
.getByText('Rolling')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
expect(rollingItem).toHaveFocus();
});
it('should navigate right from window type to timeframe', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const rollingItem = screen
.getByText('Rolling')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
rollingItem?.focus();
fireEvent.keyDown(rollingItem, { key: 'ArrowRight' });
const timeframeItem = screen
.getByText(LAST_5_MINUTES_TEXT)
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
expect(timeframeItem).toHaveFocus();
});
it('should navigate left from timeframe to window type', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const timeframeItem = screen
.getByText(LAST_5_MINUTES_TEXT)
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
timeframeItem?.focus();
fireEvent.keyDown(timeframeItem, { key: 'ArrowLeft' });
const rollingItem = screen
.getByText('Rolling')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
expect(rollingItem).toHaveFocus();
});
it('should select option with Enter key', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const cumulativeItem = screen
.getByText('Cumulative')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
cumulativeItem?.focus();
fireEvent.keyDown(cumulativeItem, { key: 'Enter' });
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
type: 'SET_WINDOW_TYPE',
payload: 'cumulative',
});
});
it('should select option with Space key', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const cumulativeItem = screen
.getByText('Cumulative')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
cumulativeItem?.focus();
fireEvent.keyDown(cumulativeItem, { key: ' ' });
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
type: 'SET_WINDOW_TYPE',
payload: 'cumulative',
});
});
});
});

View File

@@ -0,0 +1,37 @@
import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_NOTIFICATION_SETTINGS_STATE,
} from 'container/CreateAlertV2/context/constants';
import {
EvaluationWindowState,
ICreateAlertContextProps,
} from 'container/CreateAlertV2/context/types';
import { AlertTypes } from 'types/api/alerts/alertTypes';
export const createMockAlertContextState = (
overrides?: Partial<ICreateAlertContextProps>,
): ICreateAlertContextProps => ({
alertState: INITIAL_ALERT_STATE,
setAlertState: jest.fn(),
alertType: AlertTypes.METRICS_BASED_ALERT,
setAlertType: jest.fn(),
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
setThresholdState: jest.fn(),
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE,
setAdvancedOptions: jest.fn(),
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
setEvaluationWindow: jest.fn(),
notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE,
setNotificationSettings: jest.fn(),
...overrides,
});
export const createMockEvaluationWindowState = (
overrides?: Partial<EvaluationWindowState>,
): EvaluationWindowState => ({
...INITIAL_EVALUATION_WINDOW_STATE,
...overrides,
});

View File

@@ -14,6 +14,7 @@ export const EVALUATION_WINDOW_TIMEFRAME = {
{ label: 'Last 1 hour', value: '1h0m0s' },
{ label: 'Last 2 hours', value: '2h0m0s' },
{ label: 'Last 4 hours', value: '4h0m0s' },
{ label: 'Custom', value: 'custom' },
],
cumulative: [
{ label: 'Current hour', value: 'currentHour' },
@@ -23,6 +24,7 @@ export const EVALUATION_WINDOW_TIMEFRAME = {
};
export const EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS = [
{ label: 'DAY', value: 'day' },
{ label: 'WEEK', value: 'week' },
{ label: 'MONTH', value: 'month' },
];
@@ -59,3 +61,9 @@ export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
label: `${timezone.name} (${timezone.offset})`,
value: timezone.value,
}));
export const CUMULATIVE_WINDOW_DESCRIPTION =
'A Cumulative Window has a fixed starting point and expands over time.';
export const ROLLING_WINDOW_DESCRIPTION =
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.';

View File

@@ -0,0 +1,3 @@
import EvaluationSettings from './EvaluationSettings';
export default EvaluationSettings;

View File

@@ -0,0 +1,389 @@
.evaluation-settings-container {
margin: 16px;
.evaluate-alert-conditions-container {
display: flex;
align-items: center;
gap: 16px;
background-color: var(--bg-ink-400);
padding: 16px;
border-radius: 4px;
border: 1px solid var(--bg-slate-500);
margin-bottom: 16px;
.ant-typography {
color: var(--bg-vanilla-400);
font-size: 14px;
}
.evaluate-alert-conditions-separator {
flex: 1;
height: 1px;
border-top: 1px dashed var(--bg-slate-400);
}
.ant-btn {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
.evaluate-alert-conditions-button-left {
color: var(--bg-vanilla-400);
font-size: 12px;
padding-right: 16px;
}
.evaluate-alert-conditions-button-right {
display: flex;
align-items: center;
color: var(--bg-vanilla-400);
gap: 8px;
.evaluate-alert-conditions-button-right-text {
font-size: 12px;
font-weight: 500;
background-color: var(--bg-slate-400);
padding: 1px 4px;
}
}
}
}
}
.advanced-options-container {
.ant-collapse {
.ant-collapse-item {
.ant-collapse-header {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-500);
.ant-collapse-header-text {
color: var(--bg-vanilla-400);
font-family: Inter;
}
}
.ant-collapse-content {
.ant-collapse-content-box {
background-color: var(--bg-ink-400);
}
}
}
}
}
.ant-popover-arrow {
display: none !important;
}
.ant-popover-content {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
border-radius: 4px;
padding: 0;
margin: 10px;
.ant-popover-inner {
background-color: var(--bg-ink-400);
border: none;
padding: 0;
.evaluation-window-popover {
min-width: 500px;
.evaluation-window-content {
display: flex;
.evaluation-window-content-item {
display: flex;
flex-direction: column;
gap: 8px;
border-right: 1px solid var(--bg-slate-400);
padding: 12px 16px;
min-width: 250px;
min-height: 300px;
.evaluation-window-content-item-label {
color: var(--bg-slate-50);
font-size: 11px;
line-height: 18px;
font-weight: 500;
}
.evaluation-window-content-list {
display: flex;
flex-direction: column;
.evaluation-window-content-list-item {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 -16px;
padding: 4px 16px;
.ant-typography {
color: var(--bg-vanilla-400);
font-weight: 400;
}
&.active {
background-color: var(--bg-slate-500);
border-left: 2px solid var(--bg-robin-500);
.ant-typography {
font-weight: 500;
color: var(--bg-vanilla-100);
}
}
&:hover {
cursor: pointer;
background-color: var(--bg-slate-500);
}
}
}
}
.selection-content {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
width: 400px;
.ant-typography {
color: var(--bg-vanilla-400);
}
.ant-btn {
width: fit-content;
}
}
}
.evaluation-window-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
background-color: var(--bg-ink-300);
border-top: 1px solid var(--bg-slate-400);
padding: 16px;
}
.ant-btn {
background-color: var(--bg-ink-200);
border: 1px solid var(--bg-slate-200);
color: var(--bg-vanilla-400);
font-size: 14px;
}
}
}
}
.evaluation-window-details {
display: flex;
flex-direction: column;
gap: 16px;
width: 400px;
min-height: 300px;
padding: 16px;
.select-group {
display: flex;
flex-direction: column;
gap: 2px;
.ant-typography {
color: var(--bg-slate-50);
font-size: 11px;
line-height: 18px;
font-weight: 500;
}
}
.time-select-group {
.ant-input-group {
flex-direction: row;
gap: 8px;
.ant-select {
width: 40px;
}
}
}
.ant-typography {
color: var(--bg-vanilla-400);
font-size: 13px;
font-weight: 500;
}
.ant-select {
width: 60%;
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
height: 32px;
.ant-select-selector {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
height: 32px;
}
&:hover {
border-color: var(--bg-ink-400);
}
}
.select-group .ant-input:not(.time-input-field) {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
height: 32px;
width: 60%;
}
}
.lightMode {
.evaluation-settings-container {
.evaluate-alert-conditions-container {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
.ant-typography {
color: var(--bg-ink-400);
}
.evaluate-alert-conditions-separator {
border-top: 1px dashed var(--bg-vanilla-300);
}
.ant-btn {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
.evaluate-alert-conditions-button-left {
color: var(--bg-ink-400);
}
.evaluate-alert-conditions-button-right {
color: var(--bg-ink-400);
.evaluate-alert-conditions-button-right-text {
background-color: var(--bg-vanilla-300);
}
}
}
}
}
.advanced-options-container {
.ant-collapse {
.ant-collapse-item {
.ant-collapse-header {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
.ant-collapse-header-text {
color: var(--bg-ink-400);
}
}
.ant-collapse-content {
.ant-collapse-content-box {
background-color: var(--bg-vanilla-200);
}
}
}
}
}
.ant-popover-content {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
.ant-popover-inner {
background-color: var(--bg-vanilla-200);
.evaluation-window-popover {
.evaluation-window-content {
.evaluation-window-content-item {
border-right: 1px solid var(--bg-vanilla-300);
.evaluation-window-content-item-label {
color: var(--bg-ink-300);
}
.evaluation-window-content-list {
.evaluation-window-content-list-item {
.ant-typography {
color: var(--bg-ink-400);
}
&.active {
background-color: var(--bg-vanilla-300);
border-left: 2px solid var(--bg-robin-500);
.ant-typography {
color: var(--bg-ink-400);
}
}
&:hover {
background-color: var(--bg-vanilla-300);
}
}
}
}
.selection-content {
.ant-typography {
color: var(--bg-ink-400);
}
}
}
.evaluation-window-footer {
background-color: var(--bg-vanilla-300);
border-top: 1px solid var(--bg-vanilla-300);
}
.ant-btn {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
}
}
}
.evaluation-window-details {
.select-group {
.ant-typography {
color: var(--bg-ink-300);
}
}
.ant-typography {
color: var(--bg-ink-400);
}
.ant-select {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
.ant-select-selector {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
&:hover {
border-color: var(--bg-vanilla-200);
}
}
}
}

View File

@@ -9,6 +9,8 @@ export interface IAdvancedOptionItemProps {
title: string;
description: string;
input: JSX.Element;
tooltipText?: string;
onToggle?: () => void;
}
export enum RollingWindowTimeframes {
@@ -30,8 +32,6 @@ export enum CumulativeWindowTimeframes {
export interface IEvaluationWindowPopoverProps {
evaluationWindow: EvaluationWindowState;
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export interface IEvaluationWindowDetailsProps {
@@ -42,6 +42,12 @@ export interface IEvaluationWindowDetailsProps {
export interface IEvaluationCadenceDetailsProps {
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
setIsCustomScheduleButtonVisible: Dispatch<SetStateAction<boolean>>;
}
export interface IEvaluationCadencePreviewProps {
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export interface TimeInputProps {
@@ -51,3 +57,13 @@ export interface TimeInputProps {
disabled?: boolean;
className?: string;
}
export interface IEditCustomScheduleProps {
setIsEvaluationCadenceDetailsVisible: (isOpen: boolean) => void;
setIsPreviewVisible: (isOpen: boolean) => void;
}
export interface IScheduleListProps {
schedule: Date[] | null;
currentTimezone: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: [],
},
});
});
});
});

View File

@@ -0,0 +1,3 @@
import NotificationSettings from './NotificationSettings';
export default NotificationSettings;

View File

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

View File

@@ -13,6 +13,7 @@ import {
AlertThresholdState,
Algorithm,
EvaluationWindowState,
NotificationSettingsState,
Seasonality,
Threshold,
TimeDuration,
@@ -94,14 +95,14 @@ export const INITIAL_ADVANCED_OPTIONS_STATE: AdvancedOptionsState = {
timeUnit: UniversalYAxisUnit.MINUTES,
},
custom: {
repeatEvery: 'week',
startAt: '00:00:00',
repeatEvery: 'day',
startAt: dayjs().format('HH:mm:ss'),
occurence: [],
timezone: TIMEZONE_DATA[0].value,
},
rrule: {
date: dayjs(),
startAt: '00:00:00',
startAt: dayjs().format('HH:mm:ss'),
rrule: '',
},
},
@@ -111,7 +112,7 @@ export const INITIAL_EVALUATION_WINDOW_STATE: EvaluationWindowState = {
windowType: 'rolling',
timeframe: '5m0s',
startingAt: {
time: '00:00:00',
time: dayjs().format('HH:mm:ss'),
number: '1',
timezone: TIMEZONE_DATA[0].value,
unit: UniversalYAxisUnit.MINUTES,
@@ -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,
};

View File

@@ -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,
],
);

View File

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

View File

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

View File

@@ -1,3 +1,9 @@
// UI side feature flag
export const showNewCreateAlertsPage = (): boolean =>
localStorage.getItem('showNewCreateAlertsPage') === 'true';
// UI side FF to switch between the 2 layouts of the create alert page
// Layout 1 - Default layout
// Layout 2 - Condensed layout
export const showCondensedLayout = (): boolean =>
localStorage.getItem('showCondensedLayout') === 'true';

View File

@@ -17,6 +17,7 @@ export default function LogsError(): JSX.Element {
window.open('https://signoz.io/slack', '_blank');
}
};
return (
<div className="logs-error-container">
<div className="logs-error-content">

View File

@@ -57,14 +57,14 @@
border-bottom: 1px solid var(--bg-slate-400);
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
margin-right: 16px;
padding: 0 8px;
box-sizing: border-box;
.dashboard-breadcrumbs {
width: 100%;
height: 48px;
padding: 16px;
display: flex;
gap: 6px;
align-items: center;

View File

@@ -12,6 +12,7 @@ import {
Typography,
} from 'antd';
import logEvent from 'api/common/logEvent';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { QueryParams } from 'constants/query';
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
@@ -321,6 +322,12 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
{title}
</Button>
</section>
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
</div>
<section className="dashboard-details">
<div className="left-section">

View File

@@ -300,6 +300,7 @@ function RightContainer({
style={{ width: '100%' }}
className="panel-type-select"
data-testid="panel-change-select"
data-stacking-state={stackedBarChart ? 'true' : 'false'}
>
{graphTypes.map((item) => (
<Option key={item.name} value={item.name}>

View File

@@ -1,3 +1,4 @@
/* eslint-disable sonarjs/no-duplicate-string */
// This test suite covers several important scenarios:
// - Empty layout - widget should be placed at origin (0,0)
// - Empty layout with custom dimensions
@@ -6,13 +7,20 @@
// - Handling multiple rows correctly
// - Handling widgets with different heights
import { screen } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { I18nextProvider } from 'react-i18next';
import { useSearchParams } from 'react-router-dom-v5-compat';
import i18n from 'ReactI18';
import { render } from 'tests/test-utils';
import {
fireEvent,
getByText as getByTextUtil,
render,
userEvent,
within,
} from 'tests/test-utils';
import NewWidget from '..';
import {
@@ -21,6 +29,28 @@ import {
placeWidgetBetweenRows,
} from '../utils';
// Helper function to check stack series state
const checkStackSeriesState = (
container: HTMLElement,
expectedChecked: boolean,
): HTMLElement => {
expect(getByTextUtil(container, 'Stack series')).toBeInTheDocument();
const stackSeriesSection = container.querySelector(
'section > .stack-chart',
) as HTMLElement;
expect(stackSeriesSection).toBeInTheDocument();
const switchElement = within(stackSeriesSection).getByRole('switch');
if (expectedChecked) {
expect(switchElement).toBeChecked();
} else {
expect(switchElement).not.toBeChecked();
}
return switchElement;
};
const MOCK_SEARCH_PARAMS =
'?graphType=bar&widgetId=b473eef0-8eb5-4dd3-8089-c1817734084f&compositeQuery=%7B"id"%3A"f026c678-9abf-42af-a3dc-f73dc8cbb810"%2C"builder"%3A%7B"queryData"%3A%5B%7B"dataSource"%3A"metrics"%2C"queryName"%3A"A"%2C"aggregateOperator"%3A"count"%2C"aggregateAttribute"%3A%7B"id"%3A"----"%2C"dataType"%3A""%2C"key"%3A""%2C"type"%3A""%7D%2C"timeAggregation"%3A"rate"%2C"spaceAggregation"%3A"sum"%2C"filter"%3A%7B"expression"%3A""%7D%2C"aggregations"%3A%5B%7B"metricName"%3A""%2C"temporality"%3A""%2C"timeAggregation"%3A"count"%2C"spaceAggregation"%3A"sum"%2C"reduceTo"%3A"avg"%7D%5D%2C"functions"%3A%5B%5D%2C"filters"%3A%7B"items"%3A%5B%5D%2C"op"%3A"AND"%7D%2C"expression"%3A"A"%2C"disabled"%3Afalse%2C"stepInterval"%3Anull%2C"having"%3A%5B%5D%2C"limit"%3Anull%2C"orderBy"%3A%5B%5D%2C"groupBy"%3A%5B%5D%2C"legend"%3A""%2C"reduceTo"%3A"avg"%2C"source"%3A""%7D%5D%2C"queryFormulas"%3A%5B%5D%2C"queryTraceOperator"%3A%5B%5D%7D%2C"clickhouse_sql"%3A%5B%7B"name"%3A"A"%2C"legend"%3A""%2C"disabled"%3Afalse%2C"query"%3A""%7D%5D%2C"promql"%3A%5B%7B"name"%3A"A"%2C"query"%3A""%2C"legend"%3A""%2C"disabled"%3Afalse%7D%5D%2C"queryType"%3A"builder"%7D&relativeTime=30m';
// Mocks
@@ -279,7 +309,7 @@ describe('Stacking bar in new panel', () => {
jest.fn(),
]);
const { container, getByText, getByRole } = render(
const { container, getByText } = render(
<I18nextProvider i18n={i18n}>
<DashboardProvider>
<PreferenceContextProvider>
@@ -305,7 +335,83 @@ describe('Stacking bar in new panel', () => {
expect(switchBtn).toBeInTheDocument();
expect(switchBtn).toHaveClass('ant-switch-checked');
// (Optional) More semantic: verify by role
expect(getByRole('switch')).toBeChecked();
// Check that stack series is present and checked
checkStackSeriesState(container, true);
});
});
const STACKING_STATE_ATTR = 'data-stacking-state';
describe('when switching to BAR panel type', () => {
jest.setTimeout(10000);
beforeEach(() => {
jest.clearAllMocks();
// Mock useSearchParams to return the expected values
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams(MOCK_SEARCH_PARAMS),
jest.fn(),
]);
});
it('should preserve saved stacking value of true', async () => {
const { getByTestId, getByText, container } = render(
<DashboardProvider>
<NewWidget
selectedGraph={PANEL_TYPES.BAR}
fillSpans={undefined}
yAxisUnit={undefined}
/>
</DashboardProvider>,
);
expect(getByTestId('panel-change-select')).toHaveAttribute(
STACKING_STATE_ATTR,
'true',
);
await userEvent.click(getByText('Bar')); // Panel Type Selected
// find dropdown with - .ant-select-dropdown
const panelDropdown = document.querySelector(
'.ant-select-dropdown',
) as HTMLElement;
expect(panelDropdown).toBeInTheDocument();
// Select TimeSeries from dropdown
const option = within(panelDropdown).getByText('Time Series');
fireEvent.click(option);
expect(getByTestId('panel-change-select')).toHaveAttribute(
STACKING_STATE_ATTR,
'false',
);
// Since we are on timeseries panel, stack series should be false
expect(screen.queryByText('Stack series')).not.toBeInTheDocument();
// switch back to Bar panel
const panelTypeDropdown2 = getByTestId('panel-change-select') as HTMLElement;
expect(panelTypeDropdown2).toBeInTheDocument();
expect(getByTextUtil(panelTypeDropdown2, 'Time Series')).toBeInTheDocument();
fireEvent.click(getByTextUtil(panelTypeDropdown2, 'Time Series'));
// find dropdown with - .ant-select-dropdown
const panelDropdown2 = document.querySelector(
'.ant-select-dropdown',
) as HTMLElement;
// // Select BAR from dropdown
const BarOption = within(panelDropdown2).getByText('Bar');
fireEvent.click(BarOption);
// Stack series should be true
checkStackSeriesState(container, true);
expect(getByTestId('panel-change-select')).toHaveAttribute(
STACKING_STATE_ATTR,
'true',
);
});
});

View File

@@ -0,0 +1,134 @@
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
export const BarNonStackedChartData = {
apiResponse: {
data: {
result: [
{
metric: {
'service.name': 'recommendationservice',
},
values: [
[1758713940, '33.933'],
[1758715020, '31.767'],
],
queryName: 'A',
metaData: {
alias: '__result_0',
index: 0,
queryName: 'A',
},
legend: '',
},
{
metric: {
'service.name': 'frontend',
},
values: [
[1758713940, '20.0'],
[1758715020, '25.0'],
],
queryName: 'B',
metaData: {
alias: '__result_1',
index: 1,
queryName: 'B',
},
legend: '',
},
],
resultType: 'time_series',
newResult: {
data: {
resultType: 'time_series',
result: [
{
queryName: 'A',
legend: '',
series: [
{
labels: {
'service.name': 'recommendationservice',
},
labelsArray: [
{
'service.name': 'recommendationservice',
},
],
values: [
{
timestamp: 1758713940000,
value: '33.933',
},
{
timestamp: 1758715020000,
value: '31.767',
},
],
metaData: {
alias: '__result_0',
index: 0,
queryName: 'A',
},
},
],
predictedSeries: [],
upperBoundSeries: [],
lowerBoundSeries: [],
anomalyScores: [],
list: null,
},
{
queryName: 'B',
legend: '',
series: [
{
labels: {
'service.name': 'frontend',
},
labelsArray: [
{
'service.name': 'frontend',
},
],
values: [
{
timestamp: 1758713940000,
value: '20.0',
},
{
timestamp: 1758715020000,
value: '25.0',
},
],
metaData: {
alias: '__result_1',
index: 1,
queryName: 'B',
},
},
],
predictedSeries: [],
upperBoundSeries: [],
lowerBoundSeries: [],
anomalyScores: [],
list: null,
},
],
},
},
},
} as MetricRangePayloadProps,
fillSpans: false,
stackedBarChart: false,
};
export const BarStackedChartData = {
...BarNonStackedChartData,
stackedBarChart: true,
};
export const TimeSeriesChartData = {
...BarNonStackedChartData,
stackedBarChart: false,
};

View File

@@ -0,0 +1,50 @@
import { getUPlotChartData } from '../../../lib/uPlotLib/utils/getUplotChartData';
import {
BarNonStackedChartData,
BarStackedChartData,
TimeSeriesChartData,
} from './__mocks__/uplotChartData';
describe('getUplotChartData', () => {
it('should return the correct chart data for non-stacked bar chart', () => {
const result = getUPlotChartData(
BarNonStackedChartData.apiResponse,
BarNonStackedChartData.fillSpans,
BarNonStackedChartData.stackedBarChart,
);
expect(result).toEqual([
[1758713940, 1758715020],
[33.933, 31.767],
[20.0, 25.0],
]);
});
it('should return the correct chart data for stacked bar chart', () => {
const result = getUPlotChartData(
BarStackedChartData.apiResponse,
BarStackedChartData.fillSpans,
BarStackedChartData.stackedBarChart,
);
// For stacked charts, the values should be cumulative
// First series: [33.933, 31.767] + [20.0, 25.0] = [53.933, 56.767]
// Second series: [20.0, 25.0] (unchanged)
expect(result).toHaveLength(3);
expect(result[0]).toEqual([1758713940, 1758715020]);
expect(result[1][0]).toBeCloseTo(53.933, 3);
expect(result[1][1]).toBeCloseTo(56.767, 3);
expect(result[2]).toEqual([20.0, 25.0]);
});
it('should return the correct chart data for time series chart', () => {
const result = getUPlotChartData(
TimeSeriesChartData.apiResponse,
TimeSeriesChartData.fillSpans,
TimeSeriesChartData.stackedBarChart,
);
expect(result).toEqual([
[1758713940, 1758715020],
[33.933, 31.767],
[20.0, 25.0],
]);
});
});

View File

@@ -595,6 +595,13 @@ function NewWidget({
selectedGraph,
);
setGraphType(type);
// with a single source of truth for stacking, we can use the saved stacking value as a default value
const savedStackingValue = getWidget()?.stackedBarChart;
setStackedBarChart(
type === PANEL_TYPES.BAR ? savedStackingValue || false : false,
);
redirectWithQueryBuilderData(
updatedQuery,
{ [QueryParams.graphType]: type },

View File

@@ -553,7 +553,7 @@ export const getDefaultWidgetData = (
timePreferance: 'GLOBAL_TIME',
softMax: null,
softMin: null,
stackedBarChart: true,
stackedBarChart: name === PANEL_TYPES.BAR,
selectedLogFields: defaultLogsSelectedColumns.map((field) => ({
...field,
type: field.fieldContext ?? '',

View File

@@ -2,14 +2,14 @@
import '../OnboardingQuestionaire.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button, Input, Typography } from 'antd';
import { Button, Checkbox, Input, Typography } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import logEvent from 'api/common/logEvent';
import { ArrowLeft, ArrowRight, CheckCircle } from 'lucide-react';
import { useEffect, useState } from 'react';
export interface SignozDetails {
interestInSignoz: string | null;
interestInSignoz: string[] | null;
otherInterestInSignoz: string | null;
discoverSignoz: string | null;
}
@@ -22,9 +22,12 @@ interface AboutSigNozQuestionsProps {
}
const interestedInOptions: Record<string, string> = {
savingCosts: 'Saving costs',
otelNativeStack: 'Interested in Otel-native stack',
allInOne: 'All in one (Logs, Metrics & Traces)',
loweringCosts: 'Lowering observability costs',
otelNativeStack: 'Interested in OTel-native stack',
deploymentFlexibility: 'Deployment flexibility (Cloud/Self-Host) in future',
singleTool:
'Single Tool (logs, metrics & traces) to reduce operational overhead',
correlateSignals: 'Correlate signals for faster troubleshooting',
};
export function AboutSigNozQuestions({
@@ -33,8 +36,8 @@ export function AboutSigNozQuestions({
onNext,
onBack,
}: AboutSigNozQuestionsProps): JSX.Element {
const [interestInSignoz, setInterestInSignoz] = useState<string | null>(
signozDetails?.interestInSignoz || null,
const [interestInSignoz, setInterestInSignoz] = useState<string[]>(
signozDetails?.interestInSignoz || [],
);
const [otherInterestInSignoz, setOtherInterestInSignoz] = useState<string>(
signozDetails?.otherInterestInSignoz || '',
@@ -47,8 +50,8 @@ export function AboutSigNozQuestions({
useEffect((): void => {
if (
discoverSignoz !== '' &&
interestInSignoz !== null &&
(interestInSignoz !== 'Others' || otherInterestInSignoz !== '')
interestInSignoz.length > 0 &&
(!interestInSignoz.includes('Others') || otherInterestInSignoz !== '')
) {
setIsNextDisabled(false);
} else {
@@ -56,6 +59,14 @@ export function AboutSigNozQuestions({
}
}, [interestInSignoz, otherInterestInSignoz, discoverSignoz]);
const handleInterestChange = (option: string, checked: boolean): void => {
if (checked) {
setInterestInSignoz((prev) => [...prev, option]);
} else {
setInterestInSignoz((prev) => prev.filter((item) => item !== option));
}
};
const handleOnNext = (): void => {
setSignozDetails({
discoverSignoz,
@@ -108,50 +119,45 @@ export function AboutSigNozQuestions({
<div className="form-group">
<div className="question">What got you interested in SigNoz?</div>
<div className="two-column-grid">
<div className="checkbox-grid">
{Object.keys(interestedInOptions).map((option: string) => (
<Button
key={option}
type="primary"
className={`onboarding-questionaire-button ${
interestInSignoz === option ? 'active' : ''
}`}
onClick={(): void => setInterestInSignoz(option)}
>
{interestedInOptions[option]}
{interestInSignoz === option && (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
)}
</Button>
<div key={option} className="checkbox-item">
<Checkbox
checked={interestInSignoz.includes(option)}
onChange={(e): void => handleInterestChange(option, e.target.checked)}
>
{interestedInOptions[option]}
</Checkbox>
</div>
))}
{interestInSignoz === 'Others' ? (
<Input
type="text"
className="onboarding-questionaire-other-input"
placeholder="Please specify your interest"
value={otherInterestInSignoz}
autoFocus
addonAfter={
otherInterestInSignoz !== '' ? (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
) : (
''
)
<div className="checkbox-item">
<Checkbox
checked={interestInSignoz.includes('Others')}
onChange={(e): void =>
handleInterestChange('Others', e.target.checked)
}
onChange={(e): void => setOtherInterestInSignoz(e.target.value)}
/>
) : (
<Button
type="primary"
className={`onboarding-questionaire-button ${
interestInSignoz === 'Others' ? 'active' : ''
}`}
onClick={(): void => setInterestInSignoz('Others')}
>
Others
</Button>
)}
</Checkbox>
{interestInSignoz.includes('Others') && (
<Input
type="text"
className="onboarding-questionaire-other-input"
placeholder="Please specify your interest"
value={otherInterestInSignoz}
autoFocus
addonAfter={
otherInterestInSignoz !== '' ? (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
) : (
''
)
}
onChange={(e): void => setOtherInterestInSignoz(e.target.value)}
/>
)}
</div>
</div>
</div>
</div>

View File

@@ -94,6 +94,7 @@
border-radius: 4px;
font-size: 14px;
padding: 12px;
font-weight: 400;
&::placeholder {
color: var(--bg-vanilla-400);
@@ -290,6 +291,37 @@
gap: 10px;
}
.checkbox-grid {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 12px;
}
.checkbox-item {
display: flex;
flex-direction: column;
gap: 8px;
.ant-checkbox-wrapper {
color: var(--bg-vanilla-400);
font-size: 14px;
font-weight: 400;
.ant-checkbox {
.ant-checkbox-inner {
border-color: var(--bg-slate-100);
background-color: var(--bg-ink-200);
}
&.ant-checkbox-checked .ant-checkbox-inner {
background-color: var(--bg-robin-500);
border-color: var(--bg-robin-500);
}
}
}
}
.onboarding-questionaire-button,
.add-another-member-button,
.remove-team-member-button {
@@ -466,6 +498,7 @@
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
color: var(--text-ink-300);
font-weight: 400;
&::placeholder {
color: var(--bg-slate-400);
@@ -527,6 +560,24 @@
color: var(--bg-slate-300);
}
.checkbox-item {
.ant-checkbox-wrapper {
color: var(--bg-ink-300);
.ant-checkbox {
.ant-checkbox-inner {
border-color: var(--bg-vanilla-300);
background-color: var(--bg-vanilla-100);
}
&.ant-checkbox-checked .ant-checkbox-inner {
background-color: var(--bg-robin-500);
border-color: var(--bg-robin-500);
}
}
}
}
input[type='text'] {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);

View File

@@ -38,6 +38,7 @@ const observabilityTools = {
AzureAppMonitor: 'Azure App Monitor',
GCPNativeO11yTools: 'GCP-native o11y tools',
Honeycomb: 'Honeycomb',
None: 'None/Starting fresh',
};
function OrgQuestions({
@@ -53,9 +54,6 @@ function OrgQuestions({
const [organisationName, setOrganisationName] = useState<string>(
orgDetails?.organisationName || '',
);
const [usesObservability, setUsesObservability] = useState<boolean | null>(
orgDetails?.usesObservability || null,
);
const [observabilityTool, setObservabilityTool] = useState<string | null>(
orgDetails?.observabilityTool || null,
);
@@ -83,7 +81,7 @@ function OrgQuestions({
orgDetails.organisationName === organisationName
) {
logEvent('Org Onboarding: Answered', {
usesObservability,
usesObservability: !observabilityTool?.includes('None'),
observabilityTool,
otherTool,
usesOtel,
@@ -91,7 +89,7 @@ function OrgQuestions({
onNext({
organisationName,
usesObservability,
usesObservability: !observabilityTool?.includes('None'),
observabilityTool,
otherTool,
usesOtel,
@@ -114,7 +112,7 @@ function OrgQuestions({
});
logEvent('Org Onboarding: Answered', {
usesObservability,
usesObservability: !observabilityTool?.includes('None'),
observabilityTool,
otherTool,
usesOtel,
@@ -122,7 +120,7 @@ function OrgQuestions({
onNext({
organisationName,
usesObservability,
usesObservability: !observabilityTool?.includes('None'),
observabilityTool,
otherTool,
usesOtel,
@@ -152,16 +150,16 @@ function OrgQuestions({
};
const isValidUsesObservability = (): boolean => {
if (usesObservability === null) {
return false;
}
if (usesObservability && (!observabilityTool || observabilityTool === '')) {
if (!observabilityTool || observabilityTool === '') {
return false;
}
// eslint-disable-next-line sonarjs/prefer-single-boolean-return
if (usesObservability && observabilityTool === 'Others' && otherTool === '') {
if (
!observabilityTool?.includes('None') &&
observabilityTool === 'Others' &&
otherTool === ''
) {
return false;
}
@@ -177,13 +175,7 @@ function OrgQuestions({
setIsNextDisabled(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
organisationName,
usesObservability,
usesOtel,
observabilityTool,
otherTool,
]);
}, [organisationName, usesOtel, observabilityTool, otherTool]);
const handleOnNext = (): void => {
handleOrgNameUpdate();
@@ -217,99 +209,57 @@ function OrgQuestions({
</div>
<div className="form-group">
<label className="question" htmlFor="usesObservability">
Do you currently use any observability/monitoring tool?
<label className="question" htmlFor="observabilityTool">
Which observability tool do you currently use?
</label>
<div className="two-column-grid">
<Button
type="primary"
name="usesObservability"
className={`onboarding-questionaire-button ${
usesObservability === true ? 'active' : ''
}`}
onClick={(): void => {
setUsesObservability(true);
}}
>
Yes{' '}
{usesObservability === true && (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
)}
</Button>
<Button
type="primary"
className={`onboarding-questionaire-button ${
usesObservability === false ? 'active' : ''
}`}
onClick={(): void => {
setUsesObservability(false);
setObservabilityTool(null);
setOtherTool('');
}}
>
No{' '}
{usesObservability === false && (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
)}
</Button>
{Object.keys(observabilityTools).map((tool) => (
<Button
key={tool}
type="primary"
className={`onboarding-questionaire-button ${
observabilityTool === tool ? 'active' : ''
}`}
onClick={(): void => setObservabilityTool(tool)}
>
{observabilityTools[tool as keyof typeof observabilityTools]}
{observabilityTool === tool && (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
)}
</Button>
))}
{observabilityTool === 'Others' ? (
<Input
type="text"
className="onboarding-questionaire-other-input"
placeholder="Please specify the tool"
value={otherTool || ''}
autoFocus
addonAfter={
otherTool && otherTool !== '' ? (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
) : (
''
)
}
onChange={(e): void => setOtherTool(e.target.value)}
/>
) : (
<Button
type="primary"
className={`onboarding-questionaire-button ${
observabilityTool === 'Others' ? 'active' : ''
}`}
onClick={(): void => setObservabilityTool('Others')}
>
Others
</Button>
)}
</div>
</div>
{usesObservability && (
<div className="form-group">
<label className="question" htmlFor="observabilityTool">
Which observability tool do you currently use?
</label>
<div className="two-column-grid">
{Object.keys(observabilityTools).map((tool) => (
<Button
key={tool}
type="primary"
className={`onboarding-questionaire-button ${
observabilityTool === tool ? 'active' : ''
}`}
onClick={(): void => setObservabilityTool(tool)}
>
{observabilityTools[tool as keyof typeof observabilityTools]}
{observabilityTool === tool && (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
)}
</Button>
))}
{observabilityTool === 'Others' ? (
<Input
type="text"
className="onboarding-questionaire-other-input"
placeholder="Please specify the tool"
value={otherTool || ''}
autoFocus
addonAfter={
otherTool && otherTool !== '' ? (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
) : (
''
)
}
onChange={(e): void => setOtherTool(e.target.value)}
/>
) : (
<Button
type="primary"
className={`onboarding-questionaire-button ${
observabilityTool === 'Others' ? 'active' : ''
}`}
onClick={(): void => setObservabilityTool('Others')}
>
Others
</Button>
)}
</div>
</div>
)}
<div className="form-group">
<div className="question">Do you already use OpenTelemetry?</div>
<div className="two-column-grid">

View File

@@ -46,7 +46,7 @@ const INITIAL_ORG_DETAILS: OrgDetails = {
};
const INITIAL_SIGNOZ_DETAILS: SignozDetails = {
interestInSignoz: '',
interestInSignoz: [],
otherInterestInSignoz: '',
discoverSignoz: '',
};
@@ -145,6 +145,9 @@ function OnboardingQuestionaire(): JSX.Element {
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
// Allow user to proceed even if API fails
setCurrentStep(4);
},
},
);
@@ -174,10 +177,16 @@ function OnboardingQuestionaire(): JSX.Element {
? (orgDetails?.otherTool as string)
: (orgDetails?.observabilityTool as string),
where_did_you_discover_signoz: signozDetails?.discoverSignoz as string,
reasons_for_interest_in_signoz:
signozDetails?.interestInSignoz === 'Others'
? (signozDetails?.otherInterestInSignoz as string)
: (signozDetails?.interestInSignoz as string),
reasons_for_interest_in_signoz: signozDetails?.interestInSignoz?.includes(
'Others',
)
? ([
...(signozDetails?.interestInSignoz?.filter(
(item) => item !== 'Others',
) || []),
signozDetails?.otherInterestInSignoz,
] as string[])
: (signozDetails?.interestInSignoz as string[]),
logs_scale_per_day_in_gb: optimiseSignozDetails?.logsPerDay as number,
number_of_hosts: optimiseSignozDetails?.hostsPerDay as number,
number_of_services: optimiseSignozDetails?.services as number,

View File

@@ -45,6 +45,13 @@ function UplotPanelWrapper({
const isDarkMode = useIsDarkMode();
const lineChartRef = useRef<ToggleGraphProps>();
const graphRef = useRef<HTMLDivElement>(null);
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
const { currentQuery } = useQueryBuilder();
@@ -117,15 +124,23 @@ function UplotPanelWrapper({
queryResponse.data.payload.data.result = sortedSeriesData;
}
const stackedBarChart = useMemo(
() =>
(selectedGraph
? selectedGraph === PANEL_TYPES.BAR
: widget?.panelTypes === PANEL_TYPES.BAR) && widget?.stackedBarChart,
[selectedGraph, widget?.panelTypes, widget?.stackedBarChart],
);
const chartData = getUPlotChartData(
queryResponse?.data?.payload,
widget.fillSpans,
widget?.stackedBarChart,
stackedBarChart,
hiddenGraph,
);
useEffect(() => {
if (widget.panelTypes === PANEL_TYPES.BAR && widget?.stackedBarChart) {
if (widget.panelTypes === PANEL_TYPES.BAR && stackedBarChart) {
const graphV = cloneDeep(graphVisibility)?.slice(1);
const isSomeSelectedLegend = graphV?.some((v) => v === false);
if (isSomeSelectedLegend) {
@@ -138,7 +153,7 @@ function UplotPanelWrapper({
}
}
}
}, [graphVisibility, hiddenGraph, widget.panelTypes, widget?.stackedBarChart]);
}, [graphVisibility, hiddenGraph, widget.panelTypes, stackedBarChart]);
const { timezone } = useTimezone();
@@ -214,7 +229,7 @@ function UplotPanelWrapper({
setGraphsVisibilityStates: setGraphVisibility,
panelType: selectedGraph || widget.panelTypes,
currentQuery,
stackBarChart: widget?.stackedBarChart,
stackBarChart: stackedBarChart,
hiddenGraph,
setHiddenGraph,
customTooltipElement,
@@ -227,6 +242,13 @@ function UplotPanelWrapper({
enhancedLegend: true, // Enable enhanced legend
legendPosition: widget?.legendPosition,
query: widget?.query || currentQuery,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
}),
[
queryResponse.data?.payload,
@@ -247,6 +269,7 @@ function UplotPanelWrapper({
enableDrillDown,
onClickHandler,
widget,
stackedBarChart,
],
);
@@ -260,14 +283,14 @@ function UplotPanelWrapper({
items={menuItemsConfig.items}
onClose={onClose}
/>
{widget?.stackedBarChart && isFullViewMode && (
{stackedBarChart && isFullViewMode && (
<Alert
message="Selecting multiple legends is currently not supported in case of stacked bar charts"
type="info"
className="info-text"
/>
)}
{isFullViewMode && setGraphVisibility && !widget?.stackedBarChart && (
{isFullViewMode && setGraphVisibility && !stackedBarChart && (
<GraphManager
data={getUPlotChartData(queryResponse?.data?.payload, widget.fillSpans)}
name={widget.id}

View File

@@ -0,0 +1,218 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { LegendPosition } from 'types/api/dashboard/getAll';
// Mock uPlot
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
// Mock dependencies
jest.mock('container/PanelWrapper/enhancedLegend', () => ({
calculateEnhancedLegendConfig: jest.fn(() => ({
minHeight: 46,
maxHeight: 80,
calculatedHeight: 60,
showScrollbar: false,
requiredRows: 2,
})),
applyEnhancedLegendStyling: jest.fn(),
}));
const mockApiResponse = {
data: {
result: [
{
metric: { __name__: 'test_metric' },
queryName: 'test_query',
values: [
[1640995200, '10'] as [number, string],
[1640995260, '20'] as [number, string],
],
},
],
resultType: 'time_series',
newResult: {
data: {
result: [],
resultType: 'time_series',
},
},
},
};
const mockDimensions = { width: 800, height: 400 };
const baseOptions = {
id: 'test-widget',
dimensions: mockDimensions,
isDarkMode: false,
apiResponse: mockApiResponse,
enhancedLegend: true,
legendPosition: LegendPosition.BOTTOM,
softMin: null,
softMax: null,
};
describe('Legend Scroll Position Preservation', () => {
let originalRequestAnimationFrame: typeof global.requestAnimationFrame;
beforeEach(() => {
jest.clearAllMocks();
originalRequestAnimationFrame = global.requestAnimationFrame;
});
afterEach(() => {
global.requestAnimationFrame = originalRequestAnimationFrame;
});
it('should set up scroll position tracking in ready hook', () => {
const mockSetScrollPosition = jest.fn();
const options = getUPlotChartOptions({
...baseOptions,
setLegendScrollPosition: mockSetScrollPosition,
});
// Create mock chart with legend element
const mockChart = {
root: document.createElement('div'),
} as any;
const legend = document.createElement('div');
legend.className = 'u-legend';
mockChart.root.appendChild(legend);
const addEventListenerSpy = jest.spyOn(legend, 'addEventListener');
// Execute ready hook
if (options.hooks?.ready) {
options.hooks.ready.forEach((hook) => hook?.(mockChart));
}
// Verify that scroll event listener was added and cleanup function was stored
expect(addEventListenerSpy).toHaveBeenCalledWith(
'scroll',
expect.any(Function),
);
expect(mockChart._legendScrollCleanup).toBeDefined();
});
it('should restore scroll position when provided', () => {
const mockScrollPosition = { scrollTop: 50, scrollLeft: 10 };
const mockSetScrollPosition = jest.fn();
const options = getUPlotChartOptions({
...baseOptions,
legendScrollPosition: mockScrollPosition,
setLegendScrollPosition: mockSetScrollPosition,
});
// Create mock chart with legend element
const mockChart = {
root: document.createElement('div'),
} as any;
const legend = document.createElement('div');
legend.className = 'u-legend';
legend.scrollTop = 0;
legend.scrollLeft = 0;
mockChart.root.appendChild(legend);
// Mock requestAnimationFrame
const mockRequestAnimationFrame = jest.fn((callback) => callback());
global.requestAnimationFrame = mockRequestAnimationFrame;
// Execute ready hook
if (options.hooks?.ready) {
options.hooks.ready.forEach((hook) => hook?.(mockChart));
}
// Verify that requestAnimationFrame was called to restore scroll position
expect(mockRequestAnimationFrame).toHaveBeenCalledWith(expect.any(Function));
// Verify that the legend's scroll position was actually restored
expect(legend.scrollTop).toBe(mockScrollPosition.scrollTop);
expect(legend.scrollLeft).toBe(mockScrollPosition.scrollLeft);
});
it('should handle missing scroll position parameters gracefully', () => {
const options = getUPlotChartOptions(baseOptions);
// Should not throw error and should still create valid options
expect(options.hooks?.ready).toBeDefined();
});
it('should work for both bottom and right legend positions', () => {
const mockSetScrollPosition = jest.fn();
const mockScrollPosition = { scrollTop: 30, scrollLeft: 15 };
// Mock requestAnimationFrame for this test
const mockRequestAnimationFrame = jest.fn((callback) => callback());
global.requestAnimationFrame = mockRequestAnimationFrame;
// Test bottom legend position
const bottomOptions = getUPlotChartOptions({
...baseOptions,
legendPosition: LegendPosition.BOTTOM,
legendScrollPosition: mockScrollPosition,
setLegendScrollPosition: mockSetScrollPosition,
});
// Test right legend position
const rightOptions = getUPlotChartOptions({
...baseOptions,
legendPosition: LegendPosition.RIGHT,
legendScrollPosition: mockScrollPosition,
setLegendScrollPosition: mockSetScrollPosition,
});
// Both should have ready hooks
expect(bottomOptions.hooks?.ready).toBeDefined();
expect(rightOptions.hooks?.ready).toBeDefined();
// Test bottom legend scroll restoration
const bottomChart = {
root: document.createElement('div'),
} as any;
const bottomLegend = document.createElement('div');
bottomLegend.className = 'u-legend';
bottomLegend.scrollTop = 0;
bottomLegend.scrollLeft = 0;
bottomChart.root.appendChild(bottomLegend);
// Execute bottom legend ready hook
if (bottomOptions.hooks?.ready) {
bottomOptions.hooks.ready.forEach((hook) => hook?.(bottomChart));
}
expect(bottomLegend.scrollTop).toBe(mockScrollPosition.scrollTop);
expect(bottomLegend.scrollLeft).toBe(mockScrollPosition.scrollLeft);
// Test right legend scroll restoration
const rightChart = {
root: document.createElement('div'),
} as any;
const rightLegend = document.createElement('div');
rightLegend.className = 'u-legend';
rightLegend.scrollTop = 0;
rightLegend.scrollLeft = 0;
rightChart.root.appendChild(rightLegend);
// Execute right legend ready hook
if (rightOptions.hooks?.ready) {
rightOptions.hooks.ready.forEach((hook) => hook?.(rightChart));
}
expect(rightLegend.scrollTop).toBe(mockScrollPosition.scrollTop);
expect(rightLegend.scrollLeft).toBe(mockScrollPosition.scrollLeft);
});
});

View File

@@ -76,51 +76,8 @@
}
}
.share-modal-content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
width: 420px;
.absolute-relative-time-toggler-container {
display: flex;
gap: 8px;
align-items: center;
}
.absolute-relative-time-toggler {
display: flex;
gap: 4px;
align-items: center;
}
.absolute-relative-time-error {
font-size: 12px;
color: var(--bg-amber-600);
}
.share-link {
display: flex;
align-items: center;
.share-url {
flex: 1;
border: 1px solid var(--bg-slate-400);
border-radius: 2px;
background: var(--bg-ink-300);
height: 32px;
padding: 6px 8px;
}
.copy-url-btn {
width: 32px;
}
}
}
.date-time-root,
.shareable-link-popover-root {
.header-section-popover-root {
.ant-popover-inner {
border-radius: 4px !important;
border: 1px solid var(--bg-slate-400);
@@ -359,7 +316,7 @@
}
.date-time-root,
.shareable-link-popover-root {
.header-section-popover-root {
.ant-popover-inner {
border: 1px solid var(--bg-vanilla-400);
background: var(--bg-vanilla-100) !important;
@@ -471,14 +428,6 @@
}
}
.share-modal-content {
.share-link {
.share-url {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
}
}
}
.reset-button {
background: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-300);

View File

@@ -1,8 +1,7 @@
import './DateTimeSelectionV2.styles.scss';
import { SyncOutlined } from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { Button, Popover, Switch, Typography } from 'antd';
import { Button } from 'antd';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import CustomTimePicker from 'components/CustomTimePicker/CustomTimePicker';
@@ -15,16 +14,15 @@ import dayjs, { Dayjs } from 'dayjs';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax, { isValidTimeFormat } from 'lib/getMinMax';
import { isValidTimeFormat } from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
import { cloneDeep, isObject } from 'lodash-es';
import { Check, Copy, Info, Send, Undo } from 'lucide-react';
import { Undo } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';
import { useCallback, useEffect, useState } from 'react';
import { connect, useDispatch, useSelector } from 'react-redux';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { useNavigationType, useSearchParams } from 'react-router-dom-v5-compat';
import { useCopyToClipboard } from 'react-use';
import { bindActionCreators, Dispatch } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { GlobalTimeLoading, UpdateTimeInterval } from 'store/actions';
@@ -53,7 +51,6 @@ import { Form, FormContainer, FormItem } from './styles';
function DateTimeSelection({
showAutoRefresh,
showRefreshText = true,
hideShareModal = false,
location,
updateTimeInterval,
globalTimeLoading,
@@ -81,10 +78,6 @@ function DateTimeSelection({
const searchStartTime = urlQuery.get('startTime');
const searchEndTime = urlQuery.get('endTime');
const relativeTimeFromUrl = urlQuery.get(QueryParams.relativeTime);
const [enableAbsoluteTime, setEnableAbsoluteTime] = useState(false);
const [isValidteRelativeTime, setIsValidteRelativeTime] = useState(false);
const [, handleCopyToClipboard] = useCopyToClipboard();
const [isURLCopied, setIsURLCopied] = useState(false);
// Prioritize props for initial modal time, fallback to URL params
let initialModalStartTime = 0;
@@ -324,7 +317,6 @@ function DateTimeSelection({
if (isModalTimeSelection) {
if (value === 'custom') {
setCustomDTPickerVisible(true);
setIsValidteRelativeTime(false);
return;
}
onTimeChange?.(value);
@@ -334,15 +326,12 @@ function DateTimeSelection({
setIsOpen(false);
updateTimeInterval(value);
updateLocalStorageForRoutes(value);
setIsValidteRelativeTime(true);
if (refreshButtonHidden) {
setRefreshButtonHidden(false);
}
} else {
setRefreshButtonHidden(true);
setCustomDTPickerVisible(true);
setIsValidteRelativeTime(false);
setEnableAbsoluteTime(false);
return;
}
@@ -458,11 +447,6 @@ function DateTimeSelection({
urlQuery.delete('startTime');
urlQuery.delete('endTime');
setIsValidteRelativeTime(true);
urlQuery.delete('startTime');
urlQuery.delete('endTime');
urlQuery.set(QueryParams.relativeTime, dateTimeStr);
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
@@ -542,7 +526,6 @@ function DateTimeSelection({
const handleRelativeTimeSync = useCallback(
(relativeTime: string): void => {
updateTimeInterval(relativeTime as Time);
setIsValidteRelativeTime(true);
setRefreshButtonHidden(false);
},
[updateTimeInterval],
@@ -625,8 +608,6 @@ function DateTimeSelection({
const updatedTime = getCustomOrIntervalTime(time, currentRoute);
setIsValidteRelativeTime(updatedTime !== 'custom');
const [preStartTime = 0, preEndTime = 0] = getTime() || [];
setRefreshButtonHidden(updatedTime === 'custom');
@@ -654,95 +635,6 @@ function DateTimeSelection({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname, updateTimeInterval, globalTimeLoading]);
// eslint-disable-next-line sonarjs/cognitive-complexity
const shareModalContent = (): JSX.Element => {
let currentUrl = window.location.href;
const startTime = urlQuery.get(QueryParams.startTime);
const endTime = urlQuery.get(QueryParams.endTime);
const isCustomTime = !!(startTime && endTime && selectedTime === 'custom');
if (enableAbsoluteTime || isCustomTime) {
if (selectedTime === 'custom') {
if (searchStartTime && searchEndTime) {
urlQuery.set(QueryParams.startTime, searchStartTime.toString());
urlQuery.set(QueryParams.endTime, searchEndTime.toString());
}
} else {
const { minTime, maxTime } = GetMinMax(selectedTime);
urlQuery.set(QueryParams.startTime, minTime.toString());
urlQuery.set(QueryParams.endTime, maxTime.toString());
}
urlQuery.delete(QueryParams.relativeTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
} else {
urlQuery.delete(QueryParams.startTime);
urlQuery.delete(QueryParams.endTime);
urlQuery.set(QueryParams.relativeTime, selectedTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
}
return (
<div className="share-modal-content">
<div className="absolute-relative-time-toggler-container">
<div className="absolute-relative-time-toggler">
{(selectedTime === 'custom' || !isValidteRelativeTime) && (
<Info size={14} color={Color.BG_AMBER_600} />
)}
<Switch
checked={enableAbsoluteTime || isCustomTime}
disabled={selectedTime === 'custom' || !isValidteRelativeTime}
size="small"
onChange={(): void => {
setEnableAbsoluteTime(!enableAbsoluteTime);
}}
/>
</div>
<Typography.Text>Enable Absolute Time</Typography.Text>
</div>
{(selectedTime === 'custom' || !isValidteRelativeTime) && (
<div className="absolute-relative-time-error">
Please select / enter valid relative time to toggle.
</div>
)}
<div className="share-link">
<Typography.Text ellipsis className="share-url">
{currentUrl}
</Typography.Text>
<Button
className="periscope-btn copy-url-btn"
onClick={(): void => {
handleCopyToClipboard(currentUrl);
setIsURLCopied(true);
setTimeout(() => {
setIsURLCopied(false);
}, 1000);
}}
icon={
isURLCopied ? (
<Check size={14} color={Color.BG_FOREST_500} />
) : (
<Copy size={14} color={Color.BG_ROBIN_500} />
)
}
/>
</div>
</div>
);
};
const { timezone } = useTimezone();
const getSelectedValue = (): string => {
@@ -814,9 +706,6 @@ function DateTimeSelection({
onValidCustomDateChange={(dateTime): void => {
onValidCustomDateHandler(dateTime.timeStr as CustomTimeType);
}}
onCustomTimeStatusUpdate={(isValid: boolean): void => {
setIsValidteRelativeTime(isValid);
}}
selectedValue={getSelectedValue()}
data-testid="dropDown"
items={options}
@@ -843,24 +732,6 @@ function DateTimeSelection({
</FormItem>
</div>
)}
{!hideShareModal && (
<Popover
rootClassName="shareable-link-popover-root"
className="shareable-link-popover"
placement="bottomRight"
content={shareModalContent}
arrow={false}
trigger={['hover']}
>
<Button
className="share-link-btn periscope-btn"
icon={<Send size={14} />}
>
Share
</Button>
</Popover>
)}
</FormContainer>
</Form>
</div>

View File

@@ -1,4 +1,17 @@
.top-nav-container {
padding: 0px 8px;
margin-bottom: 16px;
padding: 8px;
border-bottom: 1px solid var(--bg-slate-500);
display: flex;
align-items: center;
justify-content: end;
gap: 16px;
margin-bottom: 8px;
}
.lightMode {
.top-nav-container {
border-bottom: 1px solid var(--bg-vanilla-300);
}
}

View File

@@ -1,6 +1,6 @@
import './TopNav.styles.scss';
import { Col, Row, Space } from 'antd';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import ROUTES from 'constants/routes';
import { useMemo } from 'react';
import { matchPath, useHistory } from 'react-router-dom';
@@ -46,16 +46,9 @@ function TopNav(): JSX.Element | null {
return !isRouteToSkip ? (
<div className="top-nav-container">
<Col span={24} style={{ marginTop: '1rem' }}>
<Row justify="end">
<Space align="center" size={16} direction="horizontal">
<NewExplorerCTA />
<div>
<DateTimeSelector showAutoRefresh />
</div>
</Space>
</Row>
</Col>
<NewExplorerCTA />
<DateTimeSelector showAutoRefresh />
<HeaderRightSection enableShare enableFeedback enableAnnouncements={false} />
</div>
) : null;
}

View File

@@ -0,0 +1,31 @@
import listOverview from 'api/thirdPartyApis/listOverview';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useQuery, UseQueryResult } from 'react-query';
import { SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import {
PayloadProps,
Props as ListOverviewProps,
} from 'types/api/thirdPartyApis/listOverview';
export const useListOverview = (
props: ListOverviewProps,
): UseQueryResult<SuccessResponseV2<PayloadProps>, APIError> => {
const { start, end, show_ip: showIp, filter } = props;
return useQuery<SuccessResponseV2<PayloadProps>, APIError>({
queryKey: [
REACT_QUERY_KEY.GET_DOMAINS_LIST,
start,
end,
showIp,
filter.expression,
],
queryFn: () =>
listOverview({
start,
end,
show_ip: showIp,
filter,
}),
});
};

View File

@@ -32,6 +32,12 @@ import getSeries from './utils/getSeriesData';
import { getXAxisScale } from './utils/getXAxisScale';
import { getYAxisScale } from './utils/getYAxisScale';
// Extended uPlot interface with custom properties
interface ExtendedUPlot extends uPlot {
_legendScrollCleanup?: () => void;
_tooltipCleanup?: () => void;
}
export interface GetUPlotChartOptions {
id?: string;
apiResponse?: MetricRangePayloadProps;
@@ -72,6 +78,14 @@ export interface GetUPlotChartOptions {
legendPosition?: LegendPosition;
enableZoom?: boolean;
query?: Query;
legendScrollPosition?: {
scrollTop: number;
scrollLeft: number;
};
setLegendScrollPosition?: (position: {
scrollTop: number;
scrollLeft: number;
}) => void;
}
/** the function converts series A , series B , series C to
@@ -201,6 +215,8 @@ export const getUPlotChartOptions = ({
legendPosition = LegendPosition.BOTTOM,
enableZoom,
query,
legendScrollPosition,
setLegendScrollPosition,
}: GetUPlotChartOptions): uPlot.Options => {
const timeScaleProps = getXAxisScale(minTimeScale, maxTimeScale);
@@ -455,16 +471,43 @@ export const getUPlotChartOptions = ({
const legend = self.root.querySelector('.u-legend');
if (legend) {
const legendElement = legend as HTMLElement;
// Apply enhanced legend styling
if (enhancedLegend) {
applyEnhancedLegendStyling(
legend as HTMLElement,
legendElement,
legendConfig,
legendConfig.requiredRows,
legendPosition,
);
}
// Restore scroll position if available
if (legendScrollPosition && setLegendScrollPosition) {
requestAnimationFrame(() => {
legendElement.scrollTop = legendScrollPosition.scrollTop;
legendElement.scrollLeft = legendScrollPosition.scrollLeft;
});
}
// Set up scroll position tracking
if (setLegendScrollPosition) {
const handleScroll = (): void => {
setLegendScrollPosition({
scrollTop: legendElement.scrollTop,
scrollLeft: legendElement.scrollLeft,
});
};
legendElement.addEventListener('scroll', handleScroll);
// Store cleanup function
(self as ExtendedUPlot)._legendScrollCleanup = (): void => {
legendElement.removeEventListener('scroll', handleScroll);
};
}
// Global cleanup function for all legend tooltips
const cleanupAllTooltips = (): void => {
const existingTooltips = document.querySelectorAll('.legend-tooltip');
@@ -485,7 +528,7 @@ export const getUPlotChartOptions = ({
document?.addEventListener('mousemove', globalCleanupHandler);
// Store cleanup function for potential removal later
(self as any)._tooltipCleanup = (): void => {
(self as ExtendedUPlot)._tooltipCleanup = (): void => {
cleanupAllTooltips();
document?.removeEventListener('mousemove', globalCleanupHandler);
};

View File

@@ -1,5 +1,5 @@
.alerts-container {
.ant-tabs-nav-wrap:first-of-type {
padding-left: 16px;
.ant-tabs-nav {
padding: 0 8px;
}
}

View File

@@ -3,6 +3,7 @@ import './AlertList.styles.scss';
import { Tabs } from 'antd';
import { TabsProps } from 'antd/lib';
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import ROUTES from 'constants/routes';
import AllAlertRules from 'container/ListAlertRules';
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
@@ -28,7 +29,7 @@ function AllAlertList(): JSX.Element {
{
label: (
<div className="periscope-tab top-level-tab">
<GalleryVerticalEnd size={16} />
<GalleryVerticalEnd size={14} />
Triggered Alerts
</div>
),
@@ -38,7 +39,7 @@ function AllAlertList(): JSX.Element {
{
label: (
<div className="periscope-tab top-level-tab">
<Pyramid size={16} />
<Pyramid size={14} />
Alert Rules
</div>
),
@@ -52,7 +53,7 @@ function AllAlertList(): JSX.Element {
{
label: (
<div className="periscope-tab top-level-tab">
<ConfigureIcon />
<ConfigureIcon width={14} height={14} />
Configuration
</div>
),
@@ -82,6 +83,13 @@ function AllAlertList(): JSX.Element {
className={`alerts-container ${
isAlertHistory || isAlertOverview ? 'alert-details-tabs' : ''
}`}
tabBarExtraContent={
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
}
/>
);
}

View File

@@ -7,7 +7,12 @@
}
.all-errors-right-section {
padding: 0 10px;
.right-toolbar-actions-container {
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
}
}
.ant-tabs {

View File

@@ -5,6 +5,7 @@ import { Button, Tooltip } from 'antd';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import cx from 'classnames';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import RouteTab from 'components/RouteTab';
@@ -74,10 +75,24 @@ function AllErrors(): JSX.Element {
</Tooltip>
) : undefined
}
rightActions={<RightToolbarActions onStageRunQuery={handleRunQuery} />}
rightActions={
<div className="right-toolbar-actions-container">
<RightToolbarActions onStageRunQuery={handleRunQuery} />
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
</div>
}
/>
<ResourceAttributesFilterV2 />
<RouteTab routes={routes} activeKey={pathname} history={history} />
<RouteTab
routes={routes}
activeKey={pathname}
history={history}
showRightSection={false}
/>
</>
</TypicalOverlayScrollbar>
</section>

Some files were not shown because too many files have changed in this diff Show More