diff --git a/.gitignore b/.gitignore index ff71a25bc909..014c7c2800bc 100644 --- a/.gitignore +++ b/.gitignore @@ -230,4 +230,6 @@ poetry.toml # LSP config files pyrightconfig.json -# End of https://www.toptal.com/developers/gitignore/api/python \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/python + +frontend/.cursor/rules/ \ No newline at end of file diff --git a/frontend/src/components/HeaderRightSection/AnnouncementsModal.tsx b/frontend/src/components/HeaderRightSection/AnnouncementsModal.tsx new file mode 100644 index 000000000000..fdd054de110b --- /dev/null +++ b/frontend/src/components/HeaderRightSection/AnnouncementsModal.tsx @@ -0,0 +1,15 @@ +import { Typography } from 'antd'; + +function AnnouncementsModal(): JSX.Element { + return ( +
+
+ + Announcements + +
+
+ ); +} + +export default AnnouncementsModal; diff --git a/frontend/src/components/HeaderRightSection/FeedbackModal.tsx b/frontend/src/components/HeaderRightSection/FeedbackModal.tsx new file mode 100644 index 000000000000..f0de75f3447c --- /dev/null +++ b/frontend/src/components/HeaderRightSection/FeedbackModal.tsx @@ -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 => { + 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: ( +
+
+ Feedback +
+ ), + key: 'feedback', + value: 'feedback', + }, + { + label: ( +
+
+ Report a bug +
+ ), + key: 'reportBug', + value: 'reportBug', + }, + { + label: ( +
+
+ Feature request +
+ ), + key: 'featureRequest', + value: 'featureRequest', + }, + ]; + + const handleFeedbackChange = ( + e: React.ChangeEvent, + ): void => { + setFeedback(e.target.value); + }; + + const handleContactSupportClick = useCallback((): void => { + handleContactSupport(isCloudUserVal); + }, [isCloudUserVal]); + + return ( +
+
+ setActiveTab(e.target.value)} + /> +
+
+
+ +
+
+ +
+ +
+ + Have a specific issue?{' '} + + Contact Support{' '} + + or{' '} + + Read our docs + + +
+
+
+ ); +} + +export default FeedbackModal; diff --git a/frontend/src/components/HeaderRightSection/HeaderRightSection.styles.scss b/frontend/src/components/HeaderRightSection/HeaderRightSection.styles.scss new file mode 100644 index 000000000000..910200e98868 --- /dev/null +++ b/frontend/src/components/HeaderRightSection/HeaderRightSection.styles.scss @@ -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); + } + } + } +} diff --git a/frontend/src/components/HeaderRightSection/HeaderRightSection.tsx b/frontend/src/components/HeaderRightSection/HeaderRightSection.tsx new file mode 100644 index 000000000000..19b00391bd7e --- /dev/null +++ b/frontend/src/components/HeaderRightSection/HeaderRightSection.tsx @@ -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 ( +
+ {enableFeedback && ( + } + destroyTooltipOnHide + arrow={false} + trigger="click" + open={openFeedbackModal} + onOpenChange={handleOpenFeedbackModalChange} + > + + + )} +
+ ); +} + +export default HeaderRightSection; diff --git a/frontend/src/components/HeaderRightSection/ShareURLModal.tsx b/frontend/src/components/HeaderRightSection/ShareURLModal.tsx new file mode 100644 index 000000000000..c15c0d3c6ed4 --- /dev/null +++ b/frontend/src/components/HeaderRightSection/ShareURLModal.tsx @@ -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( + (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 ( +
+ {(shareURLWithTime || isRouteToBeSharedWithTime) && ( + <> +
+ + Enable absolute time + + +
+ {!isValidateRelativeTime && ( + + )} + { + setEnableAbsoluteTime((prev) => !prev); + }} + /> +
+
+ + {!isValidateRelativeTime && ( +
+ Please select / enter valid relative time to toggle. +
+ )} + + )} + +
+
+
+ + Share page link + + + Share the current page link with your team member + +
+ + +
+
+
+ ); +} + +export default ShareURLModal; diff --git a/frontend/src/components/HeaderRightSection/__tests__/AnnouncementsModal.test.tsx b/frontend/src/components/HeaderRightSection/__tests__/AnnouncementsModal.test.tsx new file mode 100644 index 000000000000..39d01def7d93 --- /dev/null +++ b/frontend/src/components/HeaderRightSection/__tests__/AnnouncementsModal.test.tsx @@ -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(); + + expect(screen.getByText('Announcements')).toBeInTheDocument(); + }); + + it('should have proper structure and classes', () => { + render(); + + 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()).not.toThrow(); + }); +}); diff --git a/frontend/src/components/HeaderRightSection/__tests__/FeedbackModal.test.tsx b/frontend/src/components/HeaderRightSection/__tests__/FeedbackModal.test.tsx new file mode 100644 index 000000000000..2b4be0ff4634 --- /dev/null +++ b/frontend/src/components/HeaderRightSection/__tests__/FeedbackModal.test.tsx @@ -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; +const mockUseLocation = useLocation as jest.Mock; +const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock; +const mockHandleContactSupport = handleContactSupport as jest.Mock; +const mockToast = toast as jest.Mocked; + +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(); + + 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(); + + // 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(); + + 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(); + + 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(); + + // 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(); + + // 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(); + + 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(); + + const contactSupportLink = screen.getByText('Contact Support'); + await user.click(contactSupportLink); + + expect(mockHandleContactSupport).toHaveBeenCalledWith(isCloudUser); + }); + + it('should render docs link with correct attributes', () => { + render(); + + 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(); + + // 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(); + + // 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(); + }); +}); diff --git a/frontend/src/components/HeaderRightSection/__tests__/HeaderRightSection.test.tsx b/frontend/src/components/HeaderRightSection/__tests__/HeaderRightSection.test.tsx new file mode 100644 index 000000000000..68e367f31c14 --- /dev/null +++ b/frontend/src/components/HeaderRightSection/__tests__/HeaderRightSection.test.tsx @@ -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 => ( +
+ +
+ ), +})); + +jest.mock('../ShareURLModal', () => ({ + __esModule: true, + default: (): JSX.Element => ( +
Share URL Modal
+ ), +})); + +jest.mock('../AnnouncementsModal', () => ({ + __esModule: true, + default: (): JSX.Element => ( +
Announcements Modal
+ ), +})); + +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(); + + 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( + , + ); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + // 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(); + }); +}); diff --git a/frontend/src/components/HeaderRightSection/__tests__/ShareURLModal.test.tsx b/frontend/src/components/HeaderRightSection/__tests__/ShareURLModal.test.tsx new file mode 100644 index 000000000000..bf634f57daf9 --- /dev/null +++ b/frontend/src/components/HeaderRightSection/__tests__/ShareURLModal.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + // 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(); + + // 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(); + + 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'); + }); +}); diff --git a/frontend/src/components/RouteTab/index.tsx b/frontend/src/components/RouteTab/index.tsx index 392677a33ee9..43d652b2e4ab 100644 --- a/frontend/src/components/RouteTab/index.tsx +++ b/frontend/src/components/RouteTab/index.tsx @@ -1,4 +1,5 @@ import { Tabs, TabsProps } from 'antd'; +import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection'; import { generatePath, matchPath, @@ -17,6 +18,7 @@ function RouteTab({ activeKey, onChangeHandler, history, + showRightSection, ...rest }: RouteTabProps & TabsProps): JSX.Element { const params = useParams(); @@ -61,12 +63,22 @@ function RouteTab({ items={items} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} + tabBarExtraContent={ + showRightSection && ( + + ) + } /> ); } RouteTab.defaultProps = { onChangeHandler: undefined, + showRightSection: true, }; export default RouteTab; diff --git a/frontend/src/components/RouteTab/types.ts b/frontend/src/components/RouteTab/types.ts index a4dada0daf4d..6339a1c7496b 100644 --- a/frontend/src/components/RouteTab/types.ts +++ b/frontend/src/components/RouteTab/types.ts @@ -13,4 +13,5 @@ export interface RouteTabProps { activeKey: TabsProps['activeKey']; onChangeHandler?: (key: string) => void; history: History; + showRightSection: boolean; } diff --git a/frontend/src/container/LogsError/LogsError.tsx b/frontend/src/container/LogsError/LogsError.tsx index d0f058cc3e0c..d122228d4195 100644 --- a/frontend/src/container/LogsError/LogsError.tsx +++ b/frontend/src/container/LogsError/LogsError.tsx @@ -17,6 +17,7 @@ export default function LogsError(): JSX.Element { window.open('https://signoz.io/slack', '_blank'); } }; + return (
diff --git a/frontend/src/container/NewDashboard/DashboardDescription/Description.styles.scss b/frontend/src/container/NewDashboard/DashboardDescription/Description.styles.scss index 0f4b2dcc9565..f2b20f0d37cc 100644 --- a/frontend/src/container/NewDashboard/DashboardDescription/Description.styles.scss +++ b/frontend/src/container/NewDashboard/DashboardDescription/Description.styles.scss @@ -57,14 +57,14 @@ border-bottom: 1px solid var(--bg-slate-400); display: flex; justify-content: space-between; + gap: 16px; align-items: center; - margin-right: 16px; + padding: 0 8px; box-sizing: border-box; .dashboard-breadcrumbs { width: 100%; height: 48px; - padding: 16px; display: flex; gap: 6px; align-items: center; diff --git a/frontend/src/container/NewDashboard/DashboardDescription/index.tsx b/frontend/src/container/NewDashboard/DashboardDescription/index.tsx index 287cf1219ca8..154c63674962 100644 --- a/frontend/src/container/NewDashboard/DashboardDescription/index.tsx +++ b/frontend/src/container/NewDashboard/DashboardDescription/index.tsx @@ -12,6 +12,7 @@ import { Typography, } from 'antd'; import logEvent from 'api/common/logEvent'; +import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection'; import { QueryParams } from 'constants/query'; import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; @@ -321,6 +322,12 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element { {title} + +
diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/DateTimeSelectionV2.styles.scss b/frontend/src/container/TopNav/DateTimeSelectionV2/DateTimeSelectionV2.styles.scss index a2ff345dad2c..70806fcf4133 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/DateTimeSelectionV2.styles.scss +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/DateTimeSelectionV2.styles.scss @@ -76,51 +76,8 @@ } } -.share-modal-content { - display: flex; - flex-direction: column; - gap: 8px; - padding: 16px; - width: 420px; - - .absolute-relative-time-toggler-container { - display: flex; - gap: 8px; - align-items: center; - } - - .absolute-relative-time-toggler { - display: flex; - gap: 4px; - align-items: center; - } - - .absolute-relative-time-error { - font-size: 12px; - color: var(--bg-amber-600); - } - - .share-link { - display: flex; - align-items: center; - - .share-url { - flex: 1; - border: 1px solid var(--bg-slate-400); - border-radius: 2px; - background: var(--bg-ink-300); - height: 32px; - padding: 6px 8px; - } - - .copy-url-btn { - width: 32px; - } - } -} - .date-time-root, -.shareable-link-popover-root { +.header-section-popover-root { .ant-popover-inner { border-radius: 4px !important; border: 1px solid var(--bg-slate-400); @@ -359,7 +316,7 @@ } .date-time-root, - .shareable-link-popover-root { + .header-section-popover-root { .ant-popover-inner { border: 1px solid var(--bg-vanilla-400); background: var(--bg-vanilla-100) !important; @@ -471,14 +428,6 @@ } } - .share-modal-content { - .share-link { - .share-url { - border: 1px solid var(--bg-vanilla-300); - background: var(--bg-vanilla-100); - } - } - } .reset-button { background: var(--bg-vanilla-100); border-color: var(--bg-vanilla-300); diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx b/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx index e4dbc0e1b553..12ea0ab63ab7 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx @@ -1,8 +1,7 @@ import './DateTimeSelectionV2.styles.scss'; import { SyncOutlined } from '@ant-design/icons'; -import { Color } from '@signozhq/design-tokens'; -import { Button, Popover, Switch, Typography } from 'antd'; +import { Button } from 'antd'; import getLocalStorageKey from 'api/browser/localstorage/get'; import setLocalStorageKey from 'api/browser/localstorage/set'; import CustomTimePicker from 'components/CustomTimePicker/CustomTimePicker'; @@ -15,16 +14,15 @@ import dayjs, { Dayjs } from 'dayjs'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useSafeNavigate } from 'hooks/useSafeNavigate'; import useUrlQuery from 'hooks/useUrlQuery'; -import GetMinMax, { isValidTimeFormat } from 'lib/getMinMax'; +import { isValidTimeFormat } from 'lib/getMinMax'; import getTimeString from 'lib/getTimeString'; import { cloneDeep, isObject } from 'lodash-es'; -import { Check, Copy, Info, Send, Undo } from 'lucide-react'; +import { Undo } from 'lucide-react'; import { useTimezone } from 'providers/Timezone'; import { useCallback, useEffect, useState } from 'react'; import { connect, useDispatch, useSelector } from 'react-redux'; import { RouteComponentProps, withRouter } from 'react-router-dom'; import { useNavigationType, useSearchParams } from 'react-router-dom-v5-compat'; -import { useCopyToClipboard } from 'react-use'; import { bindActionCreators, Dispatch } from 'redux'; import { ThunkDispatch } from 'redux-thunk'; import { GlobalTimeLoading, UpdateTimeInterval } from 'store/actions'; @@ -53,7 +51,6 @@ import { Form, FormContainer, FormItem } from './styles'; function DateTimeSelection({ showAutoRefresh, showRefreshText = true, - hideShareModal = false, location, updateTimeInterval, globalTimeLoading, @@ -81,10 +78,6 @@ function DateTimeSelection({ const searchStartTime = urlQuery.get('startTime'); const searchEndTime = urlQuery.get('endTime'); const relativeTimeFromUrl = urlQuery.get(QueryParams.relativeTime); - const [enableAbsoluteTime, setEnableAbsoluteTime] = useState(false); - const [isValidteRelativeTime, setIsValidteRelativeTime] = useState(false); - const [, handleCopyToClipboard] = useCopyToClipboard(); - const [isURLCopied, setIsURLCopied] = useState(false); // Prioritize props for initial modal time, fallback to URL params let initialModalStartTime = 0; @@ -324,7 +317,6 @@ function DateTimeSelection({ if (isModalTimeSelection) { if (value === 'custom') { setCustomDTPickerVisible(true); - setIsValidteRelativeTime(false); return; } onTimeChange?.(value); @@ -334,15 +326,12 @@ function DateTimeSelection({ setIsOpen(false); updateTimeInterval(value); updateLocalStorageForRoutes(value); - setIsValidteRelativeTime(true); if (refreshButtonHidden) { setRefreshButtonHidden(false); } } else { setRefreshButtonHidden(true); setCustomDTPickerVisible(true); - setIsValidteRelativeTime(false); - setEnableAbsoluteTime(false); return; } @@ -458,11 +447,6 @@ function DateTimeSelection({ urlQuery.delete('startTime'); urlQuery.delete('endTime'); - setIsValidteRelativeTime(true); - - urlQuery.delete('startTime'); - urlQuery.delete('endTime'); - urlQuery.set(QueryParams.relativeTime, dateTimeStr); const generatedUrl = `${location.pathname}?${urlQuery.toString()}`; @@ -542,7 +526,6 @@ function DateTimeSelection({ const handleRelativeTimeSync = useCallback( (relativeTime: string): void => { updateTimeInterval(relativeTime as Time); - setIsValidteRelativeTime(true); setRefreshButtonHidden(false); }, [updateTimeInterval], @@ -625,8 +608,6 @@ function DateTimeSelection({ const updatedTime = getCustomOrIntervalTime(time, currentRoute); - setIsValidteRelativeTime(updatedTime !== 'custom'); - const [preStartTime = 0, preEndTime = 0] = getTime() || []; setRefreshButtonHidden(updatedTime === 'custom'); @@ -654,95 +635,6 @@ function DateTimeSelection({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [location.pathname, updateTimeInterval, globalTimeLoading]); - // eslint-disable-next-line sonarjs/cognitive-complexity - const shareModalContent = (): JSX.Element => { - let currentUrl = window.location.href; - - const startTime = urlQuery.get(QueryParams.startTime); - const endTime = urlQuery.get(QueryParams.endTime); - const isCustomTime = !!(startTime && endTime && selectedTime === 'custom'); - - if (enableAbsoluteTime || isCustomTime) { - if (selectedTime === 'custom') { - if (searchStartTime && searchEndTime) { - urlQuery.set(QueryParams.startTime, searchStartTime.toString()); - urlQuery.set(QueryParams.endTime, searchEndTime.toString()); - } - } else { - const { minTime, maxTime } = GetMinMax(selectedTime); - - urlQuery.set(QueryParams.startTime, minTime.toString()); - urlQuery.set(QueryParams.endTime, maxTime.toString()); - } - - urlQuery.delete(QueryParams.relativeTime); - - currentUrl = `${window.location.origin}${ - location.pathname - }?${urlQuery.toString()}`; - } else { - urlQuery.delete(QueryParams.startTime); - urlQuery.delete(QueryParams.endTime); - - urlQuery.set(QueryParams.relativeTime, selectedTime); - currentUrl = `${window.location.origin}${ - location.pathname - }?${urlQuery.toString()}`; - } - - return ( -
-
-
- {(selectedTime === 'custom' || !isValidteRelativeTime) && ( - - )} - { - setEnableAbsoluteTime(!enableAbsoluteTime); - }} - /> -
- - Enable Absolute Time -
- - {(selectedTime === 'custom' || !isValidteRelativeTime) && ( -
- Please select / enter valid relative time to toggle. -
- )} - -
- - {currentUrl} - - -
-
- ); - }; - const { timezone } = useTimezone(); const getSelectedValue = (): string => { @@ -814,9 +706,6 @@ function DateTimeSelection({ onValidCustomDateChange={(dateTime): void => { onValidCustomDateHandler(dateTime.timeStr as CustomTimeType); }} - onCustomTimeStatusUpdate={(isValid: boolean): void => { - setIsValidteRelativeTime(isValid); - }} selectedValue={getSelectedValue()} data-testid="dropDown" items={options} @@ -843,24 +732,6 @@ function DateTimeSelection({
)} - - {!hideShareModal && ( - - - - )}
diff --git a/frontend/src/container/TopNav/TopNav.styles.scss b/frontend/src/container/TopNav/TopNav.styles.scss index e2fd2d2f37e6..125d900048e4 100644 --- a/frontend/src/container/TopNav/TopNav.styles.scss +++ b/frontend/src/container/TopNav/TopNav.styles.scss @@ -1,4 +1,17 @@ .top-nav-container { - padding: 0px 8px; - margin-bottom: 16px; + padding: 8px; + border-bottom: 1px solid var(--bg-slate-500); + + display: flex; + align-items: center; + justify-content: end; + gap: 16px; + + margin-bottom: 8px; +} + +.lightMode { + .top-nav-container { + border-bottom: 1px solid var(--bg-vanilla-300); + } } diff --git a/frontend/src/container/TopNav/index.tsx b/frontend/src/container/TopNav/index.tsx index 4aeeadd9f0c7..50a046366990 100644 --- a/frontend/src/container/TopNav/index.tsx +++ b/frontend/src/container/TopNav/index.tsx @@ -1,6 +1,6 @@ import './TopNav.styles.scss'; -import { Col, Row, Space } from 'antd'; +import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection'; import ROUTES from 'constants/routes'; import { useMemo } from 'react'; import { matchPath, useHistory } from 'react-router-dom'; @@ -46,16 +46,9 @@ function TopNav(): JSX.Element | null { return !isRouteToSkip ? (
- - - - -
- -
-
-
- + + +
) : null; } diff --git a/frontend/src/pages/AlertList/AlertList.styles.scss b/frontend/src/pages/AlertList/AlertList.styles.scss index 0d1c58f36b28..e91325c37b6a 100644 --- a/frontend/src/pages/AlertList/AlertList.styles.scss +++ b/frontend/src/pages/AlertList/AlertList.styles.scss @@ -1,5 +1,5 @@ .alerts-container { - .ant-tabs-nav-wrap:first-of-type { - padding-left: 16px; + .ant-tabs-nav { + padding: 0 8px; } } diff --git a/frontend/src/pages/AlertList/index.tsx b/frontend/src/pages/AlertList/index.tsx index 8df082d995b1..c2d54f8cd698 100644 --- a/frontend/src/pages/AlertList/index.tsx +++ b/frontend/src/pages/AlertList/index.tsx @@ -3,6 +3,7 @@ import './AlertList.styles.scss'; import { Tabs } from 'antd'; import { TabsProps } from 'antd/lib'; import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon'; +import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection'; import ROUTES from 'constants/routes'; import AllAlertRules from 'container/ListAlertRules'; import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime'; @@ -28,7 +29,7 @@ function AllAlertList(): JSX.Element { { label: (
- + Triggered Alerts
), @@ -38,7 +39,7 @@ function AllAlertList(): JSX.Element { { label: (
- + Alert Rules
), @@ -52,7 +53,7 @@ function AllAlertList(): JSX.Element { { label: (
- + Configuration
), @@ -82,6 +83,13 @@ function AllAlertList(): JSX.Element { className={`alerts-container ${ isAlertHistory || isAlertOverview ? 'alert-details-tabs' : '' }`} + tabBarExtraContent={ + + } /> ); } diff --git a/frontend/src/pages/AllErrors/AllErrors.styles.scss b/frontend/src/pages/AllErrors/AllErrors.styles.scss index 07e953a2a7d4..ebc8ad2f8091 100644 --- a/frontend/src/pages/AllErrors/AllErrors.styles.scss +++ b/frontend/src/pages/AllErrors/AllErrors.styles.scss @@ -7,7 +7,12 @@ } .all-errors-right-section { - padding: 0 10px; + .right-toolbar-actions-container { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + } } .ant-tabs { diff --git a/frontend/src/pages/AllErrors/index.tsx b/frontend/src/pages/AllErrors/index.tsx index d2f048c4a66e..6b4e0749501b 100644 --- a/frontend/src/pages/AllErrors/index.tsx +++ b/frontend/src/pages/AllErrors/index.tsx @@ -5,6 +5,7 @@ import { Button, Tooltip } from 'antd'; import getLocalStorageKey from 'api/browser/localstorage/get'; import setLocalStorageApi from 'api/browser/localstorage/set'; import cx from 'classnames'; +import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection'; import QuickFilters from 'components/QuickFilters/QuickFilters'; import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types'; import RouteTab from 'components/RouteTab'; @@ -74,10 +75,24 @@ function AllErrors(): JSX.Element { ) : undefined } - rightActions={} + rightActions={ +
+ + +
+ } /> - + diff --git a/frontend/src/pages/DashboardsListPage/DashboardsListPage.styles.scss b/frontend/src/pages/DashboardsListPage/DashboardsListPage.styles.scss index af36aadaac2d..a2b7ccde5828 100644 --- a/frontend/src/pages/DashboardsListPage/DashboardsListPage.styles.scss +++ b/frontend/src/pages/DashboardsListPage/DashboardsListPage.styles.scss @@ -2,11 +2,18 @@ .dashboard-header { display: flex; align-items: center; - padding: 16px; + justify-content: space-between; + padding: 0 8px; gap: 8px; height: 48px; border-bottom: 1px solid var(--bg-slate-500); + .dashboard-header-left { + display: flex; + align-items: center; + gap: 8px; + } + .icon { color: var(--bg-vanilla-400); } diff --git a/frontend/src/pages/DashboardsListPage/DashboardsListPage.tsx b/frontend/src/pages/DashboardsListPage/DashboardsListPage.tsx index 6779fd945ae8..f66da0abc2a8 100644 --- a/frontend/src/pages/DashboardsListPage/DashboardsListPage.tsx +++ b/frontend/src/pages/DashboardsListPage/DashboardsListPage.tsx @@ -1,6 +1,7 @@ import './DashboardsListPage.styles.scss'; import { Space, Typography } from 'antd'; +import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection'; import ListOfAllDashboard from 'container/ListOfDashboard'; import { LayoutGrid } from 'lucide-react'; @@ -13,8 +14,16 @@ function DashboardsListPage(): JSX.Element { className="dashboard-list-page" >
- - Dashboards +
+ + Dashboards +
+ +
diff --git a/frontend/src/pages/Settings/Settings.styles.scss b/frontend/src/pages/Settings/Settings.styles.scss index ae31384c0e6b..936ff20c1c19 100644 --- a/frontend/src/pages/Settings/Settings.styles.scss +++ b/frontend/src/pages/Settings/Settings.styles.scss @@ -33,7 +33,6 @@ height: calc(100vh - 48px); border-right: 1px solid var(--Slate-500, #161922); background: var(--Ink-500, #0b0c0e); - padding: 10px 8px; } .settings-page-content { diff --git a/frontend/src/pages/Support/Support.styles.scss b/frontend/src/pages/Support/Support.styles.scss index e298f74d8a54..4d63414a9089 100644 --- a/frontend/src/pages/Support/Support.styles.scss +++ b/frontend/src/pages/Support/Support.styles.scss @@ -4,7 +4,7 @@ padding-right: 48px; max-width: 1400px; - margin: 0 auto; + margin: 64px auto; } .support-channels { @@ -19,6 +19,7 @@ flex: 0 0 calc(33.333% - 32px); min-height: 200px; position: relative; + border: none !important; .support-channel-title { width: 100%; diff --git a/frontend/src/pages/Support/Support.tsx b/frontend/src/pages/Support/Support.tsx index 7e8415bde637..0b3dad39fff1 100644 --- a/frontend/src/pages/Support/Support.tsx +++ b/frontend/src/pages/Support/Support.tsx @@ -205,6 +205,7 @@ export default function Support(): JSX.Element {
,