diff --git a/.devenv/docker/signoz-otel-collector/compose.yaml b/.devenv/docker/signoz-otel-collector/compose.yaml index 62a931b38de5..eac598f6de69 100644 --- a/.devenv/docker/signoz-otel-collector/compose.yaml +++ b/.devenv/docker/signoz-otel-collector/compose.yaml @@ -1,6 +1,6 @@ services: signoz-otel-collector: - image: signoz/signoz-otel-collector:v0.128.2 + image: signoz/signoz-otel-collector:v0.129.6 container_name: signoz-otel-collector-dev command: - --config=/etc/otel-collector-config.yaml diff --git a/.github/workflows/integrationci.yaml b/.github/workflows/integrationci.yaml index d2683a65375b..35efa27f6e36 100644 --- a/.github/workflows/integrationci.yaml +++ b/.github/workflows/integrationci.yaml @@ -21,10 +21,9 @@ jobs: - postgres - sqlite clickhouse-version: - - 24.1.2-alpine - 25.5.6 schema-migrator-version: - - v0.128.1 + - v0.129.6 postgres-version: - 15 if: | 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/docs/contributing/go/integration.md b/docs/contributing/go/integration.md index 6e1074e6126b..980f4f23ec29 100644 --- a/docs/contributing/go/integration.md +++ b/docs/contributing/go/integration.md @@ -192,7 +192,7 @@ Tests can be configured using pytest options: - `--sqlstore-provider` - Choose database provider (default: postgres) - `--postgres-version` - PostgreSQL version (default: 15) -- `--clickhouse-version` - ClickHouse version (default: 24.1.2-alpine) +- `--clickhouse-version` - ClickHouse version (default: 25.5.6) - `--zookeeper-version` - Zookeeper version (default: 3.7.1) Example: 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/CreateAlertV2/AlertCondition/AlertCondition.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/AlertCondition.tsx index e45a44d2d2b4..a0dd357cd01c 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/AlertCondition.tsx +++ b/frontend/src/container/CreateAlertV2/AlertCondition/AlertCondition.tsx @@ -6,13 +6,16 @@ import { Activity, ChartLine } from 'lucide-react'; import { AlertTypes } from 'types/api/alerts/alertTypes'; import { useCreateAlertState } from '../context'; +import AdvancedOptions from '../EvaluationSettings/AdvancedOptions'; import Stepper from '../Stepper'; +import { showCondensedLayout } from '../utils'; import AlertThreshold from './AlertThreshold'; import AnomalyThreshold from './AnomalyThreshold'; import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants'; function AlertCondition(): JSX.Element { const { alertType, setAlertType } = useCreateAlertState(); + const showCondensedLayoutFlag = showCondensedLayout(); const showMultipleTabs = alertType === AlertTypes.ANOMALY_BASED_ALERT || @@ -75,6 +78,11 @@ function AlertCondition(): JSX.Element {
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && } {alertType === AlertTypes.ANOMALY_BASED_ALERT && } + {showCondensedLayoutFlag ? ( +
+ +
+ ) : null}
); } diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx b/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx index 1610602daf2f..ca1934a774a6 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx +++ b/frontend/src/container/CreateAlertV2/AlertCondition/AlertThreshold.tsx @@ -2,6 +2,7 @@ import './styles.scss'; import { Button, Select, Typography } from 'antd'; import getAllChannels from 'api/channels/getAll'; +import classNames from 'classnames'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { Plus } from 'lucide-react'; import { useQuery } from 'react-query'; @@ -17,6 +18,8 @@ import { THRESHOLD_MATCH_TYPE_OPTIONS, THRESHOLD_OPERATOR_OPTIONS, } from '../context/constants'; +import EvaluationSettings from '../EvaluationSettings/EvaluationSettings'; +import { showCondensedLayout } from '../utils'; import ThresholdItem from './ThresholdItem'; import { UpdateThreshold } from './types'; import { @@ -37,6 +40,7 @@ function AlertThreshold(): JSX.Element { >(['getChannels'], { queryFn: () => getAllChannels(), }); + const showCondensedLayoutFlag = showCondensedLayout(); const channels = data?.data || []; const { currentQuery } = useQueryBuilder(); @@ -81,8 +85,18 @@ function AlertThreshold(): JSX.Element { }); }; + const evaluationWindowContext = showCondensedLayoutFlag ? ( + + ) : ( + Evaluation Window. + ); + return ( -
+
{/* Main condition sentence */}
@@ -128,7 +142,7 @@ function AlertThreshold(): JSX.Element { options={THRESHOLD_MATCH_TYPE_OPTIONS} /> - during the Evaluation Window. + during the {evaluationWindowContext}
diff --git a/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss b/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss index bdde72598d6a..8e9fc6e223cc 100644 --- a/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss +++ b/frontend/src/container/CreateAlertV2/AlertCondition/styles.scss @@ -84,6 +84,9 @@ color: var(--text-vanilla-400); font-size: 14px; line-height: 1.5; + display: flex; + align-items: center; + gap: 8px; } .ant-select { @@ -275,3 +278,43 @@ } } } + +.condensed-alert-threshold-container, +.condensed-anomaly-threshold-container { + width: 100%; +} + +.condensed-advanced-options-container { + margin-top: 16px; + width: fit-parent; +} + +.condensed-evaluation-settings-container { + .ant-btn { + display: flex; + align-items: center; + width: 240px; + justify-content: space-between; + background-color: var(--bg-ink-300); + border: 1px solid var(--bg-slate-400); + + .evaluate-alert-conditions-button-left { + color: var(--bg-vanilla-400); + font-size: 12px; + } + + .evaluate-alert-conditions-button-right { + display: flex; + align-items: center; + color: var(--bg-vanilla-400); + gap: 8px; + + .evaluate-alert-conditions-button-right-text { + font-size: 12px; + font-weight: 500; + background-color: var(--bg-slate-400); + padding: 1px 4px; + } + } + } +} diff --git a/frontend/src/container/CreateAlertV2/CreateAlertV2.tsx b/frontend/src/container/CreateAlertV2/CreateAlertV2.tsx index 589a18fc6ca1..0ce7e0821fff 100644 --- a/frontend/src/container/CreateAlertV2/CreateAlertV2.tsx +++ b/frontend/src/container/CreateAlertV2/CreateAlertV2.tsx @@ -7,7 +7,10 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData'; import AlertCondition from './AlertCondition'; import { CreateAlertProvider } from './context'; import CreateAlertHeader from './CreateAlertHeader'; +import EvaluationSettings from './EvaluationSettings'; +import NotificationSettings from './NotificationSettings'; import QuerySection from './QuerySection'; +import { showCondensedLayout } from './utils'; function CreateAlertV2({ initialQuery = initialQueriesMap.metrics, @@ -16,14 +19,18 @@ function CreateAlertV2({ }): JSX.Element { useShareBuilderUrl({ defaultValue: initialQuery }); + const showCondensedLayoutFlag = showCondensedLayout(); + return ( -
- + +
- -
+ {!showCondensedLayoutFlag ? : null} + +
+ ); } diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/AdvancedOptions.tsx b/frontend/src/container/CreateAlertV2/EvaluationSettings/AdvancedOptions.tsx new file mode 100644 index 000000000000..a560c449e8aa --- /dev/null +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/AdvancedOptions.tsx @@ -0,0 +1,129 @@ +import { Collapse, Input, Select, Typography } from 'antd'; +import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants'; + +import { useCreateAlertState } from '../context'; +import AdvancedOptionItem from './AdvancedOptionItem'; +import EvaluationCadence from './EvaluationCadence'; + +function AdvancedOptions(): JSX.Element { + const { advancedOptions, setAdvancedOptions } = useCreateAlertState(); + + const timeOptions = Y_AXIS_CATEGORIES.find( + (category) => category.name === 'Time', + )?.units.map((unit) => ({ label: unit.name, value: unit.id })); + + return ( +
+ + + + + + setAdvancedOptions({ + type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING', + payload: { + toleranceLimit: Number(e.target.value), + timeUnit: advancedOptions.sendNotificationIfDataIsMissing.timeUnit, + }, + }) + } + value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit} + /> + + setAdvancedOptions({ + type: 'SET_ENFORCE_MINIMUM_DATAPOINTS', + payload: { + minimumDatapoints: Number(e.target.value), + }, + }) + } + value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints} + /> + Datapoints +
+ } + /> + + + setAdvancedOptions({ + type: 'SET_DELAY_EVALUATION', + payload: { + delay: Number(e.target.value), + timeUnit: advancedOptions.delayEvaluation.timeUnit, + }, + }) + } + value={advancedOptions.delayEvaluation.delay} + /> + +
+
+ ); + } + + if (isCurrentDay) { + return ( +
+ {CUMULATIVE_WINDOW_DESCRIPTION} + {displayText} +
+ STARTING AT + +
+
+ SELECT TIMEZONE + +
+
+ STARTING AT + +
+
+ SELECT TIMEZONE + handleNumberChange(e.target.value)} + placeholder="Enter value" + /> +
+
+ UNIT + +
+ ), +})); + +describe('EvaluationWindowPopover', () => { + it('should render the evaluation window popover with 3 sections', () => { + render( + , + ); + expect(screen.getByText(EVALUATION_WINDOW_TEXT)).toBeInTheDocument(); + }); + + it('should render all window type options with rolling selected', () => { + render( + , + ); + EVALUATION_WINDOW_TYPE.forEach((option) => { + expect(screen.getByText(option.label)).toBeInTheDocument(); + }); + const rollingItem = screen + .getByText('Rolling') + .closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement; + expect(rollingItem).toHaveClass('active'); + + const cumulativeItem = screen + .getByText('Cumulative') + .closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement; + expect(cumulativeItem).not.toHaveClass('active'); + }); + + it('should render all window type options with cumulative selected', () => { + render( + , + ); + EVALUATION_WINDOW_TYPE.forEach((option) => { + expect(screen.getByText(option.label)).toBeInTheDocument(); + }); + + const cumulativeItem = screen + .getByText('Cumulative') + .closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement; + expect(cumulativeItem).toHaveClass('active'); + const rollingItem = screen + .getByText('Rolling') + .closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement; + expect(rollingItem).not.toHaveClass('active'); + }); + + it('should render all timeframe options in rolling mode with last 5 minutes selected by default', () => { + render( + , + ); + EVALUATION_WINDOW_TIMEFRAME.rolling.forEach((option) => { + expect(screen.getByText(option.label)).toBeInTheDocument(); + }); + const last5MinutesItem = screen + .getByText(LAST_5_MINUTES_TEXT) + .closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement; + expect(last5MinutesItem).toHaveClass('active'); + }); + + it('should render all timeframe options in cumulative mode with current hour selected by default', () => { + render( + , + ); + EVALUATION_WINDOW_TIMEFRAME.cumulative.forEach((option) => { + expect(screen.getByText(option.label)).toBeInTheDocument(); + }); + const currentHourItem = screen + .getByText('Current hour') + .closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement; + expect(currentHourItem).toHaveClass('active'); + }); + + it('renders help text in details section for rolling mode with non-custom timeframe', () => { + render( + , + ); + expect( + screen.getByText( + 'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.', + ), + ).toBeInTheDocument(); + expect( + screen.queryByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID), + ).not.toBeInTheDocument(); + }); + + it('renders EvaluationWindowDetails component in details section for rolling mode with custom timeframe', () => { + render( + , + ); + + expect( + screen.queryByText( + 'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.', + ), + ).not.toBeInTheDocument(); + expect( + screen.getByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID), + ).toBeInTheDocument(); + }); + + it('renders EvaluationWindowDetails component in details section for cumulative mode', () => { + render( + , + ); + expect( + screen.queryByText( + 'A Cumulative Window has a fixed starting point and expands over time.', + ), + ).not.toBeInTheDocument(); + expect( + screen.getByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID), + ).toBeInTheDocument(); + }); + + describe('keyboard navigation', () => { + it('should navigate down through window type options', () => { + render( + , + ); + + const rollingItem = screen + .getByText('Rolling') + .closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement; + rollingItem?.focus(); + + fireEvent.keyDown(rollingItem, { key: 'ArrowDown' }); + const cumulativeItem = screen + .getByText('Cumulative') + .closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS); + expect(cumulativeItem).toHaveFocus(); + }); + + it('should navigate up through window type options', () => { + render( + , + ); + + const cumulativeItem = screen + .getByText('Cumulative') + .closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement; + cumulativeItem?.focus(); + + fireEvent.keyDown(cumulativeItem, { key: 'ArrowUp' }); + const rollingItem = screen + .getByText('Rolling') + .closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS); + expect(rollingItem).toHaveFocus(); + }); + + it('should navigate right from window type to timeframe', () => { + render( + , + ); + + const rollingItem = screen + .getByText('Rolling') + .closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement; + rollingItem?.focus(); + + fireEvent.keyDown(rollingItem, { key: 'ArrowRight' }); + const timeframeItem = screen + .getByText(LAST_5_MINUTES_TEXT) + .closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS); + expect(timeframeItem).toHaveFocus(); + }); + + it('should navigate left from timeframe to window type', () => { + render( + , + ); + + const timeframeItem = screen + .getByText(LAST_5_MINUTES_TEXT) + .closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement; + timeframeItem?.focus(); + + fireEvent.keyDown(timeframeItem, { key: 'ArrowLeft' }); + const rollingItem = screen + .getByText('Rolling') + .closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS); + expect(rollingItem).toHaveFocus(); + }); + + it('should select option with Enter key', () => { + render( + , + ); + + const cumulativeItem = screen + .getByText('Cumulative') + .closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement; + cumulativeItem?.focus(); + + fireEvent.keyDown(cumulativeItem, { key: 'Enter' }); + expect(mockSetEvaluationWindow).toHaveBeenCalledWith({ + type: 'SET_WINDOW_TYPE', + payload: 'cumulative', + }); + }); + + it('should select option with Space key', () => { + render( + , + ); + + const cumulativeItem = screen + .getByText('Cumulative') + .closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement; + cumulativeItem?.focus(); + + fireEvent.keyDown(cumulativeItem, { key: ' ' }); + expect(mockSetEvaluationWindow).toHaveBeenCalledWith({ + type: 'SET_WINDOW_TYPE', + payload: 'cumulative', + }); + }); + }); +}); diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/testUtils.ts b/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/testUtils.ts index f73910bec1f2..b37bca228b41 100644 --- a/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/testUtils.ts +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/__tests__/testUtils.ts @@ -3,8 +3,12 @@ import { INITIAL_ALERT_STATE, INITIAL_ALERT_THRESHOLD_STATE, INITIAL_EVALUATION_WINDOW_STATE, + INITIAL_NOTIFICATION_SETTINGS_STATE, } from 'container/CreateAlertV2/context/constants'; -import { ICreateAlertContextProps } from 'container/CreateAlertV2/context/types'; +import { + EvaluationWindowState, + ICreateAlertContextProps, +} from 'container/CreateAlertV2/context/types'; import { AlertTypes } from 'types/api/alerts/alertTypes'; export const createMockAlertContextState = ( @@ -20,5 +24,14 @@ export const createMockAlertContextState = ( setAdvancedOptions: jest.fn(), evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE, setEvaluationWindow: jest.fn(), + notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE, + setNotificationSettings: jest.fn(), + ...overrides, +}); + +export const createMockEvaluationWindowState = ( + overrides?: Partial, +): EvaluationWindowState => ({ + ...INITIAL_EVALUATION_WINDOW_STATE, ...overrides, }); diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/constants.ts b/frontend/src/container/CreateAlertV2/EvaluationSettings/constants.ts index 61f07dc8829b..4319b765565e 100644 --- a/frontend/src/container/CreateAlertV2/EvaluationSettings/constants.ts +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/constants.ts @@ -14,6 +14,7 @@ export const EVALUATION_WINDOW_TIMEFRAME = { { label: 'Last 1 hour', value: '1h0m0s' }, { label: 'Last 2 hours', value: '2h0m0s' }, { label: 'Last 4 hours', value: '4h0m0s' }, + { label: 'Custom', value: 'custom' }, ], cumulative: [ { label: 'Current hour', value: 'currentHour' }, @@ -60,3 +61,9 @@ export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({ label: `${timezone.name} (${timezone.offset})`, value: timezone.value, })); + +export const CUMULATIVE_WINDOW_DESCRIPTION = + 'A Cumulative Window has a fixed starting point and expands over time.'; + +export const ROLLING_WINDOW_DESCRIPTION = + 'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.'; diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/index.ts b/frontend/src/container/CreateAlertV2/EvaluationSettings/index.ts new file mode 100644 index 000000000000..e3637027affc --- /dev/null +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/index.ts @@ -0,0 +1,3 @@ +import EvaluationSettings from './EvaluationSettings'; + +export default EvaluationSettings; diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/styles.scss b/frontend/src/container/CreateAlertV2/EvaluationSettings/styles.scss index 80860e4ead46..6cb15a2a87ef 100644 --- a/frontend/src/container/CreateAlertV2/EvaluationSettings/styles.scss +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/styles.scss @@ -238,7 +238,7 @@ } } - .ant-input { + .select-group .ant-input:not(.time-input-field) { background-color: var(--bg-ink-300); border: 1px solid var(--bg-slate-400); color: var(--bg-vanilla-100); diff --git a/frontend/src/container/CreateAlertV2/EvaluationSettings/types.ts b/frontend/src/container/CreateAlertV2/EvaluationSettings/types.ts index 5b6fecc4e97f..f3f0afadd69c 100644 --- a/frontend/src/container/CreateAlertV2/EvaluationSettings/types.ts +++ b/frontend/src/container/CreateAlertV2/EvaluationSettings/types.ts @@ -32,8 +32,6 @@ export enum CumulativeWindowTimeframes { export interface IEvaluationWindowPopoverProps { evaluationWindow: EvaluationWindowState; setEvaluationWindow: Dispatch; - isOpen: boolean; - setIsOpen: Dispatch>; } export interface IEvaluationWindowDetailsProps { diff --git a/frontend/src/container/CreateAlertV2/NotificationSettings/MultipleNotifications.tsx b/frontend/src/container/CreateAlertV2/NotificationSettings/MultipleNotifications.tsx new file mode 100644 index 000000000000..21274cc6a983 --- /dev/null +++ b/frontend/src/container/CreateAlertV2/NotificationSettings/MultipleNotifications.tsx @@ -0,0 +1,97 @@ +import { Select, Tooltip, Typography } from 'antd'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { Info } from 'lucide-react'; +import { useMemo } from 'react'; + +import { useCreateAlertState } from '../context'; + +function MultipleNotifications(): JSX.Element { + const { + notificationSettings, + setNotificationSettings, + } = useCreateAlertState(); + const { currentQuery } = useQueryBuilder(); + + const spaceAggregationOptions = useMemo(() => { + const allGroupBys = currentQuery.builder.queryData?.reduce( + (acc, query) => { + const groupByKeys = query.groupBy?.map((groupBy) => groupBy.key) || []; + return [...acc, ...groupByKeys]; + }, + [], + ); + const uniqueGroupBys = [...new Set(allGroupBys)]; + return uniqueGroupBys.map((key) => ({ + label: key, + value: key, + })); + }, [currentQuery.builder.queryData]); + + const isMultipleNotificationsEnabled = spaceAggregationOptions.length > 0; + + const multipleNotificationsInput = useMemo(() => { + const placeholder = isMultipleNotificationsEnabled + ? 'Select fields to group by (optional)' + : 'No grouping fields available'; + let input = ( +
+