mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-17 15:36:48 +00:00
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>
This commit is contained in:
parent
a54c3a3d7f
commit
2f4b8f6f80
4
.gitignore
vendored
4
.gitignore
vendored
@ -230,4 +230,6 @@ poetry.toml
|
|||||||
# LSP config files
|
# LSP config files
|
||||||
pyrightconfig.json
|
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/
|
||||||
@ -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;
|
||||||
160
frontend/src/components/HeaderRightSection/FeedbackModal.tsx
Normal file
160
frontend/src/components/HeaderRightSection/FeedbackModal.tsx
Normal 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;
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
171
frontend/src/components/HeaderRightSection/ShareURLModal.tsx
Normal file
171
frontend/src/components/HeaderRightSection/ShareURLModal.tsx
Normal 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;
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { Tabs, TabsProps } from 'antd';
|
import { Tabs, TabsProps } from 'antd';
|
||||||
|
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||||
import {
|
import {
|
||||||
generatePath,
|
generatePath,
|
||||||
matchPath,
|
matchPath,
|
||||||
@ -17,6 +18,7 @@ function RouteTab({
|
|||||||
activeKey,
|
activeKey,
|
||||||
onChangeHandler,
|
onChangeHandler,
|
||||||
history,
|
history,
|
||||||
|
showRightSection,
|
||||||
...rest
|
...rest
|
||||||
}: RouteTabProps & TabsProps): JSX.Element {
|
}: RouteTabProps & TabsProps): JSX.Element {
|
||||||
const params = useParams<Params>();
|
const params = useParams<Params>();
|
||||||
@ -61,12 +63,22 @@ function RouteTab({
|
|||||||
items={items}
|
items={items}
|
||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
{...rest}
|
{...rest}
|
||||||
|
tabBarExtraContent={
|
||||||
|
showRightSection && (
|
||||||
|
<HeaderRightSection
|
||||||
|
enableAnnouncements={false}
|
||||||
|
enableShare
|
||||||
|
enableFeedback
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
RouteTab.defaultProps = {
|
RouteTab.defaultProps = {
|
||||||
onChangeHandler: undefined,
|
onChangeHandler: undefined,
|
||||||
|
showRightSection: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RouteTab;
|
export default RouteTab;
|
||||||
|
|||||||
@ -13,4 +13,5 @@ export interface RouteTabProps {
|
|||||||
activeKey: TabsProps['activeKey'];
|
activeKey: TabsProps['activeKey'];
|
||||||
onChangeHandler?: (key: string) => void;
|
onChangeHandler?: (key: string) => void;
|
||||||
history: History<unknown>;
|
history: History<unknown>;
|
||||||
|
showRightSection: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export default function LogsError(): JSX.Element {
|
|||||||
window.open('https://signoz.io/slack', '_blank');
|
window.open('https://signoz.io/slack', '_blank');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="logs-error-container">
|
<div className="logs-error-container">
|
||||||
<div className="logs-error-content">
|
<div className="logs-error-content">
|
||||||
|
|||||||
@ -57,14 +57,14 @@
|
|||||||
border-bottom: 1px solid var(--bg-slate-400);
|
border-bottom: 1px solid var(--bg-slate-400);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-right: 16px;
|
padding: 0 8px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
.dashboard-breadcrumbs {
|
.dashboard-breadcrumbs {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
padding: 16px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import logEvent from 'api/common/logEvent';
|
import logEvent from 'api/common/logEvent';
|
||||||
|
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||||
import { QueryParams } from 'constants/query';
|
import { QueryParams } from 'constants/query';
|
||||||
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
@ -321,6 +322,12 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
|||||||
{title}
|
{title}
|
||||||
</Button>
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<HeaderRightSection
|
||||||
|
enableAnnouncements={false}
|
||||||
|
enableShare
|
||||||
|
enableFeedback
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<section className="dashboard-details">
|
<section className="dashboard-details">
|
||||||
<div className="left-section">
|
<div className="left-section">
|
||||||
|
|||||||
@ -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,
|
.date-time-root,
|
||||||
.shareable-link-popover-root {
|
.header-section-popover-root {
|
||||||
.ant-popover-inner {
|
.ant-popover-inner {
|
||||||
border-radius: 4px !important;
|
border-radius: 4px !important;
|
||||||
border: 1px solid var(--bg-slate-400);
|
border: 1px solid var(--bg-slate-400);
|
||||||
@ -359,7 +316,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.date-time-root,
|
.date-time-root,
|
||||||
.shareable-link-popover-root {
|
.header-section-popover-root {
|
||||||
.ant-popover-inner {
|
.ant-popover-inner {
|
||||||
border: 1px solid var(--bg-vanilla-400);
|
border: 1px solid var(--bg-vanilla-400);
|
||||||
background: var(--bg-vanilla-100) !important;
|
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 {
|
.reset-button {
|
||||||
background: var(--bg-vanilla-100);
|
background: var(--bg-vanilla-100);
|
||||||
border-color: var(--bg-vanilla-300);
|
border-color: var(--bg-vanilla-300);
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import './DateTimeSelectionV2.styles.scss';
|
import './DateTimeSelectionV2.styles.scss';
|
||||||
|
|
||||||
import { SyncOutlined } from '@ant-design/icons';
|
import { SyncOutlined } from '@ant-design/icons';
|
||||||
import { Color } from '@signozhq/design-tokens';
|
import { Button } from 'antd';
|
||||||
import { Button, Popover, Switch, Typography } from 'antd';
|
|
||||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||||
import CustomTimePicker from 'components/CustomTimePicker/CustomTimePicker';
|
import CustomTimePicker from 'components/CustomTimePicker/CustomTimePicker';
|
||||||
@ -15,16 +14,15 @@ import dayjs, { Dayjs } from 'dayjs';
|
|||||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
import useUrlQuery from 'hooks/useUrlQuery';
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
import GetMinMax, { isValidTimeFormat } from 'lib/getMinMax';
|
import { isValidTimeFormat } from 'lib/getMinMax';
|
||||||
import getTimeString from 'lib/getTimeString';
|
import getTimeString from 'lib/getTimeString';
|
||||||
import { cloneDeep, isObject } from 'lodash-es';
|
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 { useTimezone } from 'providers/Timezone';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { connect, useDispatch, useSelector } from 'react-redux';
|
import { connect, useDispatch, useSelector } from 'react-redux';
|
||||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||||
import { useNavigationType, useSearchParams } from 'react-router-dom-v5-compat';
|
import { useNavigationType, useSearchParams } from 'react-router-dom-v5-compat';
|
||||||
import { useCopyToClipboard } from 'react-use';
|
|
||||||
import { bindActionCreators, Dispatch } from 'redux';
|
import { bindActionCreators, Dispatch } from 'redux';
|
||||||
import { ThunkDispatch } from 'redux-thunk';
|
import { ThunkDispatch } from 'redux-thunk';
|
||||||
import { GlobalTimeLoading, UpdateTimeInterval } from 'store/actions';
|
import { GlobalTimeLoading, UpdateTimeInterval } from 'store/actions';
|
||||||
@ -53,7 +51,6 @@ import { Form, FormContainer, FormItem } from './styles';
|
|||||||
function DateTimeSelection({
|
function DateTimeSelection({
|
||||||
showAutoRefresh,
|
showAutoRefresh,
|
||||||
showRefreshText = true,
|
showRefreshText = true,
|
||||||
hideShareModal = false,
|
|
||||||
location,
|
location,
|
||||||
updateTimeInterval,
|
updateTimeInterval,
|
||||||
globalTimeLoading,
|
globalTimeLoading,
|
||||||
@ -81,10 +78,6 @@ function DateTimeSelection({
|
|||||||
const searchStartTime = urlQuery.get('startTime');
|
const searchStartTime = urlQuery.get('startTime');
|
||||||
const searchEndTime = urlQuery.get('endTime');
|
const searchEndTime = urlQuery.get('endTime');
|
||||||
const relativeTimeFromUrl = urlQuery.get(QueryParams.relativeTime);
|
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
|
// Prioritize props for initial modal time, fallback to URL params
|
||||||
let initialModalStartTime = 0;
|
let initialModalStartTime = 0;
|
||||||
@ -324,7 +317,6 @@ function DateTimeSelection({
|
|||||||
if (isModalTimeSelection) {
|
if (isModalTimeSelection) {
|
||||||
if (value === 'custom') {
|
if (value === 'custom') {
|
||||||
setCustomDTPickerVisible(true);
|
setCustomDTPickerVisible(true);
|
||||||
setIsValidteRelativeTime(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onTimeChange?.(value);
|
onTimeChange?.(value);
|
||||||
@ -334,15 +326,12 @@ function DateTimeSelection({
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
updateTimeInterval(value);
|
updateTimeInterval(value);
|
||||||
updateLocalStorageForRoutes(value);
|
updateLocalStorageForRoutes(value);
|
||||||
setIsValidteRelativeTime(true);
|
|
||||||
if (refreshButtonHidden) {
|
if (refreshButtonHidden) {
|
||||||
setRefreshButtonHidden(false);
|
setRefreshButtonHidden(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setRefreshButtonHidden(true);
|
setRefreshButtonHidden(true);
|
||||||
setCustomDTPickerVisible(true);
|
setCustomDTPickerVisible(true);
|
||||||
setIsValidteRelativeTime(false);
|
|
||||||
setEnableAbsoluteTime(false);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -458,11 +447,6 @@ function DateTimeSelection({
|
|||||||
urlQuery.delete('startTime');
|
urlQuery.delete('startTime');
|
||||||
urlQuery.delete('endTime');
|
urlQuery.delete('endTime');
|
||||||
|
|
||||||
setIsValidteRelativeTime(true);
|
|
||||||
|
|
||||||
urlQuery.delete('startTime');
|
|
||||||
urlQuery.delete('endTime');
|
|
||||||
|
|
||||||
urlQuery.set(QueryParams.relativeTime, dateTimeStr);
|
urlQuery.set(QueryParams.relativeTime, dateTimeStr);
|
||||||
|
|
||||||
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
|
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
|
||||||
@ -542,7 +526,6 @@ function DateTimeSelection({
|
|||||||
const handleRelativeTimeSync = useCallback(
|
const handleRelativeTimeSync = useCallback(
|
||||||
(relativeTime: string): void => {
|
(relativeTime: string): void => {
|
||||||
updateTimeInterval(relativeTime as Time);
|
updateTimeInterval(relativeTime as Time);
|
||||||
setIsValidteRelativeTime(true);
|
|
||||||
setRefreshButtonHidden(false);
|
setRefreshButtonHidden(false);
|
||||||
},
|
},
|
||||||
[updateTimeInterval],
|
[updateTimeInterval],
|
||||||
@ -625,8 +608,6 @@ function DateTimeSelection({
|
|||||||
|
|
||||||
const updatedTime = getCustomOrIntervalTime(time, currentRoute);
|
const updatedTime = getCustomOrIntervalTime(time, currentRoute);
|
||||||
|
|
||||||
setIsValidteRelativeTime(updatedTime !== 'custom');
|
|
||||||
|
|
||||||
const [preStartTime = 0, preEndTime = 0] = getTime() || [];
|
const [preStartTime = 0, preEndTime = 0] = getTime() || [];
|
||||||
|
|
||||||
setRefreshButtonHidden(updatedTime === 'custom');
|
setRefreshButtonHidden(updatedTime === 'custom');
|
||||||
@ -654,95 +635,6 @@ function DateTimeSelection({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [location.pathname, updateTimeInterval, globalTimeLoading]);
|
}, [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 { timezone } = useTimezone();
|
||||||
|
|
||||||
const getSelectedValue = (): string => {
|
const getSelectedValue = (): string => {
|
||||||
@ -814,9 +706,6 @@ function DateTimeSelection({
|
|||||||
onValidCustomDateChange={(dateTime): void => {
|
onValidCustomDateChange={(dateTime): void => {
|
||||||
onValidCustomDateHandler(dateTime.timeStr as CustomTimeType);
|
onValidCustomDateHandler(dateTime.timeStr as CustomTimeType);
|
||||||
}}
|
}}
|
||||||
onCustomTimeStatusUpdate={(isValid: boolean): void => {
|
|
||||||
setIsValidteRelativeTime(isValid);
|
|
||||||
}}
|
|
||||||
selectedValue={getSelectedValue()}
|
selectedValue={getSelectedValue()}
|
||||||
data-testid="dropDown"
|
data-testid="dropDown"
|
||||||
items={options}
|
items={options}
|
||||||
@ -843,24 +732,6 @@ function DateTimeSelection({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
</div>
|
</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>
|
</FormContainer>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,17 @@
|
|||||||
.top-nav-container {
|
.top-nav-container {
|
||||||
padding: 0px 8px;
|
padding: 8px;
|
||||||
margin-bottom: 16px;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import './TopNav.styles.scss';
|
import './TopNav.styles.scss';
|
||||||
|
|
||||||
import { Col, Row, Space } from 'antd';
|
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { matchPath, useHistory } from 'react-router-dom';
|
import { matchPath, useHistory } from 'react-router-dom';
|
||||||
@ -46,16 +46,9 @@ function TopNav(): JSX.Element | null {
|
|||||||
|
|
||||||
return !isRouteToSkip ? (
|
return !isRouteToSkip ? (
|
||||||
<div className="top-nav-container">
|
<div className="top-nav-container">
|
||||||
<Col span={24} style={{ marginTop: '1rem' }}>
|
<NewExplorerCTA />
|
||||||
<Row justify="end">
|
<DateTimeSelector showAutoRefresh />
|
||||||
<Space align="center" size={16} direction="horizontal">
|
<HeaderRightSection enableShare enableFeedback enableAnnouncements={false} />
|
||||||
<NewExplorerCTA />
|
|
||||||
<div>
|
|
||||||
<DateTimeSelector showAutoRefresh />
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</Row>
|
|
||||||
</Col>
|
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
.alerts-container {
|
.alerts-container {
|
||||||
.ant-tabs-nav-wrap:first-of-type {
|
.ant-tabs-nav {
|
||||||
padding-left: 16px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import './AlertList.styles.scss';
|
|||||||
import { Tabs } from 'antd';
|
import { Tabs } from 'antd';
|
||||||
import { TabsProps } from 'antd/lib';
|
import { TabsProps } from 'antd/lib';
|
||||||
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
|
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
|
||||||
|
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
import AllAlertRules from 'container/ListAlertRules';
|
import AllAlertRules from 'container/ListAlertRules';
|
||||||
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
|
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
|
||||||
@ -28,7 +29,7 @@ function AllAlertList(): JSX.Element {
|
|||||||
{
|
{
|
||||||
label: (
|
label: (
|
||||||
<div className="periscope-tab top-level-tab">
|
<div className="periscope-tab top-level-tab">
|
||||||
<GalleryVerticalEnd size={16} />
|
<GalleryVerticalEnd size={14} />
|
||||||
Triggered Alerts
|
Triggered Alerts
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -38,7 +39,7 @@ function AllAlertList(): JSX.Element {
|
|||||||
{
|
{
|
||||||
label: (
|
label: (
|
||||||
<div className="periscope-tab top-level-tab">
|
<div className="periscope-tab top-level-tab">
|
||||||
<Pyramid size={16} />
|
<Pyramid size={14} />
|
||||||
Alert Rules
|
Alert Rules
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -52,7 +53,7 @@ function AllAlertList(): JSX.Element {
|
|||||||
{
|
{
|
||||||
label: (
|
label: (
|
||||||
<div className="periscope-tab top-level-tab">
|
<div className="periscope-tab top-level-tab">
|
||||||
<ConfigureIcon />
|
<ConfigureIcon width={14} height={14} />
|
||||||
Configuration
|
Configuration
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -82,6 +83,13 @@ function AllAlertList(): JSX.Element {
|
|||||||
className={`alerts-container ${
|
className={`alerts-container ${
|
||||||
isAlertHistory || isAlertOverview ? 'alert-details-tabs' : ''
|
isAlertHistory || isAlertOverview ? 'alert-details-tabs' : ''
|
||||||
}`}
|
}`}
|
||||||
|
tabBarExtraContent={
|
||||||
|
<HeaderRightSection
|
||||||
|
enableAnnouncements={false}
|
||||||
|
enableShare
|
||||||
|
enableFeedback
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.all-errors-right-section {
|
.all-errors-right-section {
|
||||||
padding: 0 10px;
|
.right-toolbar-actions-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-tabs {
|
.ant-tabs {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Button, Tooltip } from 'antd';
|
|||||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
|
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||||
import RouteTab from 'components/RouteTab';
|
import RouteTab from 'components/RouteTab';
|
||||||
@ -74,10 +75,24 @@ function AllErrors(): JSX.Element {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
rightActions={<RightToolbarActions onStageRunQuery={handleRunQuery} />}
|
rightActions={
|
||||||
|
<div className="right-toolbar-actions-container">
|
||||||
|
<RightToolbarActions onStageRunQuery={handleRunQuery} />
|
||||||
|
<HeaderRightSection
|
||||||
|
enableAnnouncements={false}
|
||||||
|
enableShare
|
||||||
|
enableFeedback
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<ResourceAttributesFilterV2 />
|
<ResourceAttributesFilterV2 />
|
||||||
<RouteTab routes={routes} activeKey={pathname} history={history} />
|
<RouteTab
|
||||||
|
routes={routes}
|
||||||
|
activeKey={pathname}
|
||||||
|
history={history}
|
||||||
|
showRightSection={false}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
</TypicalOverlayScrollbar>
|
</TypicalOverlayScrollbar>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -2,11 +2,18 @@
|
|||||||
.dashboard-header {
|
.dashboard-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16px;
|
justify-content: space-between;
|
||||||
|
padding: 0 8px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-bottom: 1px solid var(--bg-slate-500);
|
border-bottom: 1px solid var(--bg-slate-500);
|
||||||
|
|
||||||
|
.dashboard-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
color: var(--bg-vanilla-400);
|
color: var(--bg-vanilla-400);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import './DashboardsListPage.styles.scss';
|
import './DashboardsListPage.styles.scss';
|
||||||
|
|
||||||
import { Space, Typography } from 'antd';
|
import { Space, Typography } from 'antd';
|
||||||
|
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||||
import ListOfAllDashboard from 'container/ListOfDashboard';
|
import ListOfAllDashboard from 'container/ListOfDashboard';
|
||||||
import { LayoutGrid } from 'lucide-react';
|
import { LayoutGrid } from 'lucide-react';
|
||||||
|
|
||||||
@ -13,8 +14,16 @@ function DashboardsListPage(): JSX.Element {
|
|||||||
className="dashboard-list-page"
|
className="dashboard-list-page"
|
||||||
>
|
>
|
||||||
<div className="dashboard-header">
|
<div className="dashboard-header">
|
||||||
<LayoutGrid size={14} className="icon" />
|
<div className="dashboard-header-left">
|
||||||
<Typography.Text className="text">Dashboards</Typography.Text>
|
<LayoutGrid size={14} className="icon" />
|
||||||
|
<Typography.Text className="text">Dashboards</Typography.Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HeaderRightSection
|
||||||
|
enableAnnouncements={false}
|
||||||
|
enableShare
|
||||||
|
enableFeedback
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ListOfAllDashboard />
|
<ListOfAllDashboard />
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@ -33,7 +33,6 @@
|
|||||||
height: calc(100vh - 48px);
|
height: calc(100vh - 48px);
|
||||||
border-right: 1px solid var(--Slate-500, #161922);
|
border-right: 1px solid var(--Slate-500, #161922);
|
||||||
background: var(--Ink-500, #0b0c0e);
|
background: var(--Ink-500, #0b0c0e);
|
||||||
padding: 10px 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-page-content {
|
.settings-page-content {
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
padding-right: 48px;
|
padding-right: 48px;
|
||||||
|
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 64px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-channels {
|
.support-channels {
|
||||||
@ -19,6 +19,7 @@
|
|||||||
flex: 0 0 calc(33.333% - 32px);
|
flex: 0 0 calc(33.333% - 32px);
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
border: none !important;
|
||||||
|
|
||||||
.support-channel-title {
|
.support-channel-title {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@ -205,6 +205,7 @@ export default function Support(): JSX.Element {
|
|||||||
<div className="support-channel-action">
|
<div className="support-channel-action">
|
||||||
<Button
|
<Button
|
||||||
type="default"
|
type="default"
|
||||||
|
className="periscope-btn secondary"
|
||||||
onClick={(): void => handleChannelClick(channel)}
|
onClick={(): void => handleChannelClick(channel)}
|
||||||
>
|
>
|
||||||
<Text ellipsis>{channel.btnText} </Text>
|
<Text ellipsis>{channel.btnText} </Text>
|
||||||
@ -240,7 +241,7 @@ export default function Support(): JSX.Element {
|
|||||||
loading={isLoadingBilling}
|
loading={isLoadingBilling}
|
||||||
disabled={isLoadingBilling}
|
disabled={isLoadingBilling}
|
||||||
onClick={handleAddCreditCard}
|
onClick={handleAddCreditCard}
|
||||||
className="add-credit-card-btn"
|
className="add-credit-card-btn periscope-btn primary"
|
||||||
>
|
>
|
||||||
Add Credit Card
|
Add Credit Card
|
||||||
</Button>,
|
</Button>,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user