index.tsx 20 KB

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