index.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  1. import React from 'react';
  2. import {browserHistory} from 'react-router';
  3. import {css} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import createReactClass from 'create-react-class';
  6. import {Location} from 'history';
  7. import isEqual from 'lodash/isEqual';
  8. import * as queryString from 'query-string';
  9. import Reflux from 'reflux';
  10. import {hideSidebar, showSidebar} from 'app/actionCreators/preferences';
  11. import SidebarPanelActions from 'app/actions/sidebarPanelActions';
  12. import Feature from 'app/components/acl/feature';
  13. import GuideAnchor from 'app/components/assistant/guideAnchor';
  14. import {extractSelectionParameters} from 'app/components/organizations/globalSelectionHeader/utils';
  15. import {
  16. IconActivity,
  17. IconChevron,
  18. IconGraph,
  19. IconIssues,
  20. IconLab,
  21. IconLightning,
  22. IconProject,
  23. IconReleases,
  24. IconSettings,
  25. IconSiren,
  26. IconStats,
  27. IconSupport,
  28. IconTelescope,
  29. } from 'app/icons';
  30. import {t} from 'app/locale';
  31. import ConfigStore from 'app/stores/configStore';
  32. import HookStore from 'app/stores/hookStore';
  33. import PreferencesStore from 'app/stores/preferencesStore';
  34. import SidebarPanelStore from 'app/stores/sidebarPanelStore';
  35. import space from 'app/styles/space';
  36. import {Organization} from 'app/types';
  37. import {getDiscoverLandingUrl} from 'app/utils/discover/urls';
  38. import theme from 'app/utils/theme';
  39. import withOrganization from 'app/utils/withOrganization';
  40. import Broadcasts from './broadcasts';
  41. import SidebarHelp from './help';
  42. import OnboardingStatus from './onboardingStatus';
  43. import ServiceIncidents from './serviceIncidents';
  44. import SidebarDropdown from './sidebarDropdown';
  45. import SidebarItem from './sidebarItem';
  46. import {SidebarOrientation, SidebarPanelKey} from './types';
  47. type Props = {
  48. organization: Organization;
  49. activePanel: SidebarPanelKey | '';
  50. collapsed: boolean;
  51. location?: Location;
  52. children?: never;
  53. };
  54. type State = {
  55. horizontal: boolean;
  56. };
  57. class Sidebar extends React.Component<Props, State> {
  58. constructor(props: Props) {
  59. super(props);
  60. if (!window.matchMedia) {
  61. return;
  62. }
  63. // TODO(billy): We should consider moving this into a component
  64. this.mq = window.matchMedia(`(max-width: ${theme.breakpoints[1]})`);
  65. this.mq.addListener(this.handleMediaQueryChange);
  66. this.state.horizontal = this.mq.matches;
  67. }
  68. state: State = {
  69. horizontal: false,
  70. };
  71. componentDidMount() {
  72. document.body.classList.add('body-sidebar');
  73. this.checkHash();
  74. this.doCollapse(this.props.collapsed);
  75. }
  76. // Sidebar doesn't use children, so don't use it to compare
  77. // Also ignore location, will re-render when routes change (instead of query params)
  78. //
  79. // NOTE(epurkhiser): The comment above is why I added `children?: never` as a
  80. // type to this component. I'm not sure the implications of removing this so
  81. // I've just left it for now.
  82. shouldComponentUpdate(
  83. {children: _children, location: _location, ...nextPropsToCompare}: Props,
  84. nextState: State
  85. ) {
  86. const {
  87. children: _childrenCurrent,
  88. location: _locationCurrent,
  89. ...currentPropsToCompare
  90. } = this.props;
  91. return (
  92. !isEqual(currentPropsToCompare, nextPropsToCompare) ||
  93. !isEqual(this.state, nextState)
  94. );
  95. }
  96. componentDidUpdate(prevProps: Props) {
  97. const {collapsed, location} = this.props;
  98. // Close active panel if we navigated anywhere
  99. if (location?.pathname !== prevProps.location?.pathname) {
  100. this.hidePanel();
  101. }
  102. // Collapse
  103. if (collapsed !== prevProps.collapsed) {
  104. this.doCollapse(collapsed);
  105. }
  106. }
  107. componentWillUnmount() {
  108. document.body.classList.remove('body-sidebar');
  109. if (this.mq) {
  110. this.mq.removeListener(this.handleMediaQueryChange);
  111. this.mq = null;
  112. }
  113. }
  114. mq: MediaQueryList | null = null;
  115. sidebarRef = React.createRef<HTMLDivElement>();
  116. doCollapse(collapsed: boolean) {
  117. if (collapsed) {
  118. document.body.classList.add('collapsed');
  119. } else {
  120. document.body.classList.remove('collapsed');
  121. }
  122. }
  123. toggleSidebar = () => {
  124. const {collapsed} = this.props;
  125. if (!collapsed) {
  126. hideSidebar();
  127. } else {
  128. showSidebar();
  129. }
  130. };
  131. checkHash = () => {
  132. if (window.location.hash === '#welcome') {
  133. this.togglePanel(SidebarPanelKey.OnboardingWizard);
  134. }
  135. };
  136. handleMediaQueryChange = (changed: MediaQueryListEvent) => {
  137. this.setState({
  138. horizontal: changed.matches,
  139. });
  140. };
  141. togglePanel = (panel: SidebarPanelKey) => SidebarPanelActions.togglePanel(panel);
  142. hidePanel = () => SidebarPanelActions.hidePanel();
  143. // Keep the global selection querystring values in the path
  144. navigateWithGlobalSelection = (
  145. pathname: string,
  146. evt: React.MouseEvent<HTMLAnchorElement>
  147. ) => {
  148. const globalSelectionRoutes = [
  149. 'dashboards',
  150. 'issues',
  151. 'releases',
  152. 'user-feedback',
  153. 'discover',
  154. 'discover/results', // Team plans do not have query landing page
  155. 'performance',
  156. ].map(route => `/organizations/${this.props.organization.slug}/${route}/`);
  157. // Only keep the querystring if the current route matches one of the above
  158. if (globalSelectionRoutes.includes(pathname)) {
  159. const query = extractSelectionParameters(this.props.location?.query);
  160. // Handle cmd-click (mac) and meta-click (linux)
  161. if (evt.metaKey) {
  162. const q = queryString.stringify(query);
  163. evt.currentTarget.href = `${evt.currentTarget.href}?${q}`;
  164. return;
  165. }
  166. evt.preventDefault();
  167. browserHistory.push({pathname, query});
  168. }
  169. this.hidePanel();
  170. };
  171. render() {
  172. const {activePanel, organization, collapsed} = this.props;
  173. const {horizontal} = this.state;
  174. const config = ConfigStore.getConfig();
  175. const user = ConfigStore.get('user');
  176. const hasPanel = !!activePanel;
  177. const orientation: SidebarOrientation = horizontal ? 'top' : 'left';
  178. const sidebarItemProps = {
  179. orientation,
  180. collapsed,
  181. hasPanel,
  182. };
  183. const hasOrganization = !!organization;
  184. const projects = hasOrganization && (
  185. <SidebarItem
  186. {...sidebarItemProps}
  187. index
  188. onClick={this.hidePanel}
  189. icon={<IconProject size="md" />}
  190. label={<GuideAnchor target="projects">{t('Projects')}</GuideAnchor>}
  191. to={`/organizations/${organization.slug}/projects/`}
  192. id="projects"
  193. />
  194. );
  195. const issues = hasOrganization && (
  196. <SidebarItem
  197. {...sidebarItemProps}
  198. onClick={(_id, evt) =>
  199. this.navigateWithGlobalSelection(
  200. `/organizations/${organization.slug}/issues/`,
  201. evt
  202. )
  203. }
  204. icon={<IconIssues size="md" />}
  205. label={<GuideAnchor target="issues">{t('Issues')}</GuideAnchor>}
  206. to={`/organizations/${organization.slug}/issues/`}
  207. id="issues"
  208. />
  209. );
  210. const discover2 = hasOrganization && (
  211. <Feature
  212. hookName="feature-disabled:discover2-sidebar-item"
  213. features={['discover-basic']}
  214. organization={organization}
  215. >
  216. <SidebarItem
  217. {...sidebarItemProps}
  218. onClick={(_id, evt) =>
  219. this.navigateWithGlobalSelection(getDiscoverLandingUrl(organization), evt)
  220. }
  221. icon={<IconTelescope size="md" />}
  222. label={<GuideAnchor target="discover">{t('Discover')}</GuideAnchor>}
  223. to={getDiscoverLandingUrl(organization)}
  224. id="discover-v2"
  225. />
  226. </Feature>
  227. );
  228. const performance = hasOrganization && (
  229. <Feature
  230. hookName="feature-disabled:performance-sidebar-item"
  231. features={['performance-view']}
  232. organization={organization}
  233. >
  234. <SidebarItem
  235. {...sidebarItemProps}
  236. onClick={(_id, evt) =>
  237. this.navigateWithGlobalSelection(
  238. `/organizations/${organization.slug}/performance/`,
  239. evt
  240. )
  241. }
  242. icon={<IconLightning size="md" />}
  243. label={<GuideAnchor target="performance">{t('Performance')}</GuideAnchor>}
  244. to={`/organizations/${organization.slug}/performance/`}
  245. id="performance"
  246. />
  247. </Feature>
  248. );
  249. const releases = hasOrganization && (
  250. <SidebarItem
  251. {...sidebarItemProps}
  252. onClick={(_id, evt) =>
  253. this.navigateWithGlobalSelection(
  254. `/organizations/${organization.slug}/releases/`,
  255. evt
  256. )
  257. }
  258. icon={<IconReleases size="md" />}
  259. label={<GuideAnchor target="releases">{t('Releases')}</GuideAnchor>}
  260. to={`/organizations/${organization.slug}/releases/`}
  261. id="releases"
  262. />
  263. );
  264. const userFeedback = hasOrganization && (
  265. <SidebarItem
  266. {...sidebarItemProps}
  267. onClick={(_id, evt) =>
  268. this.navigateWithGlobalSelection(
  269. `/organizations/${organization.slug}/user-feedback/`,
  270. evt
  271. )
  272. }
  273. icon={<IconSupport size="md" />}
  274. label={t('User Feedback')}
  275. to={`/organizations/${organization.slug}/user-feedback/`}
  276. id="user-feedback"
  277. />
  278. );
  279. const alerts = hasOrganization && (
  280. <Feature features={['incidents', 'alert-details-redesign']} requireAll={false}>
  281. {({features}) => {
  282. const hasIncidents = features.includes('incidents');
  283. const hasAlertList = features.includes('alert-details-redesign');
  284. const alertsPath =
  285. hasIncidents && !hasAlertList
  286. ? `/organizations/${organization.slug}/alerts/`
  287. : `/organizations/${organization.slug}/alerts/rules/`;
  288. return (
  289. <SidebarItem
  290. {...sidebarItemProps}
  291. onClick={(_id, evt) => this.navigateWithGlobalSelection(alertsPath, evt)}
  292. icon={<IconSiren size="md" />}
  293. label={t('Alerts')}
  294. to={alertsPath}
  295. id="alerts"
  296. />
  297. );
  298. }}
  299. </Feature>
  300. );
  301. const monitors = hasOrganization && (
  302. <Feature features={['monitors']} organization={organization}>
  303. <SidebarItem
  304. {...sidebarItemProps}
  305. onClick={(_id, evt) =>
  306. this.navigateWithGlobalSelection(
  307. `/organizations/${organization.slug}/monitors/`,
  308. evt
  309. )
  310. }
  311. icon={<IconLab size="md" />}
  312. label={t('Monitors')}
  313. to={`/organizations/${organization.slug}/monitors/`}
  314. id="monitors"
  315. />
  316. </Feature>
  317. );
  318. const dashboards = hasOrganization && (
  319. <Feature
  320. features={['discover', 'discover-query', 'dashboards-basic', 'dashboards-edit']}
  321. organization={organization}
  322. requireAll={false}
  323. >
  324. <SidebarItem
  325. {...sidebarItemProps}
  326. index
  327. onClick={(_id, evt) =>
  328. this.navigateWithGlobalSelection(
  329. `/organizations/${organization.slug}/dashboards/`,
  330. evt
  331. )
  332. }
  333. icon={<IconGraph size="md" />}
  334. label={t('Dashboards')}
  335. to={`/organizations/${organization.slug}/dashboards/`}
  336. id="customizable-dashboards"
  337. />
  338. </Feature>
  339. );
  340. const activity = hasOrganization && (
  341. <SidebarItem
  342. {...sidebarItemProps}
  343. onClick={this.hidePanel}
  344. icon={<IconActivity size="md" />}
  345. label={t('Activity')}
  346. to={`/organizations/${organization.slug}/activity/`}
  347. id="activity"
  348. />
  349. );
  350. const stats = hasOrganization && (
  351. <SidebarItem
  352. {...sidebarItemProps}
  353. onClick={this.hidePanel}
  354. icon={<IconStats size="md" />}
  355. label={t('Stats')}
  356. to={`/organizations/${organization.slug}/stats/`}
  357. id="stats"
  358. />
  359. );
  360. const settings = hasOrganization && (
  361. <SidebarItem
  362. {...sidebarItemProps}
  363. onClick={this.hidePanel}
  364. icon={<IconSettings size="md" />}
  365. label={t('Settings')}
  366. to={`/settings/${organization.slug}/`}
  367. id="settings"
  368. />
  369. );
  370. return (
  371. <StyledSidebar ref={this.sidebarRef} collapsed={collapsed}>
  372. <SidebarSectionGroupPrimary>
  373. <SidebarSection>
  374. <SidebarDropdown
  375. orientation={orientation}
  376. collapsed={collapsed}
  377. org={organization}
  378. user={user}
  379. config={config}
  380. />
  381. </SidebarSection>
  382. <PrimaryItems>
  383. {hasOrganization && (
  384. <React.Fragment>
  385. <SidebarSection>
  386. {projects}
  387. {issues}
  388. {performance}
  389. {releases}
  390. {userFeedback}
  391. {alerts}
  392. {discover2}
  393. </SidebarSection>
  394. <SidebarSection>
  395. {dashboards}
  396. {monitors}
  397. </SidebarSection>
  398. <SidebarSection>
  399. {activity}
  400. {stats}
  401. </SidebarSection>
  402. <SidebarSection>{settings}</SidebarSection>
  403. </React.Fragment>
  404. )}
  405. </PrimaryItems>
  406. </SidebarSectionGroupPrimary>
  407. {hasOrganization && (
  408. <SidebarSectionGroup>
  409. <SidebarSection noMargin noPadding>
  410. <OnboardingStatus
  411. org={organization}
  412. currentPanel={activePanel}
  413. onShowPanel={() => this.togglePanel(SidebarPanelKey.OnboardingWizard)}
  414. hidePanel={this.hidePanel}
  415. {...sidebarItemProps}
  416. />
  417. </SidebarSection>
  418. <SidebarSection>
  419. {HookStore.get('sidebar:bottom-items').length > 0 &&
  420. HookStore.get('sidebar:bottom-items')[0]({
  421. organization,
  422. ...sidebarItemProps,
  423. })}
  424. <SidebarHelp
  425. orientation={orientation}
  426. collapsed={collapsed}
  427. hidePanel={this.hidePanel}
  428. organization={organization}
  429. />
  430. <Broadcasts
  431. orientation={orientation}
  432. collapsed={collapsed}
  433. currentPanel={activePanel}
  434. onShowPanel={() => this.togglePanel(SidebarPanelKey.Broadcasts)}
  435. hidePanel={this.hidePanel}
  436. organization={organization}
  437. />
  438. <ServiceIncidents
  439. orientation={orientation}
  440. collapsed={collapsed}
  441. currentPanel={activePanel}
  442. onShowPanel={() => this.togglePanel(SidebarPanelKey.StatusUpdate)}
  443. hidePanel={this.hidePanel}
  444. />
  445. </SidebarSection>
  446. {!horizontal && (
  447. <SidebarSection>
  448. <SidebarCollapseItem
  449. id="collapse"
  450. data-test-id="sidebar-collapse"
  451. {...sidebarItemProps}
  452. icon={<StyledIconChevron collapsed={collapsed} />}
  453. label={collapsed ? t('Expand') : t('Collapse')}
  454. onClick={this.toggleSidebar}
  455. />
  456. </SidebarSection>
  457. )}
  458. </SidebarSectionGroup>
  459. )}
  460. </StyledSidebar>
  461. );
  462. }
  463. }
  464. const SidebarContainer = createReactClass<Omit<Props, 'collapsed' | 'activePanel'>>({
  465. displayName: 'SidebarContainer',
  466. mixins: [
  467. Reflux.listenTo(PreferencesStore, 'onPreferenceChange') as any,
  468. Reflux.listenTo(SidebarPanelStore, 'onSidebarPanelChange') as any,
  469. ],
  470. getInitialState() {
  471. return {
  472. collapsed: PreferencesStore.getInitialState().collapsed,
  473. activePanel: '',
  474. };
  475. },
  476. onPreferenceChange(preferences: typeof PreferencesStore.prefs) {
  477. if (preferences.collapsed === this.state.collapsed) {
  478. return;
  479. }
  480. this.setState({collapsed: preferences.collapsed});
  481. },
  482. onSidebarPanelChange(activePanel: SidebarPanelKey | '') {
  483. this.setState({activePanel});
  484. },
  485. render() {
  486. const {activePanel, collapsed} = this.state;
  487. return <Sidebar {...this.props} {...{activePanel, collapsed}} />;
  488. },
  489. });
  490. export default withOrganization(SidebarContainer);
  491. const responsiveFlex = css`
  492. display: flex;
  493. flex-direction: column;
  494. @media (max-width: ${theme.breakpoints[1]}) {
  495. flex-direction: row;
  496. }
  497. `;
  498. const StyledSidebar = styled('div')<{collapsed: boolean}>`
  499. background: ${p => p.theme.sidebar.background};
  500. background: ${p => p.theme.sidebarGradient};
  501. color: ${p => p.theme.sidebar.color};
  502. line-height: 1;
  503. padding: 12px 0 2px; /* Allows for 32px avatars */
  504. width: ${p => p.theme.sidebar.expandedWidth};
  505. position: fixed;
  506. top: ${p => (ConfigStore.get('demoMode') ? p.theme.demo.headerSize : 0)};
  507. left: 0;
  508. bottom: 0;
  509. justify-content: space-between;
  510. z-index: ${p => p.theme.zIndex.sidebar};
  511. ${responsiveFlex};
  512. ${p => p.collapsed && `width: ${p.theme.sidebar.collapsedWidth};`};
  513. @media (max-width: ${p => p.theme.breakpoints[1]}) {
  514. top: 0;
  515. left: 0;
  516. right: 0;
  517. height: ${p => p.theme.sidebar.mobileHeight};
  518. bottom: auto;
  519. width: auto;
  520. padding: 0 ${space(1)};
  521. align-items: center;
  522. }
  523. `;
  524. const SidebarSectionGroup = styled('div')`
  525. ${responsiveFlex};
  526. flex-shrink: 0; /* prevents shrinking on Safari */
  527. `;
  528. const SidebarSectionGroupPrimary = styled('div')`
  529. ${responsiveFlex};
  530. /* necessary for child flexing on msedge and ff */
  531. min-height: 0;
  532. min-width: 0;
  533. flex: 1;
  534. /* expand to fill the entire height on mobile */
  535. @media (max-width: ${p => p.theme.breakpoints[1]}) {
  536. height: 100%;
  537. align-items: center;
  538. }
  539. `;
  540. const PrimaryItems = styled('div')`
  541. overflow: auto;
  542. flex: 1;
  543. display: flex;
  544. flex-direction: column;
  545. -ms-overflow-style: -ms-autohiding-scrollbar;
  546. @media (max-height: 675px) and (min-width: ${p => p.theme.breakpoints[1]}) {
  547. border-bottom: 1px solid ${p => p.theme.gray400};
  548. padding-bottom: ${space(1)};
  549. box-shadow: rgba(0, 0, 0, 0.15) 0px -10px 10px inset;
  550. &::-webkit-scrollbar {
  551. background-color: transparent;
  552. width: 8px;
  553. }
  554. &::-webkit-scrollbar-thumb {
  555. background: ${p => p.theme.gray400};
  556. border-radius: 8px;
  557. }
  558. }
  559. @media (max-width: ${p => p.theme.breakpoints[1]}) {
  560. overflow-y: visible;
  561. flex-direction: row;
  562. height: 100%;
  563. align-items: center;
  564. border-right: 1px solid ${p => p.theme.gray400};
  565. padding-right: ${space(1)};
  566. margin-right: ${space(0.5)};
  567. box-shadow: rgba(0, 0, 0, 0.15) -10px 0px 10px inset;
  568. ::-webkit-scrollbar {
  569. display: none;
  570. }
  571. }
  572. `;
  573. const SidebarSection = styled(SidebarSectionGroup)<{
  574. noMargin?: boolean;
  575. noPadding?: boolean;
  576. }>`
  577. ${p => !p.noMargin && `margin: ${space(1)} 0`};
  578. ${p => !p.noPadding && 'padding: 0 19px'};
  579. @media (max-width: ${p => p.theme.breakpoints[0]}) {
  580. margin: 0;
  581. padding: 0;
  582. }
  583. &:empty {
  584. display: none;
  585. }
  586. `;
  587. const ExpandedIcon = css`
  588. transition: 0.3s transform ease;
  589. transform: rotate(270deg);
  590. `;
  591. const CollapsedIcon = css`
  592. transform: rotate(90deg);
  593. `;
  594. const StyledIconChevron = styled(({collapsed, ...props}) => (
  595. <IconChevron
  596. direction="left"
  597. size="md"
  598. isCircled
  599. css={[ExpandedIcon, collapsed && CollapsedIcon]}
  600. {...props}
  601. />
  602. ))``;
  603. const SidebarCollapseItem = styled(SidebarItem)`
  604. @media (max-width: ${p => p.theme.breakpoints[1]}) {
  605. display: none;
  606. }
  607. `;