index.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. import {Fragment, useEffect} from 'react';
  2. import {browserHistory} from 'react-router';
  3. import {css} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import {Location} from 'history';
  6. import * as qs from 'query-string';
  7. import {hideSidebar, showSidebar} from 'sentry/actionCreators/preferences';
  8. import SidebarPanelActions from 'sentry/actions/sidebarPanelActions';
  9. import Feature from 'sentry/components/acl/feature';
  10. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  11. import HookOrDefault from 'sentry/components/hookOrDefault';
  12. import {extractSelectionParameters} from 'sentry/components/organizations/pageFilters/utils';
  13. import {
  14. IconActivity,
  15. IconChevron,
  16. IconGraph,
  17. IconIssues,
  18. IconLab,
  19. IconLightning,
  20. IconProject,
  21. IconReleases,
  22. IconSettings,
  23. IconSiren,
  24. IconStats,
  25. IconSupport,
  26. IconTelescope,
  27. } from 'sentry/icons';
  28. import {t} from 'sentry/locale';
  29. import ConfigStore from 'sentry/stores/configStore';
  30. import HookStore from 'sentry/stores/hookStore';
  31. import PreferencesStore from 'sentry/stores/preferencesStore';
  32. import SidebarPanelStore from 'sentry/stores/sidebarPanelStore';
  33. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  34. import space from 'sentry/styles/space';
  35. import {Organization} from 'sentry/types';
  36. import {getDiscoverLandingUrl} from 'sentry/utils/discover/urls';
  37. import theme from 'sentry/utils/theme';
  38. import useMedia from 'sentry/utils/useMedia';
  39. import Broadcasts from './broadcasts';
  40. import SidebarHelp from './help';
  41. import OnboardingStatus from './onboardingStatus';
  42. import ServiceIncidents from './serviceIncidents';
  43. import SidebarDropdown from './sidebarDropdown';
  44. import SidebarItem from './sidebarItem';
  45. import {SidebarOrientation, SidebarPanelKey} from './types';
  46. const SidebarOverride = HookOrDefault({
  47. hookName: 'sidebar:item-override',
  48. defaultComponent: ({children}) => <Fragment>{children({})}</Fragment>,
  49. });
  50. type Props = {
  51. organization?: Organization;
  52. location?: Location;
  53. };
  54. function Sidebar({location, organization}: Props) {
  55. const config = useLegacyStore(ConfigStore);
  56. const preferences = useLegacyStore(PreferencesStore);
  57. const activePanel = useLegacyStore(SidebarPanelStore);
  58. const collapsed = !!preferences.collapsed;
  59. const horizontal = useMedia(`(max-width: ${theme.breakpoints[1]})`);
  60. const toggleCollapse = () => {
  61. const action = collapsed ? showSidebar : hideSidebar;
  62. action();
  63. };
  64. const togglePanel = (panel: SidebarPanelKey) => SidebarPanelActions.togglePanel(panel);
  65. const hidePanel = () => SidebarPanelActions.hidePanel();
  66. const bcl = document.body.classList;
  67. // Close panel on any navigation
  68. useEffect(() => void hidePanel(), [location?.pathname]);
  69. // Add classname to body
  70. useEffect(() => {
  71. bcl.add('body-sidebar');
  72. return () => bcl.remove('body-sidebar');
  73. }, []);
  74. // Add sidebar collapse classname to body
  75. useEffect(() => {
  76. if (collapsed) {
  77. bcl.add('collapsed');
  78. } else {
  79. bcl.remove('collapsed');
  80. }
  81. return () => bcl.remove('collapsed');
  82. }, [collapsed]);
  83. // Trigger panels depending on the location hash
  84. useEffect(() => {
  85. if (location?.hash === '#welcome') {
  86. togglePanel(SidebarPanelKey.OnboardingWizard);
  87. }
  88. }, [location?.hash]);
  89. /**
  90. * Navigate to a path, but keep the page filter query strings.
  91. */
  92. const navigateWithPageFilters = (
  93. pathname: string,
  94. evt: React.MouseEvent<HTMLAnchorElement>
  95. ) => {
  96. // XXX(epurkhiser): No need to navigation w/ the page filters in the world
  97. // of new page filter selection. You must pin your filters in which case
  98. // they will persist anyway.
  99. if (organization?.features.includes('selection-filters-v2')) {
  100. return;
  101. }
  102. const globalSelectionRoutes = [
  103. 'alerts',
  104. 'alerts/rules',
  105. 'dashboards',
  106. 'issues',
  107. 'releases',
  108. 'user-feedback',
  109. 'discover',
  110. 'discover/results', // Team plans do not have query landing page
  111. 'performance',
  112. ].map(route => `/organizations/${organization?.slug}/${route}/`);
  113. // Only keep the querystring if the current route matches one of the above
  114. if (globalSelectionRoutes.includes(pathname)) {
  115. const query = extractSelectionParameters(location?.query ?? {});
  116. // Handle cmd-click (mac) and meta-click (linux)
  117. if (evt.metaKey) {
  118. const q = qs.stringify(query);
  119. evt.currentTarget.href = `${evt.currentTarget.href}?${q}`;
  120. return;
  121. }
  122. evt.preventDefault();
  123. browserHistory.push({pathname, query});
  124. }
  125. };
  126. const hasPanel = !!activePanel;
  127. const hasOrganization = !!organization;
  128. const orientation: SidebarOrientation = horizontal ? 'top' : 'left';
  129. const sidebarItemProps = {
  130. orientation,
  131. collapsed,
  132. hasPanel,
  133. };
  134. const projects = hasOrganization && (
  135. <SidebarItem
  136. {...sidebarItemProps}
  137. index
  138. icon={<IconProject size="md" />}
  139. label={<GuideAnchor target="projects">{t('Projects')}</GuideAnchor>}
  140. to={`/organizations/${organization.slug}/projects/`}
  141. id="projects"
  142. />
  143. );
  144. const issues = hasOrganization && (
  145. <SidebarItem
  146. {...sidebarItemProps}
  147. onClick={(_id, evt) =>
  148. navigateWithPageFilters(`/organizations/${organization.slug}/issues/`, evt)
  149. }
  150. icon={<IconIssues size="md" />}
  151. label={<GuideAnchor target="issues">{t('Issues')}</GuideAnchor>}
  152. to={`/organizations/${organization.slug}/issues/`}
  153. id="issues"
  154. />
  155. );
  156. const discover2 = hasOrganization && (
  157. <Feature
  158. hookName="feature-disabled:discover2-sidebar-item"
  159. features={['discover-basic']}
  160. organization={organization}
  161. >
  162. <SidebarItem
  163. {...sidebarItemProps}
  164. onClick={(_id, evt) =>
  165. navigateWithPageFilters(getDiscoverLandingUrl(organization), evt)
  166. }
  167. icon={<IconTelescope size="md" />}
  168. label={<GuideAnchor target="discover">{t('Discover')}</GuideAnchor>}
  169. to={getDiscoverLandingUrl(organization)}
  170. id="discover-v2"
  171. />
  172. </Feature>
  173. );
  174. const performance = hasOrganization && (
  175. <Feature
  176. hookName="feature-disabled:performance-sidebar-item"
  177. features={['performance-view']}
  178. organization={organization}
  179. >
  180. <SidebarOverride id="performance-override">
  181. {(overideProps: Partial<React.ComponentProps<typeof SidebarItem>>) => (
  182. <SidebarItem
  183. {...sidebarItemProps}
  184. onClick={(_id, evt) =>
  185. navigateWithPageFilters(
  186. `/organizations/${organization.slug}/performance/`,
  187. evt
  188. )
  189. }
  190. icon={<IconLightning size="md" />}
  191. label={<GuideAnchor target="performance">{t('Performance')}</GuideAnchor>}
  192. to={`/organizations/${organization.slug}/performance/`}
  193. id="performance"
  194. {...overideProps}
  195. />
  196. )}
  197. </SidebarOverride>
  198. </Feature>
  199. );
  200. const releases = hasOrganization && (
  201. <SidebarItem
  202. {...sidebarItemProps}
  203. onClick={(_id, evt) =>
  204. navigateWithPageFilters(`/organizations/${organization.slug}/releases/`, evt)
  205. }
  206. icon={<IconReleases size="md" />}
  207. label={<GuideAnchor target="releases">{t('Releases')}</GuideAnchor>}
  208. to={`/organizations/${organization.slug}/releases/`}
  209. id="releases"
  210. />
  211. );
  212. const userFeedback = hasOrganization && (
  213. <SidebarItem
  214. {...sidebarItemProps}
  215. onClick={(_id, evt) =>
  216. navigateWithPageFilters(`/organizations/${organization.slug}/user-feedback/`, evt)
  217. }
  218. icon={<IconSupport size="md" />}
  219. label={t('User Feedback')}
  220. to={`/organizations/${organization.slug}/user-feedback/`}
  221. id="user-feedback"
  222. />
  223. );
  224. const alerts = hasOrganization && (
  225. <SidebarItem
  226. {...sidebarItemProps}
  227. onClick={(_id, evt) =>
  228. navigateWithPageFilters(`/organizations/${organization.slug}/alerts/rules/`, evt)
  229. }
  230. icon={<IconSiren size="md" />}
  231. label={t('Alerts')}
  232. to={`/organizations/${organization.slug}/alerts/rules/`}
  233. id="alerts"
  234. />
  235. );
  236. const monitors = hasOrganization && (
  237. <Feature features={['monitors']} organization={organization}>
  238. <SidebarItem
  239. {...sidebarItemProps}
  240. onClick={(_id, evt) =>
  241. navigateWithPageFilters(`/organizations/${organization.slug}/monitors/`, evt)
  242. }
  243. icon={<IconLab size="md" />}
  244. label={t('Monitors')}
  245. to={`/organizations/${organization.slug}/monitors/`}
  246. id="monitors"
  247. />
  248. </Feature>
  249. );
  250. const dashboards = hasOrganization && (
  251. <Feature
  252. hookName="feature-disabled:dashboards-sidebar-item"
  253. features={['discover', 'discover-query', 'dashboards-basic', 'dashboards-edit']}
  254. organization={organization}
  255. requireAll={false}
  256. >
  257. <SidebarItem
  258. {...sidebarItemProps}
  259. index
  260. onClick={(_id, evt) =>
  261. navigateWithPageFilters(`/organizations/${organization.slug}/dashboards/`, evt)
  262. }
  263. icon={<IconGraph size="md" />}
  264. label={t('Dashboards')}
  265. to={`/organizations/${organization.slug}/dashboards/`}
  266. id="customizable-dashboards"
  267. />
  268. </Feature>
  269. );
  270. const activity = hasOrganization && (
  271. <SidebarItem
  272. {...sidebarItemProps}
  273. icon={<IconActivity size="md" />}
  274. label={t('Activity')}
  275. to={`/organizations/${organization.slug}/activity/`}
  276. id="activity"
  277. />
  278. );
  279. const stats = hasOrganization && (
  280. <SidebarItem
  281. {...sidebarItemProps}
  282. icon={<IconStats size="md" />}
  283. label={t('Stats')}
  284. to={`/organizations/${organization.slug}/stats/`}
  285. id="stats"
  286. />
  287. );
  288. const settings = hasOrganization && (
  289. <SidebarItem
  290. {...sidebarItemProps}
  291. icon={<IconSettings size="md" />}
  292. label={t('Settings')}
  293. to={`/settings/${organization.slug}/`}
  294. id="settings"
  295. />
  296. );
  297. return (
  298. <SidebarWrapper collapsed={collapsed}>
  299. <SidebarSectionGroupPrimary>
  300. <SidebarSection>
  301. <SidebarDropdown
  302. orientation={orientation}
  303. collapsed={collapsed}
  304. org={organization}
  305. user={config.user}
  306. config={config}
  307. />
  308. </SidebarSection>
  309. <PrimaryItems>
  310. {hasOrganization && (
  311. <Fragment>
  312. <SidebarSection>
  313. {projects}
  314. {issues}
  315. {performance}
  316. {releases}
  317. {userFeedback}
  318. {alerts}
  319. {discover2}
  320. {dashboards}
  321. </SidebarSection>
  322. <SidebarSection>{monitors}</SidebarSection>
  323. <SidebarSection>
  324. {activity}
  325. {stats}
  326. </SidebarSection>
  327. <SidebarSection>{settings}</SidebarSection>
  328. </Fragment>
  329. )}
  330. </PrimaryItems>
  331. </SidebarSectionGroupPrimary>
  332. {hasOrganization && (
  333. <SidebarSectionGroup>
  334. <SidebarSection noMargin noPadding>
  335. <OnboardingStatus
  336. org={organization}
  337. currentPanel={activePanel}
  338. onShowPanel={() => togglePanel(SidebarPanelKey.OnboardingWizard)}
  339. hidePanel={hidePanel}
  340. {...sidebarItemProps}
  341. />
  342. </SidebarSection>
  343. <SidebarSection>
  344. {HookStore.get('sidebar:bottom-items').length > 0 &&
  345. HookStore.get('sidebar:bottom-items')[0]({
  346. organization,
  347. ...sidebarItemProps,
  348. })}
  349. <SidebarHelp
  350. orientation={orientation}
  351. collapsed={collapsed}
  352. hidePanel={hidePanel}
  353. organization={organization}
  354. />
  355. <Broadcasts
  356. orientation={orientation}
  357. collapsed={collapsed}
  358. currentPanel={activePanel}
  359. onShowPanel={() => togglePanel(SidebarPanelKey.Broadcasts)}
  360. hidePanel={hidePanel}
  361. organization={organization}
  362. />
  363. <ServiceIncidents
  364. orientation={orientation}
  365. collapsed={collapsed}
  366. currentPanel={activePanel}
  367. onShowPanel={() => togglePanel(SidebarPanelKey.StatusUpdate)}
  368. hidePanel={hidePanel}
  369. />
  370. </SidebarSection>
  371. {!horizontal && (
  372. <SidebarSection>
  373. <SidebarCollapseItem
  374. id="collapse"
  375. data-test-id="sidebar-collapse"
  376. {...sidebarItemProps}
  377. icon={<StyledIconChevron collapsed={collapsed} />}
  378. label={collapsed ? t('Expand') : t('Collapse')}
  379. onClick={toggleCollapse}
  380. />
  381. </SidebarSection>
  382. )}
  383. </SidebarSectionGroup>
  384. )}
  385. </SidebarWrapper>
  386. );
  387. }
  388. export default Sidebar;
  389. const responsiveFlex = css`
  390. display: flex;
  391. flex-direction: column;
  392. @media (max-width: ${theme.breakpoints[1]}) {
  393. flex-direction: row;
  394. }
  395. `;
  396. export const SidebarWrapper = styled('nav')<{collapsed: boolean}>`
  397. background: ${p => p.theme.sidebarGradient};
  398. color: ${p => p.theme.sidebar.color};
  399. line-height: 1;
  400. padding: 12px 0 2px; /* Allows for 32px avatars */
  401. width: ${p => p.theme.sidebar[p.collapsed ? 'collapsedWidth' : 'expandedWidth']};
  402. position: fixed;
  403. top: ${p => (ConfigStore.get('demoMode') ? p.theme.demo.headerSize : 0)};
  404. left: 0;
  405. bottom: 0;
  406. justify-content: space-between;
  407. z-index: ${p => p.theme.zIndex.sidebar};
  408. border-right: solid 1px ${p => p.theme.sidebarBorder};
  409. ${responsiveFlex};
  410. @media (max-width: ${p => p.theme.breakpoints[1]}) {
  411. top: 0;
  412. left: 0;
  413. right: 0;
  414. height: ${p => p.theme.sidebar.mobileHeight};
  415. bottom: auto;
  416. width: auto;
  417. padding: 0 ${space(1)};
  418. align-items: center;
  419. border-right: none;
  420. border-bottom: solid 1px ${p => p.theme.sidebarBorder};
  421. }
  422. `;
  423. const SidebarSectionGroup = styled('div')`
  424. ${responsiveFlex};
  425. flex-shrink: 0; /* prevents shrinking on Safari */
  426. `;
  427. const SidebarSectionGroupPrimary = styled('div')`
  428. ${responsiveFlex};
  429. /* necessary for child flexing on msedge and ff */
  430. min-height: 0;
  431. min-width: 0;
  432. flex: 1;
  433. /* expand to fill the entire height on mobile */
  434. @media (max-width: ${p => p.theme.breakpoints[1]}) {
  435. height: 100%;
  436. align-items: center;
  437. }
  438. `;
  439. const PrimaryItems = styled('div')`
  440. overflow: auto;
  441. flex: 1;
  442. display: flex;
  443. flex-direction: column;
  444. -ms-overflow-style: -ms-autohiding-scrollbar;
  445. @media (max-height: 675px) and (min-width: ${p => p.theme.breakpoints[1]}) {
  446. border-bottom: 1px solid ${p => p.theme.gray400};
  447. padding-bottom: ${space(1)};
  448. box-shadow: rgba(0, 0, 0, 0.15) 0px -10px 10px inset;
  449. &::-webkit-scrollbar {
  450. background-color: transparent;
  451. width: 8px;
  452. }
  453. &::-webkit-scrollbar-thumb {
  454. background: ${p => p.theme.gray400};
  455. border-radius: 8px;
  456. }
  457. }
  458. @media (max-width: ${p => p.theme.breakpoints[1]}) {
  459. overflow-y: visible;
  460. flex-direction: row;
  461. height: 100%;
  462. align-items: center;
  463. border-right: 1px solid ${p => p.theme.gray400};
  464. padding-right: ${space(1)};
  465. margin-right: ${space(0.5)};
  466. box-shadow: rgba(0, 0, 0, 0.15) -10px 0px 10px inset;
  467. ::-webkit-scrollbar {
  468. display: none;
  469. }
  470. }
  471. `;
  472. const SidebarSection = styled(SidebarSectionGroup)<{
  473. noMargin?: boolean;
  474. noPadding?: boolean;
  475. }>`
  476. ${p => !p.noMargin && `margin: ${space(1)} 0`};
  477. ${p => !p.noPadding && 'padding: 0 19px'};
  478. @media (max-width: ${p => p.theme.breakpoints[0]}) {
  479. margin: 0;
  480. padding: 0;
  481. }
  482. &:empty {
  483. display: none;
  484. }
  485. `;
  486. const ExpandedIcon = css`
  487. transition: 0.3s transform ease;
  488. transform: rotate(270deg);
  489. `;
  490. const CollapsedIcon = css`
  491. transform: rotate(90deg);
  492. `;
  493. const StyledIconChevron = styled(({collapsed, ...props}) => (
  494. <IconChevron
  495. direction="left"
  496. size="md"
  497. isCircled
  498. css={[ExpandedIcon, collapsed && CollapsedIcon]}
  499. {...props}
  500. />
  501. ))``;
  502. const SidebarCollapseItem = styled(SidebarItem)`
  503. @media (max-width: ${p => p.theme.breakpoints[1]}) {
  504. display: none;
  505. }
  506. `;