sidebarItem.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  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' && location.pathname.includes('/performance/'))
  320. );
  321. }
  322. const SidebarNavigationItemHook = HookOrDefault({
  323. hookName: 'sidebar:navigation-item',
  324. defaultComponent: ({children}) =>
  325. children({
  326. disabled: false,
  327. additionalContent: null,
  328. Wrapper: Fragment,
  329. }),
  330. });
  331. export default SidebarItem;
  332. const getActiveStyle = ({
  333. active,
  334. theme,
  335. isInFloatingAccordion,
  336. }: {
  337. active?: string;
  338. hasNewNav?: boolean;
  339. isInFloatingAccordion?: boolean;
  340. theme?: Theme;
  341. }) => {
  342. if (!active) {
  343. return '';
  344. }
  345. if (isInFloatingAccordion) {
  346. return css`
  347. &:active,
  348. &:focus,
  349. &:hover {
  350. color: ${theme?.gray400};
  351. }
  352. `;
  353. }
  354. return css`
  355. color: ${theme?.white};
  356. &:active,
  357. &:focus,
  358. &:hover {
  359. color: ${theme?.white};
  360. }
  361. &:before {
  362. background-color: ${theme?.active};
  363. }
  364. `;
  365. };
  366. const StyledSidebarItem = styled(Link, {
  367. shouldForwardProp: p => typeof p === 'string' && isPropValid(p),
  368. })`
  369. display: flex;
  370. color: ${p => (p.isInFloatingAccordion ? p.theme.gray400 : 'inherit')};
  371. position: relative;
  372. cursor: pointer;
  373. font-size: 15px;
  374. height: ${p => (p.isInFloatingAccordion ? '35px' : p.hasNewNav ? '40px' : '30px')};
  375. flex-shrink: 0;
  376. border-radius: ${p => p.theme.borderRadius};
  377. transition: none;
  378. ${p => {
  379. if (!p.hasNewNav) {
  380. return css`
  381. &:before {
  382. display: block;
  383. content: '';
  384. position: absolute;
  385. top: 4px;
  386. left: calc(-${space(2)} - 1px);
  387. bottom: 6px;
  388. width: 5px;
  389. border-radius: 0 3px 3px 0;
  390. background-color: transparent;
  391. transition: 0.15s background-color linear;
  392. }
  393. `;
  394. }
  395. return css`
  396. margin: ${space(2)} 0;
  397. width: 100px;
  398. align-self: center;
  399. `;
  400. }}
  401. @media (max-width: ${p => p.theme.breakpoints.medium}) {
  402. &:before {
  403. top: auto;
  404. left: 5px;
  405. bottom: -12px;
  406. height: 5px;
  407. width: auto;
  408. right: 5px;
  409. border-radius: 3px 3px 0 0;
  410. }
  411. }
  412. &:hover,
  413. &:focus-visible {
  414. ${p => {
  415. if (p.isInFloatingAccordion) {
  416. return css`
  417. background-color: ${p.theme.hover};
  418. color: ${p.theme.gray400};
  419. `;
  420. }
  421. return css`
  422. color: ${p.theme.white};
  423. `;
  424. }}
  425. }
  426. &:focus {
  427. outline: none;
  428. }
  429. &:focus-visible {
  430. outline: none;
  431. box-shadow: 0 0 0 2px ${p => p.theme.purple300};
  432. }
  433. ${getActiveStyle};
  434. `;
  435. const SidebarItemWrapper = styled('div')<{collapsed?: boolean; hasNewNav?: boolean}>`
  436. display: flex;
  437. align-items: center;
  438. justify-content: center;
  439. ${p => p.hasNewNav && 'flex-direction: column;'}
  440. width: 100%;
  441. ${p => !p.collapsed && `padding-right: ${space(1)};`}
  442. @media (max-width: ${p => p.theme.breakpoints.medium}) {
  443. padding-right: 0;
  444. }
  445. `;
  446. const SidebarItemIcon = styled('span')<{hasNewNav?: boolean}>`
  447. display: flex;
  448. align-items: center;
  449. justify-content: center;
  450. flex-shrink: 0;
  451. width: 37px;
  452. svg {
  453. display: block;
  454. margin: 0 auto;
  455. width: 18px;
  456. height: 18px;
  457. }
  458. ${p =>
  459. p.hasNewNav &&
  460. css`
  461. @media (max-width: ${p.theme.breakpoints.medium}) {
  462. display: none;
  463. }
  464. `};
  465. `;
  466. const SidebarItemLabel = styled('span')<{
  467. isInFloatingAccordion?: boolean;
  468. isNested?: boolean;
  469. }>`
  470. margin-left: ${p => (p.isNested && p.isInFloatingAccordion ? space(4) : '10px')};
  471. white-space: nowrap;
  472. opacity: 1;
  473. flex: 1;
  474. display: flex;
  475. align-items: center;
  476. overflow: hidden;
  477. `;
  478. const TruncatedLabel = styled(TextOverflow)<{hasNewNav?: boolean}>`
  479. ${p =>
  480. !p.hasNewNav &&
  481. css`
  482. margin-right: auto;
  483. `}
  484. `;
  485. const getCollapsedBadgeStyle = ({collapsed, theme}) => {
  486. if (!collapsed) {
  487. return '';
  488. }
  489. return css`
  490. text-indent: -99999em;
  491. position: absolute;
  492. right: 0;
  493. top: 1px;
  494. background: ${theme.red300};
  495. width: ${theme.sidebar.smallBadgeSize};
  496. height: ${theme.sidebar.smallBadgeSize};
  497. border-radius: ${theme.sidebar.smallBadgeSize};
  498. line-height: ${theme.sidebar.smallBadgeSize};
  499. box-shadow: ${theme.sidebar.boxShadow};
  500. `;
  501. };
  502. const SidebarItemBadge = styled(({collapsed: _, ...props}) => <span {...props} />)`
  503. display: block;
  504. text-align: center;
  505. color: ${p => p.theme.white};
  506. font-size: 12px;
  507. background: ${p => p.theme.red300};
  508. width: ${p => p.theme.sidebar.badgeSize};
  509. height: ${p => p.theme.sidebar.badgeSize};
  510. border-radius: ${p => p.theme.sidebar.badgeSize};
  511. line-height: ${p => p.theme.sidebar.badgeSize};
  512. ${getCollapsedBadgeStyle};
  513. `;
  514. const CollapsedFeatureBadge = styled(FeatureBadge)`
  515. position: absolute;
  516. top: 2px;
  517. right: 2px;
  518. `;
  519. const StyledInteractionStateLayer = styled(InteractionStateLayer)`
  520. height: ${16 * 2 + 40}px;
  521. width: 70px;
  522. `;