diff --git a/static/app/components/sidebar/index.tsx b/static/app/components/sidebar/index.tsx index 11e95e7cf3d3dc..a40e2c42060c4e 100644 --- a/static/app/components/sidebar/index.tsx +++ b/static/app/components/sidebar/index.tsx @@ -1,9 +1,8 @@ -import * as React from 'react'; +import {Fragment, useEffect} from 'react'; import {browserHistory} from 'react-router'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; import {Location} from 'history'; -import isEqual from 'lodash/isEqual'; import * as queryString from 'query-string'; import {hideSidebar, showSidebar} from 'app/actionCreators/preferences'; @@ -32,11 +31,12 @@ import ConfigStore from 'app/stores/configStore'; import HookStore from 'app/stores/hookStore'; import PreferencesStore from 'app/stores/preferencesStore'; import SidebarPanelStore from 'app/stores/sidebarPanelStore'; +import {useLegacyStore} from 'app/stores/useLegacyStore'; import space from 'app/styles/space'; import {Organization} from 'app/types'; import {getDiscoverLandingUrl} from 'app/utils/discover/urls'; import theme from 'app/utils/theme'; -import withOrganization from 'app/utils/withOrganization'; +import useMedia from 'app/utils/useMedia'; import Broadcasts from './broadcasts'; import SidebarHelp from './help'; @@ -48,131 +48,63 @@ import {SidebarOrientation, SidebarPanelKey} from './types'; const SidebarOverride = HookOrDefault({ hookName: 'sidebar:item-override', - defaultComponent: ({children}) => {children({})}, + defaultComponent: ({children}) => {children({})}, }); -type ActivePanelType = SidebarPanelKey | ''; - type Props = { - organization: Organization; - activePanel: ActivePanelType; - collapsed: boolean; + organization?: Organization; location?: Location; - children?: never; }; -type State = { - horizontal: boolean; -}; +function Sidebar({location, organization}: Props) { + const config = useLegacyStore(ConfigStore); + const preferences = useLegacyStore(PreferencesStore); + const activePanel = useLegacyStore(SidebarPanelStore); -class Sidebar extends React.Component { - constructor(props: Props) { - super(props); + const collapsed = !!preferences.collapsed; + const horizontal = useMedia(`(max-width: ${theme.breakpoints[1]})`); - if (!window.matchMedia) { - return; - } - - // TODO(billy): We should consider moving this into a component - this.mq = window.matchMedia(`(max-width: ${theme.breakpoints[1]})`); - this.mq.addListener(this.handleMediaQueryChange); - this.state.horizontal = this.mq.matches; - } - - state: State = { - horizontal: false, + const toggleCollapse = () => { + const action = collapsed ? showSidebar : hideSidebar; + action(); }; - componentDidMount() { - document.body.classList.add('body-sidebar'); - - this.checkHash(); - this.doCollapse(this.props.collapsed); - } + const togglePanel = (panel: SidebarPanelKey) => SidebarPanelActions.togglePanel(panel); + const hidePanel = () => SidebarPanelActions.hidePanel(); - // Sidebar doesn't use children, so don't use it to compare - // Also ignore location, will re-render when routes change (instead of query params) - // - // NOTE(epurkhiser): The comment above is why I added `children?: never` as a - // type to this component. I'm not sure the implications of removing this so - // I've just left it for now. - shouldComponentUpdate( - {children: _children, location: _location, ...nextPropsToCompare}: Props, - nextState: State - ) { - const { - children: _childrenCurrent, - location: _locationCurrent, - ...currentPropsToCompare - } = this.props; - - return ( - !isEqual(currentPropsToCompare, nextPropsToCompare) || - !isEqual(this.state, nextState) - ); - } + const bcl = document.body.classList; - componentDidUpdate(prevProps: Props) { - const {collapsed, location} = this.props; + // Close panel on any navigation + useEffect(() => void hidePanel(), [location?.pathname]); - // Close active panel if we navigated anywhere - if (location?.pathname !== prevProps.location?.pathname) { - this.hidePanel(); - } - - // Collapse - if (collapsed !== prevProps.collapsed) { - this.doCollapse(collapsed); - } - } - - componentWillUnmount() { - document.body.classList.remove('body-sidebar'); - - if (this.mq) { - this.mq.removeListener(this.handleMediaQueryChange); - this.mq = null; - } - } + // Add classname to body + useEffect(() => { + bcl.add('body-sidebar'); + return () => bcl.remove('body-sidebar'); + }, []); - mq: MediaQueryList | null = null; - sidebarRef = React.createRef(); - - doCollapse(collapsed: boolean) { + // Add sidebar collapse classname to body + useEffect(() => { if (collapsed) { - document.body.classList.add('collapsed'); + bcl.add('collapsed'); } else { - document.body.classList.remove('collapsed'); + bcl.remove('collapsed'); } - } - toggleSidebar = () => { - const {collapsed} = this.props; - - if (!collapsed) { - hideSidebar(); - } else { - showSidebar(); - } - }; + return () => bcl.remove('collapsed'); + }, [collapsed]); - checkHash = () => { - if (window.location.hash === '#welcome') { - this.togglePanel(SidebarPanelKey.OnboardingWizard); + // Trigger panels depending on the location hash + useEffect(() => { + if (location?.hash === '#welcome') { + togglePanel(SidebarPanelKey.OnboardingWizard); } - }; - - handleMediaQueryChange = (changed: MediaQueryListEvent) => { - this.setState({ - horizontal: changed.matches, - }); - }; - - togglePanel = (panel: SidebarPanelKey) => SidebarPanelActions.togglePanel(panel); - hidePanel = () => SidebarPanelActions.hidePanel(); + }, [location?.hash]); - // Keep the global selection querystring values in the path - navigateWithGlobalSelection = ( + /** + * Navigate to a path, but keep the global selection query strings. + */ + const navigateWithGlobalSelection = ( pathname: string, evt: React.MouseEvent ) => { @@ -186,11 +118,11 @@ class Sidebar extends React.Component { 'discover', 'discover/results', // Team plans do not have query landing page 'performance', - ].map(route => `/organizations/${this.props.organization.slug}/${route}/`); + ].map(route => `/organizations/${organization?.slug}/${route}/`); // Only keep the querystring if the current route matches one of the above if (globalSelectionRoutes.includes(pathname)) { - const query = extractSelectionParameters(this.props.location?.query); + const query = extractSelectionParameters(location?.query); // Handle cmd-click (mac) and meta-click (linux) if (evt.metaKey) { @@ -202,370 +134,306 @@ class Sidebar extends React.Component { evt.preventDefault(); browserHistory.push({pathname, query}); } + }; + + const hasPanel = !!activePanel; + const hasOrganization = !!organization; + const orientation: SidebarOrientation = horizontal ? 'top' : 'left'; - this.hidePanel(); + const sidebarItemProps = { + orientation, + collapsed, + hasPanel, }; - render() { - const {activePanel, organization, collapsed} = this.props; - const {horizontal} = this.state; - const config = ConfigStore.getConfig(); - const user = ConfigStore.get('user'); - const hasPanel = !!activePanel; - const orientation: SidebarOrientation = horizontal ? 'top' : 'left'; - const sidebarItemProps = { - orientation, - collapsed, - hasPanel, - }; - const hasOrganization = !!organization; - - const projects = hasOrganization && ( - } - label={{t('Projects')}} - to={`/organizations/${organization.slug}/projects/`} - id="projects" - /> - ); + const projects = hasOrganization && ( + } + label={{t('Projects')}} + to={`/organizations/${organization.slug}/projects/`} + id="projects" + /> + ); - const issues = hasOrganization && ( - - this.navigateWithGlobalSelection( - `/organizations/${organization.slug}/issues/`, - evt - ) - } - icon={} - label={{t('Issues')}} - to={`/organizations/${organization.slug}/issues/`} - id="issues" - /> - ); - - const discover2 = hasOrganization && ( - - - this.navigateWithGlobalSelection(getDiscoverLandingUrl(organization), evt) - } - icon={} - label={{t('Discover')}} - to={getDiscoverLandingUrl(organization)} - id="discover-v2" - /> - - ); - - const performance = hasOrganization && ( - - - {(overideProps: Partial>) => ( - - this.navigateWithGlobalSelection( - `/organizations/${organization.slug}/performance/`, - evt - ) - } - icon={} - label={{t('Performance')}} - to={`/organizations/${organization.slug}/performance/`} - id="performance" - {...overideProps} - /> - )} - - - ); + const issues = hasOrganization && ( + + navigateWithGlobalSelection(`/organizations/${organization.slug}/issues/`, evt) + } + icon={} + label={{t('Issues')}} + to={`/organizations/${organization.slug}/issues/`} + id="issues" + /> + ); - const releases = hasOrganization && ( + const discover2 = hasOrganization && ( + - this.navigateWithGlobalSelection( - `/organizations/${organization.slug}/releases/`, - evt - ) + navigateWithGlobalSelection(getDiscoverLandingUrl(organization), evt) } - icon={} - label={{t('Releases')}} - to={`/organizations/${organization.slug}/releases/`} - id="releases" + icon={} + label={{t('Discover')}} + to={getDiscoverLandingUrl(organization)} + id="discover-v2" /> - ); + + ); - const userFeedback = hasOrganization && ( + const performance = hasOrganization && ( + + + {(overideProps: Partial>) => ( + + navigateWithGlobalSelection( + `/organizations/${organization.slug}/performance/`, + evt + ) + } + icon={} + label={{t('Performance')}} + to={`/organizations/${organization.slug}/performance/`} + id="performance" + {...overideProps} + /> + )} + + + ); + + const releases = hasOrganization && ( + + navigateWithGlobalSelection(`/organizations/${organization.slug}/releases/`, evt) + } + icon={} + label={{t('Releases')}} + to={`/organizations/${organization.slug}/releases/`} + id="releases" + /> + ); + + const userFeedback = hasOrganization && ( + + navigateWithGlobalSelection( + `/organizations/${organization.slug}/user-feedback/`, + evt + ) + } + icon={} + label={t('User Feedback')} + to={`/organizations/${organization.slug}/user-feedback/`} + id="user-feedback" + /> + ); + + const alerts = hasOrganization && ( + + navigateWithGlobalSelection( + `/organizations/${organization.slug}/alerts/rules/`, + evt + ) + } + icon={} + label={t('Alerts')} + to={`/organizations/${organization.slug}/alerts/rules/`} + id="alerts" + /> + ); + + const monitors = hasOrganization && ( + - this.navigateWithGlobalSelection( - `/organizations/${organization.slug}/user-feedback/`, + navigateWithGlobalSelection( + `/organizations/${organization.slug}/monitors/`, evt ) } - icon={} - label={t('User Feedback')} - to={`/organizations/${organization.slug}/user-feedback/`} - id="user-feedback" + icon={} + label={t('Monitors')} + to={`/organizations/${organization.slug}/monitors/`} + id="monitors" /> - ); + + ); - const alerts = hasOrganization && ( + const dashboards = hasOrganization && ( + - this.navigateWithGlobalSelection( - `/organizations/${organization.slug}/alerts/rules/`, + navigateWithGlobalSelection( + `/organizations/${organization.slug}/dashboards/`, evt ) } - icon={} - label={t('Alerts')} - to={`/organizations/${organization.slug}/alerts/rules/`} - id="alerts" - /> - ); - - const monitors = hasOrganization && ( - - - this.navigateWithGlobalSelection( - `/organizations/${organization.slug}/monitors/`, - evt - ) - } - icon={} - label={t('Monitors')} - to={`/organizations/${organization.slug}/monitors/`} - id="monitors" - /> - - ); - - const dashboards = hasOrganization && ( - - - this.navigateWithGlobalSelection( - `/organizations/${organization.slug}/dashboards/`, - evt - ) - } - icon={} - label={t('Dashboards')} - to={`/organizations/${organization.slug}/dashboards/`} - id="customizable-dashboards" - isNew - /> - - ); - - const activity = hasOrganization && ( - } - label={t('Activity')} - to={`/organizations/${organization.slug}/activity/`} - id="activity" - /> - ); - - const stats = hasOrganization && ( - } - label={t('Stats')} - to={`/organizations/${organization.slug}/stats/`} - id="stats" - /> - ); - - const settings = hasOrganization && ( - } - label={t('Settings')} - to={`/settings/${organization.slug}/`} - id="settings" + icon={} + label={t('Dashboards')} + to={`/organizations/${organization.slug}/dashboards/`} + id="customizable-dashboards" + isNew /> - ); + + ); - return ( - - - - - + const activity = hasOrganization && ( + } + label={t('Activity')} + to={`/organizations/${organization.slug}/activity/`} + id="activity" + /> + ); - - {hasOrganization && ( - - - {projects} - {issues} - {performance} - {releases} - {userFeedback} - {alerts} - {discover2} - {dashboards} - - - {monitors} - - - {activity} - {stats} - - - {settings} - - )} - - - - {hasOrganization && ( - - - this.togglePanel(SidebarPanelKey.OnboardingWizard)} - hidePanel={this.hidePanel} - {...sidebarItemProps} - /> - + const stats = hasOrganization && ( + } + label={t('Stats')} + to={`/organizations/${organization.slug}/stats/`} + id="stats" + /> + ); - - {HookStore.get('sidebar:bottom-items').length > 0 && - HookStore.get('sidebar:bottom-items')[0]({ - organization, - ...sidebarItemProps, - })} - - this.togglePanel(SidebarPanelKey.Broadcasts)} - hidePanel={this.hidePanel} - organization={organization} - /> - this.togglePanel(SidebarPanelKey.StatusUpdate)} - hidePanel={this.hidePanel} - /> - + const settings = hasOrganization && ( + } + label={t('Settings')} + to={`/settings/${organization.slug}/`} + id="settings" + /> + ); - {!horizontal && ( + return ( + + + + + + + + {hasOrganization && ( + - } - label={collapsed ? t('Expand') : t('Collapse')} - onClick={this.toggleSidebar} - /> + {projects} + {issues} + {performance} + {releases} + {userFeedback} + {alerts} + {discover2} + {dashboards} - )} - - )} - - ); - } -} -type ContainerProps = Omit; + {monitors} -type ContainerState = { - collapsed: boolean; - activePanel: ActivePanelType; -}; -type Preferences = typeof PreferencesStore.prefs; + + {activity} + {stats} + -class SidebarContainer extends React.Component { - state: ContainerState = { - collapsed: !!PreferencesStore.getInitialState().collapsed, - activePanel: '', - }; + {settings} + + )} + + - componentWillUnmount() { - this.preferenceUnsubscribe(); - this.sidebarUnsubscribe(); - } + {hasOrganization && ( + + + togglePanel(SidebarPanelKey.OnboardingWizard)} + hidePanel={hidePanel} + {...sidebarItemProps} + /> + - preferenceUnsubscribe = PreferencesStore.listen( - (preferences: Preferences) => this.onPreferenceChange(preferences), - undefined - ); + + {HookStore.get('sidebar:bottom-items').length > 0 && + HookStore.get('sidebar:bottom-items')[0]({ + organization, + ...sidebarItemProps, + })} + + togglePanel(SidebarPanelKey.Broadcasts)} + hidePanel={hidePanel} + organization={organization} + /> + togglePanel(SidebarPanelKey.StatusUpdate)} + hidePanel={hidePanel} + /> + - sidebarUnsubscribe = SidebarPanelStore.listen( - (activePanel: ActivePanelType) => this.onSidebarPanelChange(activePanel), - undefined + {!horizontal && ( + + } + label={collapsed ? t('Expand') : t('Collapse')} + onClick={toggleCollapse} + /> + + )} + + )} + ); - - onPreferenceChange(preferences: Preferences) { - if (preferences.collapsed === this.state.collapsed) { - return; - } - - this.setState({collapsed: !!preferences.collapsed}); - } - - onSidebarPanelChange(activePanel: ActivePanelType) { - this.setState({activePanel}); - } - - render() { - const {activePanel, collapsed} = this.state; - return ; - } } -export default withOrganization(SidebarContainer); +export default Sidebar; const responsiveFlex = css` display: flex; @@ -576,7 +444,7 @@ const responsiveFlex = css` } `; -export const SidebarWrapper = styled('div')<{collapsed: boolean}>` +export const SidebarWrapper = styled('nav')<{collapsed: boolean}>` background: ${p => p.theme.sidebar.background}; background: ${p => p.theme.sidebarGradient}; color: ${p => p.theme.sidebar.color}; diff --git a/static/app/components/sidebar/onboardingStatus.tsx b/static/app/components/sidebar/onboardingStatus.tsx index 5dccb0071e5b64..eef848d8a21ba9 100644 --- a/static/app/components/sidebar/onboardingStatus.tsx +++ b/static/app/components/sidebar/onboardingStatus.tsx @@ -72,9 +72,16 @@ function OnboardingStatus({ return null; } + const label = t('Quick Start'); + return ( - + {!collapsed && (
- {t('Quick Start')} + {label} {tct('[numberRemaining] Remaining tasks', {numberRemaining})} {pendingCompletionSeen && } diff --git a/static/app/components/sidebar/sidebarDropdown/index.tsx b/static/app/components/sidebar/sidebarDropdown/index.tsx index c1f1dbe846dd38..6998ccbc2dcebe 100644 --- a/static/app/components/sidebar/sidebarDropdown/index.tsx +++ b/static/app/components/sidebar/sidebarDropdown/index.tsx @@ -30,10 +30,10 @@ import SwitchOrganization from './switchOrganization'; // TODO: make org and user optional props type Props = Pick & { api: Client; - org: Organization; projects: Project[]; user: User; config: Config; + org?: Organization; /** * Set to true to hide links within the organization */ @@ -158,15 +158,17 @@ const SidebarDropdown = ({ {t('API keys')} - + {hasOrganization && ( + + )} {user.isSuperuser && ( {t('Admin')} )} {t('Sign out')} diff --git a/static/app/components/sidebar/sidebarItem.tsx b/static/app/components/sidebar/sidebarItem.tsx index a0d0f6a48123ed..c6564f36def189 100644 --- a/static/app/components/sidebar/sidebarItem.tsx +++ b/static/app/components/sidebar/sidebarItem.tsx @@ -126,7 +126,7 @@ const SidebarItem = ({ className={className} onClick={(event: React.MouseEvent) => { !(to || href) && event.preventDefault(); - typeof onClick === 'function' && onClick(id, event); + onClick?.(id, event); showIsNew && localStorage.setItem(isNewSeenKey, 'true'); }} > diff --git a/static/app/components/sidebar/sidebarPanel.tsx b/static/app/components/sidebar/sidebarPanel.tsx index 78baaa8215cffb..cb4ae260a09a20 100644 --- a/static/app/components/sidebar/sidebarPanel.tsx +++ b/static/app/components/sidebar/sidebarPanel.tsx @@ -22,7 +22,7 @@ const PanelContainer = styled('div')` box-shadow: 1px 0 2px rgba(0, 0, 0, 0.06); text-align: left; animation: 200ms ${slideInLeft}; - z-index: ${p => p.theme.zIndex.sidebar}; + z-index: ${p => p.theme.zIndex.sidebar - 1}; ${p => p.orientation === 'top' @@ -91,7 +91,12 @@ function SidebarPanel({ } const sidebar = ( - + {title && ( {title} diff --git a/static/app/stores/preferencesStore.tsx b/static/app/stores/preferencesStore.tsx index 0d2d31706e79bb..a029ff32bff527 100644 --- a/static/app/stores/preferencesStore.tsx +++ b/static/app/stores/preferencesStore.tsx @@ -35,9 +35,7 @@ const storeConfig: Reflux.StoreDefinition & PreferenceStoreInterface = { }, reset() { - this.prefs = { - collapsed: false, - }; + this.prefs = {collapsed: false}; }, loadInitialState(prefs: Preferences) { @@ -46,12 +44,12 @@ const storeConfig: Reflux.StoreDefinition & PreferenceStoreInterface = { }, onHideSidebar() { - this.prefs.collapsed = true; + this.prefs = {...this.prefs, collapsed: true}; this.trigger(this.prefs); }, onShowSidebar() { - this.prefs.collapsed = false; + this.prefs = {...this.prefs, collapsed: false}; this.trigger(this.prefs); }, diff --git a/tests/js/spec/components/sidebar/index.spec.jsx b/tests/js/spec/components/sidebar/index.spec.jsx index 6f2a23791291c4..267e2c62a8a761 100644 --- a/tests/js/spec/components/sidebar/index.spec.jsx +++ b/tests/js/spec/components/sidebar/index.spec.jsx @@ -1,5 +1,12 @@ -import {mountWithTheme} from 'sentry-test/enzyme'; -import {act} from 'sentry-test/reactTestingLibrary'; +import {initializeOrg} from 'sentry-test/initializeOrg'; +import { + act, + fireEvent, + mountWithTheme, + screen, + waitFor, + waitForElementToBeRemoved, +} from 'sentry-test/reactTestingLibrary'; import * as incidentActions from 'app/actionCreators/serviceIncidents'; import SidebarContainer from 'app/components/sidebar'; @@ -8,28 +15,23 @@ import ConfigStore from 'app/stores/configStore'; jest.mock('app/actionCreators/serviceIncidents'); describe('Sidebar', function () { - let wrapper; - const routerContext = TestStubs.routerContext(); - const {organization, router} = routerContext.context; + const {organization, router} = initializeOrg(); + const broadcast = TestStubs.Broadcast(); const user = TestStubs.User(); const apiMocks = {}; - const createWrapper = props => - mountWithTheme( - , - routerContext - ); + const location = {...router.location, ...{pathname: '/test/'}}; + + const getElement = props => ( + + ); + + const renderSidebar = props => mountWithTheme(getElement(props)); beforeEach(function () { apiMocks.broadcasts = MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/broadcasts/`, - body: [TestStubs.Broadcast()], + body: [broadcast], }); apiMocks.broadcastsMarkAsSeen = MockApiClient.addMockResponse({ url: '/broadcasts/', @@ -42,250 +44,204 @@ describe('Sidebar', function () { }); it('renders', function () { - wrapper = mountWithTheme( - , - TestStubs.routerContext() - ); - - expect(wrapper.find('SidebarWrapper')).toHaveLength(1); + renderSidebar(); + expect(screen.getByTestId('sidebar-dropdown')).toBeInTheDocument(); }); - it('renders without org and router', function () { - wrapper = createWrapper({ - organization: null, - router: null, - }); + it('renders without org', function () { + const {container} = renderSidebar({organization: null}); // no org displays user details - expect(wrapper.find('OrgOrUserName').text()).toContain(user.name); - expect(wrapper.find('UserNameOrEmail').text()).toContain(user.email); + expect(screen.getByText(user.name)).toBeInTheDocument(); + expect(screen.getByText(user.email)).toBeInTheDocument(); - wrapper.find('SidebarDropdownActor').simulate('click'); - expect(wrapper).toSnapshot(); + fireEvent.click(screen.getByTestId('sidebar-dropdown')); + expect(container).toSnapshot(); }); - it('can toggle collapsed state', async function () { - wrapper = mountWithTheme( - , - routerContext - ); + it('has can logout', async function () { + const mock = MockApiClient.addMockResponse({ + url: '/auth/', + method: 'DELETE', + status: 204, + }); + jest.spyOn(window.location, 'assign').mockImplementation(() => {}); - expect(wrapper.find('OrgOrUserName').text()).toContain(organization.name); - expect(wrapper.find('UserNameOrEmail').text()).toContain(user.name); + renderSidebar({ + organization: TestStubs.Organization({access: ['member:read']}), + }); - wrapper.find('SidebarCollapseItem StyledSidebarItem').simulate('click'); - await tick(); - wrapper.update(); + fireEvent.click(screen.getByTestId('sidebar-dropdown')); + fireEvent.click(screen.getByTestId('sidebar-signout')); - // Because of HoCs, we can't access the collapsed prop - // Instead check for `SidebarItemLabel` which doesn't exist in collapsed state - expect(wrapper.find('SidebarItemLabel')).toHaveLength(0); + await waitFor(() => expect(mock).toHaveBeenCalled()); - wrapper.find('SidebarCollapseItem StyledSidebarItem').simulate('click'); - await tick(); - wrapper.update(); - expect(wrapper.find('SidebarItemLabel').length).toBeGreaterThan(0); + expect(window.location.assign).toHaveBeenCalledWith('/auth/login/'); + window.location.assign.mockRestore(); }); - it('can have onboarding feature', async function () { - wrapper = mountWithTheme( - , - routerContext - ); + it('can toggle collapsed state', async function () { + renderSidebar(); - expect(wrapper.find('OnboardingStatus ProgressRing')).toHaveLength(1); + expect(screen.getByText(user.name)).toBeInTheDocument(); + expect(screen.getByText(organization.name)).toBeInTheDocument(); - wrapper.find('OnboardingStatus ProgressRing').simulate('click'); - await tick(); - wrapper.update(); + fireEvent.click(screen.getByTestId('sidebar-collapse')); - expect(wrapper.find('OnboardingStatus TaskSidebarPanel').exists()).toBe(true); + // Check that the organization name is no longer visible + await waitFor(() => + expect(screen.queryByText(organization.name)).not.toBeInTheDocument() + ); + + // Un-collapse he sidebar and make sure the org name is visible again + fireEvent.click(screen.getByTestId('sidebar-collapse')); + expect(await screen.findByText(organization.name)).toBeInTheDocument(); }); - describe('SidebarHelp', function () { - it('can toggle help menu', function () { - wrapper = createWrapper(); - wrapper.find('HelpActor').simulate('click'); - const menu = wrapper.find('HelpMenu'); - expect(menu).toHaveLength(1); - expect(wrapper).toSnapshot(); - expect(menu.find('SidebarMenuItem')).toHaveLength(2); - wrapper.find('HelpActor').simulate('click'); - expect(wrapper.find('HelpMenu')).toHaveLength(0); - }); + it('can toggle help menu', function () { + const {container} = renderSidebar(); + + fireEvent.click(screen.getByText('Help')); + + expect(screen.getByText('Visit Help Center')).toBeInTheDocument(); + expect(container).toSnapshot(); }); describe('SidebarDropdown', function () { it('can open Sidebar org/name dropdown menu', function () { - wrapper = createWrapper(); - wrapper.find('SidebarDropdownActor').simulate('click'); - expect(wrapper.find('OrgAndUserMenu')).toHaveLength(1); - expect(wrapper).toSnapshot(); + const {container} = renderSidebar(); + + fireEvent.click(screen.getByTestId('sidebar-dropdown')); + + const orgSettingsLink = screen.getByText('Organization settings'); + expect(orgSettingsLink).toBeInTheDocument(); + expect(container).toSnapshot(); }); it('has link to Members settings with `member:write`', function () { - let org = TestStubs.Organization(); - org = { - ...org, - access: [...org.access, 'member:read'], - }; - - wrapper = createWrapper({ - organization: org, + renderSidebar({ + organization: TestStubs.Organization({access: ['member:read']}), }); - wrapper.find('SidebarDropdownActor').simulate('click'); - expect(wrapper.find('OrgAndUserMenu')).toHaveLength(1); - expect( - wrapper.find('SidebarMenuItem[to="/settings/org-slug/members/"]') - ).toHaveLength(1); + + fireEvent.click(screen.getByTestId('sidebar-dropdown')); + + expect(screen.getByText('Members')).toBeInTheDocument(); }); it('can open "Switch Organization" sub-menu', function () { act(() => void ConfigStore.set('features', new Set(['organizations:create']))); + + const {container} = renderSidebar(); + + fireEvent.click(screen.getByTestId('sidebar-dropdown')); + jest.useFakeTimers(); - wrapper = createWrapper(); - wrapper.find('SidebarDropdownActor').simulate('click'); - wrapper.find('SwitchOrganizationMenuActor').simulate('mouseEnter'); - jest.advanceTimersByTime(500); - wrapper.update(); - expect(wrapper.find('SwitchOrganizationMenu')).toHaveLength(1); - expect(wrapper).toSnapshot(); + fireEvent.mouseEnter(screen.getByText('Switch organization')); + act(() => jest.advanceTimersByTime(500)); jest.useRealTimers(); + + const createOrg = screen.getByText('Create a new organization'); + expect(createOrg).toBeInTheDocument(); + + expect(container).toSnapshot(); }); + }); - it('has can logout', async function () { - const mock = MockApiClient.addMockResponse({ - url: '/auth/', - method: 'DELETE', - status: 204, - }); - jest.spyOn(window.location, 'assign').mockImplementation(() => {}); + describe('SidebarPanel', function () { + it('hides when path changes', async function () { + const {rerender} = renderSidebar(); + + fireEvent.click(screen.getByText("What's new")); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText("What's new in Sentry")).toBeInTheDocument(); - let org = TestStubs.Organization(); - org = { - ...org, - access: [...org.access, 'member:read'], - }; + rerender(getElement({location: {...router.location, pathname: 'new-path-name'}})); + + await waitForElementToBeRemoved(() => screen.queryByText("What's new in Sentry")); + }); - wrapper = createWrapper({ - organization: org, - user: TestStubs.User(), + it('can have onboarding feature', async function () { + renderSidebar({ + organization: {...organization, features: ['onboarding']}, }); - wrapper.find('SidebarDropdownActor').simulate('click'); - wrapper.find('SidebarMenuItem[data-test-id="sidebarSignout"]').simulate('click'); - expect(mock).toHaveBeenCalled(); - await tick(); - expect(window.location.assign).toHaveBeenCalledWith('/auth/login/'); - window.location.assign.mockRestore(); + const quickStart = screen.getByText('Quick Start'); + + expect(quickStart).toBeInTheDocument(); + fireEvent.click(quickStart); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Capture your first error')).toBeInTheDocument(); }); - }); - describe('SidebarPanel', function () { it('displays empty panel when there are no Broadcasts', async function () { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/broadcasts/`, body: [], }); - wrapper = createWrapper(); + renderSidebar(); - wrapper.find('Broadcasts SidebarItem').simulate('click'); + fireEvent.click(screen.getByText("What's new")); - await tick(); - wrapper.update(); - expect(wrapper.find('SidebarPanel')).toHaveLength(1); - - expect(wrapper.find('SidebarPanelItem')).toHaveLength(0); - expect(wrapper.find('SidebarPanelEmpty')).toHaveLength(1); + 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 - wrapper.find('Broadcasts SidebarItem').simulate('click'); - await tick(); - wrapper.update(); + fireEvent.click(screen.getByText("What's new")); + await waitForElementToBeRemoved(() => screen.queryByText("What's new in Sentry")); }); it('can display Broadcasts panel and mark as seen', async function () { jest.useFakeTimers(); - wrapper = createWrapper(); - expect(apiMocks.broadcasts).toHaveBeenCalled(); - - wrapper.find('Broadcasts SidebarItem').simulate('click'); + const {container} = renderSidebar(); - // XXX: Need to do this for reflux since we're using fake timers - jest.advanceTimersByTime(0); - await Promise.resolve(); - wrapper.update(); + expect(apiMocks.broadcasts).toHaveBeenCalled(); - expect(wrapper.find('SidebarPanel')).toHaveLength(1); + fireEvent.click(screen.getByText("What's new")); - expect(wrapper.find('SidebarPanelItem')).toHaveLength(1); - expect(wrapper.find('SidebarPanelItem').prop('hasSeen')).toBe(false); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText("What's new in Sentry")).toBeInTheDocument(); - expect(wrapper.find('SidebarPanelItem')).toSnapshot(); + const broadcastTitle = screen.getByText(broadcast.title); + expect(broadcastTitle).toBeInTheDocument(); + expect(container).toSnapshot(); // Should mark as seen after a delay - jest.advanceTimersByTime(2000); + act(() => jest.advanceTimersByTime(2000)); + expect(apiMocks.broadcastsMarkAsSeen).toHaveBeenCalledWith( '/broadcasts/', expect.objectContaining({ - data: { - hasSeen: '1', - }, - query: { - id: ['8'], - }, + data: {hasSeen: '1'}, + query: {id: ['8']}, }) ); jest.useRealTimers(); // Close the sidebar - wrapper.find('Broadcasts SidebarItem').simulate('click'); - }); - - it('can toggle display of Broadcasts SidebarPanel', async function () { - wrapper = createWrapper(); - wrapper.update(); - - // Show Broadcasts Panel - wrapper.find('Broadcasts SidebarItem').simulate('click'); - await tick(); - wrapper.update(); - expect(wrapper.find('SidebarPanel')).toHaveLength(1); - - // Hide Broadcasts Panel - wrapper.find('Broadcasts SidebarItem').simulate('click'); - await tick(); - wrapper.update(); - expect(wrapper.find('SidebarPanel')).toHaveLength(0); - - // Close the sidebar - wrapper.find('Broadcasts SidebarItem').simulate('click'); + fireEvent.click(screen.getByText("What's new")); + await waitForElementToBeRemoved(() => screen.queryByText("What's new in Sentry")); }); it('can unmount Sidebar (and Broadcasts) and kills Broadcast timers', async function () { jest.useFakeTimers(); - wrapper = createWrapper(); - const broadcasts = wrapper.find('Broadcasts').instance(); + const {unmount} = renderSidebar(); // This will start timer to mark as seen - await wrapper.find('Broadcasts SidebarItem').simulate('click'); - wrapper.update(); + fireEvent.click(screen.getByRole('link', {name: "What's new"})); + expect(await screen.findByText("What's new in Sentry")).toBeInTheDocument(); - jest.advanceTimersByTime(500); - expect(broadcasts.poller).toBeDefined(); - expect(broadcasts.timer).toBeDefined(); + act(() => jest.advanceTimersByTime(500)); // Unmounting will cancel timers - wrapper.unmount(); - expect(broadcasts.poller).toBe(null); - expect(broadcasts.timer).toBe(null); + unmount(); - // This advances timers enough so that mark as seen should be called if it wasn't unmounted - jest.advanceTimersByTime(600); + // 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(); }); @@ -295,42 +251,11 @@ describe('Sidebar', function () { incidents: [TestStubs.ServiceIncident()], })); - wrapper = createWrapper(); - await tick(); - - wrapper.find('ServiceIncidents').simulate('click'); - await tick(); - wrapper.update(); - expect(wrapper.find('SidebarPanel')).toHaveLength(1); - - expect(wrapper.find('IncidentList')).toSnapshot(); - }); - - it('hides when path changes', async function () { - wrapper = createWrapper(); - wrapper.update(); - - wrapper.find('Broadcasts SidebarItem').simulate('click'); - await tick(); - wrapper.update(); - expect(wrapper.find('SidebarPanel')).toHaveLength(1); - - const prevProps = wrapper.props(); - - wrapper.setProps({ - location: {...router.location, pathname: 'new-path-name'}, - }); + const {container} = renderSidebar(); - // XXX(epurkhsier): Due to a bug in enzyme [0], componentDidUpdate is not - // called after props have updated, it still receives _old_ `this.props`. - // We manually call it here after the props have been correctly updated. - // - // [0]: https://github.com/enzymejs/enzyme/issues/2197 - wrapper.find('Sidebar').instance().componentDidUpdate(prevProps); + fireEvent.click(await screen.findByText('Service status')); - await tick(); - wrapper.update(); - expect(wrapper.find('SidebarPanel')).toHaveLength(0); + expect(container).toSnapshot(); }); }); });