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:
Yunus M 2025-09-24 11:52:37 +05:30 committed by GitHub
parent a54c3a3d7f
commit 2f4b8f6f80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1629 additions and 215 deletions

4
.gitignore vendored
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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, .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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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