import type {UseQueryResult} from '@tanstack/react-query'; import {BroadcastFixture} from 'sentry-fixture/broadcast'; import {LocationFixture} from 'sentry-fixture/locationFixture'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {ServiceIncidentFixture} from 'sentry-fixture/serviceIncident'; import {UserFixture} from 'sentry-fixture/user'; import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; import {logout} from 'sentry/actionCreators/account'; import {OnboardingContextProvider} from 'sentry/components/onboarding/onboardingContext'; import SidebarContainer from 'sentry/components/sidebar'; import ConfigStore from 'sentry/stores/configStore'; import type {Organization} from 'sentry/types/organization'; import type {StatuspageIncident} from 'sentry/types/system'; import localStorage from 'sentry/utils/localStorage'; import {useLocation} from 'sentry/utils/useLocation'; import * as incidentsHook from 'sentry/utils/useServiceIncidents'; jest.mock('sentry/actionCreators/account'); jest.mock('sentry/utils/useServiceIncidents'); jest.mock('sentry/utils/useLocation'); const mockUseLocation = jest.mocked(useLocation); const ALL_AVAILABLE_FEATURES = [ 'insights-entry-points', 'discover', 'discover-basic', 'discover-query', 'dashboards-basic', 'dashboards-edit', 'custom-metrics', 'user-feedback-ui', 'session-replay-ui', 'performance-view', 'performance-trace-explorer', 'profiling', ]; describe('Sidebar', function () { const organization = OrganizationFixture(); const broadcast = BroadcastFixture(); const user = UserFixture(); const apiMocks = { broadcasts: jest.fn(), broadcastsMarkAsSeen: jest.fn(), sdkUpdates: jest.fn(), }; const getElement = () => ( ); const renderSidebar = ({organization: org}: {organization: Organization | null}) => render(getElement(), {organization: org}); const renderSidebarWithFeatures = (features: string[] = []) => { return renderSidebar({ organization: { ...organization, features: [...organization.features, ...features], }, }); }; beforeEach(function () { mockUseLocation.mockReturnValue(LocationFixture()); jest.spyOn(incidentsHook, 'useServiceIncidents').mockImplementation( () => ({ data: [ServiceIncidentFixture()], }) as UseQueryResult ); apiMocks.broadcasts = MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/broadcasts/`, body: [broadcast], }); apiMocks.broadcastsMarkAsSeen = MockApiClient.addMockResponse({ url: '/broadcasts/', method: 'PUT', }); apiMocks.sdkUpdates = MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/sdk-updates/`, body: [], }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/onboarding-tasks/`, method: 'GET', body: { onboardingTasks: [], }, }); }); afterEach(function () { mockUseLocation.mockReset(); }); it('renders', async function () { renderSidebar({organization}); expect(await screen.findByTestId('sidebar-dropdown')).toBeInTheDocument(); }); it('renders without org', async function () { renderSidebar({organization: null}); // no org displays user details expect(await screen.findByText(user.name)).toBeInTheDocument(); expect(screen.getByText(user.email)).toBeInTheDocument(); await userEvent.click(screen.getByTestId('sidebar-dropdown')); }); it('does not render collapse with navigation-sidebar-v2 flag', async function () { renderSidebar({ organization: {...organization, features: ['navigation-sidebar-v2']}, }); // await for the page to be rendered expect(await screen.findByText('Issues')).toBeInTheDocument(); // Check that the user name is no longer visible expect(screen.queryByText(user.name)).not.toBeInTheDocument(); // Check that the organization name is no longer visible expect(screen.queryByText(organization.name)).not.toBeInTheDocument(); expect(screen.queryByTestId('sidebar-collapse')).not.toBeInTheDocument(); }); it('has can logout', async function () { renderSidebar({ organization: OrganizationFixture({access: ['member:read']}), }); await userEvent.click(await screen.findByTestId('sidebar-dropdown')); await userEvent.click(screen.getByTestId('sidebar-signout')); await waitFor(() => expect(logout).toHaveBeenCalled()); }); it('can toggle help menu', async function () { renderSidebar({organization}); await userEvent.click(await screen.findByText('Help')); expect(screen.getByText('Visit Help Center')).toBeInTheDocument(); }); describe('SidebarDropdown', function () { it('can open Sidebar org/name dropdown menu', async function () { renderSidebar({organization}); await userEvent.click(await screen.findByTestId('sidebar-dropdown')); const orgSettingsLink = screen.getByText('Organization settings'); expect(orgSettingsLink).toBeInTheDocument(); }); it('has link to Members settings with `member:write`', async function () { renderSidebar({ organization: OrganizationFixture({access: ['member:read']}), }); await userEvent.click(await screen.findByTestId('sidebar-dropdown')); expect(screen.getByText('Members')).toBeInTheDocument(); }); it('can open "Switch Organization" sub-menu', async function () { act(() => void ConfigStore.set('features', new Set(['organizations:create']))); renderSidebar({organization}); await userEvent.click(await screen.findByTestId('sidebar-dropdown')); jest.useFakeTimers(); await userEvent.hover(screen.getByText('Switch organization'), {delay: null}); act(() => jest.advanceTimersByTime(500)); jest.useRealTimers(); const createOrg = screen.getByText('Create a new organization'); expect(createOrg).toBeInTheDocument(); }); }); describe('SidebarPanel', function () { it('hides when path changes', async function () { const {rerender} = renderSidebar({organization}); await userEvent.click(await screen.findByText("What's new")); expect(await screen.findByRole('dialog')).toBeInTheDocument(); expect(screen.getByText("What's new in Sentry")).toBeInTheDocument(); mockUseLocation.mockReturnValue({...LocationFixture(), pathname: '/other/path'}); rerender(getElement()); expect(screen.queryByText("What's new in Sentry")).not.toBeInTheDocument(); }); it('can have onboarding feature', async function () { renderSidebar({ organization: {...organization, features: ['onboarding']}, }); const quickStart = await screen.findByText('Onboarding'); expect(quickStart).toBeInTheDocument(); await userEvent.click(quickStart); expect(await screen.findByRole('dialog')).toBeInTheDocument(); expect(screen.getByText('Capture your first error')).toBeInTheDocument(); await userEvent.click(quickStart); expect(screen.queryByText('Capture your first error')).not.toBeInTheDocument(); await tick(); }); it('displays empty panel when there are no Broadcasts', async function () { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/broadcasts/`, body: [], }); renderSidebar({organization}); await userEvent.click(await screen.findByText("What's new")); expect(await screen.findByRole('dialog')).toBeInTheDocument(); expect(screen.getByText("What's new in Sentry")).toBeInTheDocument(); expect( screen.getByText('No recent updates from the Sentry team.') ).toBeInTheDocument(); // Close the sidebar await userEvent.click(screen.getByText("What's new")); expect(screen.queryByText("What's new in Sentry")).not.toBeInTheDocument(); await tick(); }); it('can display Broadcasts panel and mark as seen', async function () { jest.useFakeTimers(); renderSidebar({organization}); expect(apiMocks.broadcasts).toHaveBeenCalled(); await userEvent.click(await screen.findByText("What's new"), {delay: null}); expect(await screen.findByRole('dialog')).toBeInTheDocument(); expect(screen.getByText("What's new in Sentry")).toBeInTheDocument(); const broadcastTitle = screen.getByText(broadcast.title); expect(broadcastTitle).toBeInTheDocument(); // Should mark as seen after a delay act(() => jest.advanceTimersByTime(2000)); await waitFor(() => { expect(apiMocks.broadcastsMarkAsSeen).toHaveBeenCalledWith( '/broadcasts/', expect.objectContaining({ data: {hasSeen: '1'}, query: {id: ['8']}, }) ); }); jest.useRealTimers(); // Close the sidebar await userEvent.click(screen.getByText("What's new")); expect(screen.queryByText("What's new in Sentry")).not.toBeInTheDocument(); await tick(); }); it('can unmount Sidebar (and Broadcasts) and kills Broadcast timers', async function () { jest.useFakeTimers(); const {unmount} = renderSidebar({organization}); // This will start timer to mark as seen await userEvent.click(await screen.findByTestId('sidebar-broadcasts'), { delay: null, }); expect(await screen.findByText("What's new in Sentry")).toBeInTheDocument(); act(() => jest.advanceTimersByTime(500)); // Unmounting will cancel timers unmount(); // This advances timers enough so that mark as seen should be called if // it wasn't unmounted act(() => jest.advanceTimersByTime(600)); expect(apiMocks.broadcastsMarkAsSeen).not.toHaveBeenCalled(); jest.useRealTimers(); }); it('can show Incidents in Sidebar Panel', async function () { renderSidebar({organization}); await userEvent.click(await screen.findByText(/Service status/)); await screen.findByText('Recent service updates'); }); }); it('can toggle collapsed state', async function () { renderSidebar({organization}); expect(await screen.findByText(user.name)).toBeInTheDocument(); expect(screen.getByText(organization.name)).toBeInTheDocument(); await userEvent.click(screen.getByTestId('sidebar-collapse')); // Check that the organization name is no longer visible expect(screen.queryByText(organization.name)).not.toBeInTheDocument(); // Un-collapse he sidebar and make sure the org name is visible again await userEvent.click(screen.getByTestId('sidebar-collapse')); expect(await screen.findByText(organization.name)).toBeInTheDocument(); }); describe('sidebar links', () => { beforeEach(function () { ConfigStore.init(); ConfigStore.set('features', new Set([])); ConfigStore.set('user', user); mockUseLocation.mockReturnValue({...LocationFixture()}); }); it('renders navigation', async function () { renderSidebar({organization}); await waitFor(function () { expect(apiMocks.broadcasts).toHaveBeenCalled(); }); expect( screen.getByRole('navigation', {name: 'Primary Navigation'}) ).toBeInTheDocument(); }); it('in self-hosted-errors-only mode, only shows links to basic features', async function () { ConfigStore.set('isSelfHostedErrorsOnly', true); renderSidebarWithFeatures(ALL_AVAILABLE_FEATURES); await waitFor(function () { expect(apiMocks.broadcasts).toHaveBeenCalled(); }); const links = screen.getAllByRole('link'); expect(links).toHaveLength(12); [ 'Issues', 'Projects', 'Alerts', 'Discover', 'Dashboards', 'Releases', 'Stats', 'Settings', 'Help', /What's new/, /Service status/, ].forEach((title, index) => { expect(links[index]).toHaveAccessibleName(title); }); }); it('in regular mode, also shows links to Performance and Crons', async function () { localStorage.setItem('sidebar-accordion-insights:expanded', 'true'); renderSidebarWithFeatures([...ALL_AVAILABLE_FEATURES]); await waitFor(function () { expect(apiMocks.broadcasts).toHaveBeenCalled(); }); const links = screen.getAllByRole('link'); expect(links).toHaveLength(25); [ 'Issues', 'Projects', /Explore/, /Traces/, /Metrics/, 'Profiles', 'Replays', 'Discover', /Insights/, 'Frontend', 'Backend', 'Mobile', 'AI', 'Performance', 'User Feedback', 'Crons', 'Alerts', 'Dashboards', 'Releases', 'Stats', 'Settings', 'Help', /What's new/, /Service status/, ].forEach((title, index) => { expect(links[index]).toHaveAccessibleName(title); }); }); it('should not render floating accordion when expanded', async () => { renderSidebarWithFeatures(ALL_AVAILABLE_FEATURES); await userEvent.click( screen.getByTestId('sidebar-accordion-insights-domains-item') ); expect(screen.queryByTestId('floating-accordion')).not.toBeInTheDocument(); }); it('should render floating accordion when collapsed', async () => { renderSidebarWithFeatures(ALL_AVAILABLE_FEATURES); await userEvent.click(screen.getByTestId('sidebar-collapse')); await userEvent.click( screen.getByTestId('sidebar-accordion-insights-domains-item') ); expect(await screen.findByTestId('floating-accordion')).toBeInTheDocument(); }); }); });