sidebarItem.tsx 15 KB

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