sidebarItem.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. import {Fragment, isValidElement, useCallback, useContext, useMemo} from 'react';
  2. import isPropValid from '@emotion/is-prop-valid';
  3. import type {Theme} from '@emotion/react';
  4. import {css} from '@emotion/react';
  5. import styled from '@emotion/styled';
  6. import type {LocationDescriptor} from 'history';
  7. import FeatureBadge from 'sentry/components/badge/featureBadge';
  8. import {Flex} from 'sentry/components/container/flex';
  9. import HookOrDefault from 'sentry/components/hookOrDefault';
  10. import InteractionStateLayer from 'sentry/components/interactionStateLayer';
  11. import Link from 'sentry/components/links/link';
  12. import {ExpandedContext} from 'sentry/components/sidebar/expandedContextProvider';
  13. import TextOverflow from 'sentry/components/textOverflow';
  14. import {Tooltip} from 'sentry/components/tooltip';
  15. import {space} from 'sentry/styles/space';
  16. import {defined} from 'sentry/utils';
  17. import {trackAnalytics} from 'sentry/utils/analytics';
  18. import localStorage from 'sentry/utils/localStorage';
  19. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  20. import useOrganization from 'sentry/utils/useOrganization';
  21. import useRouter from 'sentry/utils/useRouter';
  22. import type {SidebarOrientation} from './types';
  23. import {SIDEBAR_NAVIGATION_SOURCE} from './utils';
  24. const LabelHook = HookOrDefault({
  25. hookName: 'sidebar:item-label',
  26. defaultComponent: ({children}) => <Fragment>{children}</Fragment>,
  27. });
  28. const tooltipDisabledProps = {
  29. disabled: true,
  30. };
  31. export type SidebarItemProps = {
  32. /**
  33. * Icon to display
  34. */
  35. icon: React.ReactNode;
  36. /**
  37. * Key of the sidebar item. Used for label hooks
  38. */
  39. id: string;
  40. /**
  41. * Label to display (only when expanded)
  42. */
  43. label: React.ReactNode;
  44. /**
  45. * Sidebar is at "top" or "left" of screen
  46. */
  47. orientation: SidebarOrientation;
  48. /**
  49. * Is this sidebar item active
  50. */
  51. active?: boolean;
  52. /**
  53. * Additional badge to display after label
  54. */
  55. badge?: number;
  56. /**
  57. * Custom tooltip title for the badge
  58. */
  59. badgeTitle?: string;
  60. className?: string;
  61. /**
  62. * Is sidebar in a collapsed state
  63. */
  64. collapsed?: boolean;
  65. /**
  66. * Whether to use exact matching to detect active paths. If true, this item will only
  67. * be active if the current router path exactly matches the `to` prop. If false
  68. * (default), there will be a match for any router path that _starts with_ the `to`
  69. * prop.
  70. */
  71. exact?: boolean;
  72. hasNewNav?: boolean;
  73. /**
  74. * Sidebar has a panel open
  75. */
  76. hasPanel?: boolean;
  77. href?: string;
  78. index?: boolean;
  79. /**
  80. * Additional badge letting users know a tab is in alpha.
  81. */
  82. isAlpha?: boolean;
  83. /**
  84. * Additional badge letting users know a tab is in beta.
  85. */
  86. isBeta?: boolean;
  87. /**
  88. * Is main item in a floating accordion
  89. */
  90. isMainItem?: boolean;
  91. /**
  92. * Is this item nested within another item
  93. */
  94. isNested?: boolean;
  95. /**
  96. * Specify the variant for the badge.
  97. */
  98. isNew?: boolean;
  99. /**
  100. * An optional prefix that can be used to reset the "new" indicator
  101. */
  102. isNewSeenKeySuffix?: string;
  103. /**
  104. * Is this item expanded in the floating sidebar
  105. */
  106. isOpenInFloatingSidebar?: boolean;
  107. onClick?: (id: string, e: React.MouseEvent<HTMLAnchorElement>) => void;
  108. search?: string;
  109. to?: string;
  110. /**
  111. * Content to render at the end of the item.
  112. */
  113. trailingItems?: React.ReactNode;
  114. /**
  115. * Content to render at the end of the item.
  116. */
  117. variant?: 'badge' | 'indicator' | 'short' | undefined;
  118. };
  119. function SidebarItem({
  120. id,
  121. href,
  122. to,
  123. search,
  124. icon,
  125. label,
  126. badge,
  127. active,
  128. exact,
  129. hasPanel,
  130. isNew,
  131. isBeta,
  132. isAlpha,
  133. collapsed,
  134. className,
  135. orientation,
  136. isNewSeenKeySuffix,
  137. onClick,
  138. trailingItems,
  139. variant,
  140. isNested,
  141. isMainItem,
  142. isOpenInFloatingSidebar,
  143. hasNewNav,
  144. badgeTitle,
  145. ...props
  146. }: SidebarItemProps) {
  147. const {setExpandedItemId, shouldAccordionFloat} = useContext(ExpandedContext);
  148. const router = useRouter();
  149. // label might be wrapped in a guideAnchor
  150. let labelString = label;
  151. if (isValidElement(label)) {
  152. labelString = label?.props?.children ?? label;
  153. }
  154. // If there is no active panel open and if path is active according to react-router
  155. const isActiveRouter =
  156. !hasPanel && router && isItemActive({to, label: labelString}, exact);
  157. // TODO: floating accordion should be transformed into secondary panel
  158. let isInFloatingAccordion = (isNested || isMainItem) && shouldAccordionFloat;
  159. if (hasNewNav) {
  160. isInFloatingAccordion = false;
  161. }
  162. const hasLink = Boolean(to);
  163. const isInCollapsedState = (!isInFloatingAccordion && collapsed) || hasNewNav;
  164. const isActive = defined(active) ? active : isActiveRouter;
  165. const isTop = orientation === 'top' && !isInFloatingAccordion;
  166. const placement = isTop ? 'bottom' : 'right';
  167. const seenSuffix = isNewSeenKeySuffix ?? '';
  168. const isNewSeenKey = `sidebar-new-seen:${id}${seenSuffix}`;
  169. const showIsNew =
  170. isNew && !localStorage.getItem(isNewSeenKey) && !(isInFloatingAccordion && !hasLink);
  171. const organization = useOrganization({allowNull: true});
  172. const recordAnalytics = useCallback(
  173. () => trackAnalytics('growth.clicked_sidebar', {item: id, organization}),
  174. [id, organization]
  175. );
  176. const toProps: LocationDescriptor = useMemo(() => {
  177. if (!to && !href) {
  178. return '#';
  179. }
  180. return {
  181. pathname: to ? to : href,
  182. search,
  183. state: {source: SIDEBAR_NAVIGATION_SOURCE},
  184. };
  185. }, [to, href, search]);
  186. const badges = (
  187. <Fragment>
  188. {showIsNew && <FeatureBadge type="new" variant={variant} title={badgeTitle} />}
  189. {isBeta && <FeatureBadge type="beta" variant={variant} title={badgeTitle} />}
  190. {isAlpha && <FeatureBadge type="alpha" variant={variant} title={badgeTitle} />}
  191. </Fragment>
  192. );
  193. const handleItemClick = useCallback(
  194. (event: React.MouseEvent<HTMLAnchorElement>) => {
  195. setExpandedItemId(null);
  196. !(to || href) && event.preventDefault();
  197. recordAnalytics();
  198. onClick?.(id, event);
  199. showIsNew && localStorage.setItem(isNewSeenKey, 'true');
  200. },
  201. [href, to, id, onClick, recordAnalytics, showIsNew, isNewSeenKey, setExpandedItemId]
  202. );
  203. return (
  204. <Tooltip
  205. disabled={
  206. (!isInCollapsedState && !isTop) ||
  207. (shouldAccordionFloat && isOpenInFloatingSidebar) ||
  208. hasNewNav
  209. }
  210. title={
  211. <Flex align="center">
  212. {label} {badges}
  213. </Flex>
  214. }
  215. position={placement}
  216. >
  217. <SidebarNavigationItemHook id={id}>
  218. {({additionalContent}) => (
  219. <StyledSidebarItem
  220. {...props}
  221. id={`sidebar-item-${id}`}
  222. isInFloatingAccordion={isInFloatingAccordion}
  223. active={isActive ? 'true' : undefined}
  224. to={toProps}
  225. disabled={!hasLink && isInFloatingAccordion}
  226. className={className}
  227. aria-current={isActive ? 'page' : undefined}
  228. onClick={handleItemClick}
  229. hasNewNav={hasNewNav}
  230. >
  231. {hasNewNav ? (
  232. <StyledInteractionStateLayer
  233. isPressed={isActive}
  234. color="white"
  235. higherOpacity
  236. />
  237. ) : (
  238. <InteractionStateLayer isPressed={isActive} color="white" higherOpacity />
  239. )}
  240. <SidebarItemWrapper collapsed={isInCollapsedState} hasNewNav={hasNewNav}>
  241. {!isInFloatingAccordion && (
  242. <SidebarItemIcon hasNewNav={hasNewNav}>{icon}</SidebarItemIcon>
  243. )}
  244. {!isInCollapsedState && !isTop && (
  245. <SidebarItemLabel
  246. isInFloatingAccordion={isInFloatingAccordion}
  247. isNested={isNested}
  248. >
  249. <LabelHook id={id}>
  250. <TruncatedLabel>{label}</TruncatedLabel>
  251. {additionalContent ?? badges}
  252. </LabelHook>
  253. </SidebarItemLabel>
  254. )}
  255. {isInCollapsedState && showIsNew && (
  256. <CollapsedFeatureBadge
  257. type="new"
  258. variant="indicator"
  259. tooltipProps={tooltipDisabledProps}
  260. />
  261. )}
  262. {isInCollapsedState && isBeta && (
  263. <CollapsedFeatureBadge
  264. type="beta"
  265. variant="indicator"
  266. tooltipProps={tooltipDisabledProps}
  267. />
  268. )}
  269. {isInCollapsedState && isAlpha && (
  270. <CollapsedFeatureBadge
  271. type="alpha"
  272. variant="indicator"
  273. tooltipProps={tooltipDisabledProps}
  274. />
  275. )}
  276. {badge !== undefined && badge > 0 && (
  277. <SidebarItemBadge collapsed={isInCollapsedState}>
  278. {badge}
  279. </SidebarItemBadge>
  280. )}
  281. {!isInFloatingAccordion && hasNewNav && (
  282. <LabelHook id={id}>
  283. <TruncatedLabel hasNewNav={hasNewNav}>{label}</TruncatedLabel>
  284. {additionalContent ?? badges}
  285. </LabelHook>
  286. )}
  287. {trailingItems}
  288. </SidebarItemWrapper>
  289. </StyledSidebarItem>
  290. )}
  291. </SidebarNavigationItemHook>
  292. </Tooltip>
  293. );
  294. }
  295. export function isItemActive(
  296. item: Pick<SidebarItemProps, 'to' | 'label'>,
  297. exact?: boolean
  298. ): boolean {
  299. // take off the query params for matching
  300. const toPathWithoutReferrer = item?.to?.split('?')[0];
  301. if (!toPathWithoutReferrer) {
  302. return false;
  303. }
  304. return (
  305. (exact
  306. ? location.pathname === normalizeUrl(toPathWithoutReferrer)
  307. : location.pathname.startsWith(normalizeUrl(toPathWithoutReferrer))) ||
  308. (item?.label === 'Discover' && location.pathname.includes('/discover/')) ||
  309. (item?.label === 'Dashboards' &&
  310. (location.pathname.includes('/dashboards/') ||
  311. location.pathname.includes('/dashboard/')) &&
  312. !location.pathname.startsWith('/settings/')) ||
  313. // TODO: this won't be necessary once we remove settingsHome
  314. (item?.label === 'Settings' && location.pathname.startsWith('/settings/')) ||
  315. (item?.label === 'Alerts' &&
  316. location.pathname.includes('/alerts/') &&
  317. !location.pathname.startsWith('/settings/')) ||
  318. (item?.label === 'Releases' && location.pathname.includes('/release-thresholds/')) ||
  319. (item?.label === 'Performance' &&
  320. location.pathname.includes('/performance/') &&
  321. !location.pathname.startsWith('/settings/'))
  322. );
  323. }
  324. const SidebarNavigationItemHook = HookOrDefault({
  325. hookName: 'sidebar:navigation-item',
  326. defaultComponent: ({children}) =>
  327. children({
  328. disabled: false,
  329. additionalContent: null,
  330. Wrapper: Fragment,
  331. }),
  332. });
  333. export default SidebarItem;
  334. const getActiveStyle = ({
  335. active,
  336. theme,
  337. isInFloatingAccordion,
  338. }: {
  339. active?: string;
  340. hasNewNav?: boolean;
  341. isInFloatingAccordion?: boolean;
  342. theme?: Theme;
  343. }) => {
  344. if (!active) {
  345. return '';
  346. }
  347. if (isInFloatingAccordion) {
  348. return css`
  349. &:active,
  350. &:focus,
  351. &:hover {
  352. color: ${theme?.gray400};
  353. }
  354. `;
  355. }
  356. return css`
  357. color: ${theme?.white};
  358. &:active,
  359. &:focus,
  360. &:hover {
  361. color: ${theme?.white};
  362. }
  363. &:before {
  364. background-color: ${theme?.active};
  365. }
  366. `;
  367. };
  368. const StyledSidebarItem = styled(Link, {
  369. shouldForwardProp: p => typeof p === 'string' && isPropValid(p),
  370. })`
  371. display: flex;
  372. color: ${p => (p.isInFloatingAccordion ? p.theme.gray400 : 'inherit')};
  373. position: relative;
  374. cursor: pointer;
  375. font-size: 15px;
  376. height: ${p => (p.isInFloatingAccordion ? '35px' : p.hasNewNav ? '40px' : '30px')};
  377. flex-shrink: 0;
  378. border-radius: ${p => p.theme.borderRadius};
  379. transition: none;
  380. ${p => {
  381. if (!p.hasNewNav) {
  382. return css`
  383. &:before {
  384. display: block;
  385. content: '';
  386. position: absolute;
  387. top: 4px;
  388. left: calc(-${space(2)} - 1px);
  389. bottom: 6px;
  390. width: 5px;
  391. border-radius: 0 3px 3px 0;
  392. background-color: transparent;
  393. transition: 0.15s background-color linear;
  394. }
  395. `;
  396. }
  397. return css`
  398. margin: ${space(2)} 0;
  399. width: 100px;
  400. align-self: center;
  401. `;
  402. }}
  403. @media (max-width: ${p => p.theme.breakpoints.medium}) {
  404. &:before {
  405. top: auto;
  406. left: 5px;
  407. bottom: -12px;
  408. height: 5px;
  409. width: auto;
  410. right: 5px;
  411. border-radius: 3px 3px 0 0;
  412. }
  413. }
  414. &:hover,
  415. &:focus-visible {
  416. ${p => {
  417. if (p.isInFloatingAccordion) {
  418. return css`
  419. background-color: ${p.theme.hover};
  420. color: ${p.theme.gray400};
  421. `;
  422. }
  423. return css`
  424. color: ${p.theme.white};
  425. `;
  426. }}
  427. }
  428. &:focus {
  429. outline: none;
  430. }
  431. &:focus-visible {
  432. outline: none;
  433. box-shadow: 0 0 0 2px ${p => p.theme.purple300};
  434. }
  435. ${getActiveStyle};
  436. `;
  437. const SidebarItemWrapper = styled('div')<{collapsed?: boolean; hasNewNav?: boolean}>`
  438. display: flex;
  439. align-items: center;
  440. justify-content: center;
  441. ${p => p.hasNewNav && 'flex-direction: column;'}
  442. width: 100%;
  443. ${p => !p.collapsed && `padding-right: ${space(1)};`}
  444. @media (max-width: ${p => p.theme.breakpoints.medium}) {
  445. padding-right: 0;
  446. }
  447. `;
  448. const SidebarItemIcon = styled('span')<{hasNewNav?: boolean}>`
  449. display: flex;
  450. align-items: center;
  451. justify-content: center;
  452. flex-shrink: 0;
  453. width: 37px;
  454. svg {
  455. display: block;
  456. margin: 0 auto;
  457. width: 18px;
  458. height: 18px;
  459. }
  460. ${p =>
  461. p.hasNewNav &&
  462. css`
  463. @media (max-width: ${p.theme.breakpoints.medium}) {
  464. display: none;
  465. }
  466. `};
  467. `;
  468. const SidebarItemLabel = styled('span')<{
  469. isInFloatingAccordion?: boolean;
  470. isNested?: boolean;
  471. }>`
  472. margin-left: ${p => (p.isNested && p.isInFloatingAccordion ? space(4) : '10px')};
  473. white-space: nowrap;
  474. opacity: 1;
  475. flex: 1;
  476. display: flex;
  477. align-items: center;
  478. overflow: hidden;
  479. `;
  480. const TruncatedLabel = styled(TextOverflow)<{hasNewNav?: boolean}>`
  481. ${p =>
  482. !p.hasNewNav &&
  483. css`
  484. margin-right: auto;
  485. `}
  486. `;
  487. const getCollapsedBadgeStyle = ({collapsed, theme}) => {
  488. if (!collapsed) {
  489. return '';
  490. }
  491. return css`
  492. text-indent: -99999em;
  493. position: absolute;
  494. right: 0;
  495. top: 1px;
  496. background: ${theme.red300};
  497. width: ${theme.sidebar.smallBadgeSize};
  498. height: ${theme.sidebar.smallBadgeSize};
  499. border-radius: ${theme.sidebar.smallBadgeSize};
  500. line-height: ${theme.sidebar.smallBadgeSize};
  501. box-shadow: ${theme.sidebar.boxShadow};
  502. `;
  503. };
  504. const SidebarItemBadge = styled(({collapsed: _, ...props}) => <span {...props} />)`
  505. display: block;
  506. text-align: center;
  507. color: ${p => p.theme.white};
  508. font-size: 12px;
  509. background: ${p => p.theme.red300};
  510. width: ${p => p.theme.sidebar.badgeSize};
  511. height: ${p => p.theme.sidebar.badgeSize};
  512. border-radius: ${p => p.theme.sidebar.badgeSize};
  513. line-height: ${p => p.theme.sidebar.badgeSize};
  514. ${getCollapsedBadgeStyle};
  515. `;
  516. const CollapsedFeatureBadge = styled(FeatureBadge)`
  517. position: absolute;
  518. top: 2px;
  519. right: 2px;
  520. `;
  521. const StyledInteractionStateLayer = styled(InteractionStateLayer)`
  522. height: ${16 * 2 + 40}px;
  523. width: 70px;
  524. `;