index.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. import {useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import color from 'color';
  4. import {Location} from 'history';
  5. import partition from 'lodash/partition';
  6. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  7. import {Button} from 'sentry/components/button';
  8. import Collapsible from 'sentry/components/collapsible';
  9. import GlobalSelectionLink from 'sentry/components/globalSelectionLink';
  10. import Panel from 'sentry/components/panels/panel';
  11. import PanelHeader from 'sentry/components/panels/panelHeader';
  12. import TextOverflow from 'sentry/components/textOverflow';
  13. import TimeSince from 'sentry/components/timeSince';
  14. import {Tooltip} from 'sentry/components/tooltip';
  15. import Version from 'sentry/components/version';
  16. import {t, tct, tn} from 'sentry/locale';
  17. import {space} from 'sentry/styles/space';
  18. import {Organization, PageFilters, Release} from 'sentry/types';
  19. import {ThresholdStatus} from '../../utils/types';
  20. import {ReleasesDisplayOption} from '../releasesDisplayOptions';
  21. import {ReleasesRequestRenderProps} from '../releasesRequest';
  22. import ReleaseCardCommits from './releaseCardCommits';
  23. import ReleaseCardProjectRow from './releaseCardProjectRow';
  24. import ReleaseCardStatsPeriod from './releaseCardStatsPeriod';
  25. function getReleaseProjectId(release: Release, selection: PageFilters) {
  26. // if a release has only one project
  27. if (release.projects.length === 1) {
  28. return release.projects[0].id;
  29. }
  30. // if only one project is selected in global header and release has it (second condition will prevent false positives like -1)
  31. if (
  32. selection.projects.length === 1 &&
  33. release.projects.map(p => p.id).includes(selection.projects[0])
  34. ) {
  35. return selection.projects[0];
  36. }
  37. // project selector on release detail page will pick it up
  38. return undefined;
  39. }
  40. type Props = {
  41. activeDisplay: ReleasesDisplayOption;
  42. getHealthData: ReleasesRequestRenderProps['getHealthData'];
  43. isTopRelease: boolean;
  44. location: Location;
  45. organization: Organization;
  46. release: Release;
  47. reloading: boolean;
  48. selection: PageFilters;
  49. showHealthPlaceholders: boolean;
  50. showReleaseAdoptionStages: boolean;
  51. thresholdStatuses: {[key: string]: ThresholdStatus[]};
  52. };
  53. function ReleaseCard({
  54. release,
  55. organization,
  56. activeDisplay,
  57. location,
  58. reloading,
  59. selection,
  60. showHealthPlaceholders,
  61. isTopRelease,
  62. getHealthData,
  63. showReleaseAdoptionStages,
  64. thresholdStatuses,
  65. }: Props) {
  66. const {
  67. version,
  68. commitCount,
  69. lastDeploy,
  70. dateCreated,
  71. versionInfo,
  72. adoptionStages,
  73. projects,
  74. } = release;
  75. const [projectsToShow, projectsToHide] = useMemo(() => {
  76. // sort health rows inside release card alphabetically by project name,
  77. // show only the ones that are selected in global header
  78. return partition(
  79. projects.sort((a, b) => a.slug.localeCompare(b.slug)),
  80. p =>
  81. // do not filter for My Projects & All Projects
  82. selection.projects.length > 0 && !selection.projects.includes(-1)
  83. ? selection.projects.includes(p.id)
  84. : true
  85. );
  86. }, [projects, selection.projects]);
  87. const hasThresholds = useMemo(() => {
  88. const project_slugs = projects.map(proj => proj.slug);
  89. let has = false;
  90. project_slugs.forEach(slug => {
  91. if (`${slug}-${version}` in thresholdStatuses) {
  92. has = thresholdStatuses[`${slug}-${version}`].length > 0;
  93. }
  94. });
  95. return has;
  96. }, [thresholdStatuses, version, projects]);
  97. const getHiddenProjectsTooltip = () => {
  98. const limitedProjects = projectsToHide.map(p => p.slug).slice(0, 5);
  99. const remainderLength = projectsToHide.length - limitedProjects.length;
  100. if (remainderLength) {
  101. limitedProjects.push(tn('and %s more', 'and %s more', remainderLength));
  102. }
  103. return limitedProjects.join(', ');
  104. };
  105. return (
  106. <StyledPanel reloading={reloading ? 1 : 0} data-test-id="release-panel">
  107. <ReleaseInfo>
  108. {/* Header/info is the table sidecard */}
  109. <ReleaseInfoHeader>
  110. <GlobalSelectionLink
  111. to={{
  112. pathname: `/organizations/${
  113. organization.slug
  114. }/releases/${encodeURIComponent(version)}/`,
  115. query: {project: getReleaseProjectId(release, selection)},
  116. }}
  117. >
  118. <GuideAnchor
  119. disabled={!isTopRelease || projectsToShow.length > 1}
  120. target="release_version"
  121. >
  122. <VersionWrapper>
  123. <StyledVersion version={version} tooltipRawVersion anchor={false} />
  124. </VersionWrapper>
  125. </GuideAnchor>
  126. </GlobalSelectionLink>
  127. {commitCount > 0 && (
  128. <ReleaseCardCommits release={release} withHeading={false} />
  129. )}
  130. </ReleaseInfoHeader>
  131. <ReleaseInfoSubheader>
  132. {versionInfo?.package && (
  133. <PackageName>
  134. <TextOverflow ellipsisDirection="left">{versionInfo.package}</TextOverflow>
  135. </PackageName>
  136. )}
  137. <TimeSince date={lastDeploy?.dateFinished || dateCreated} />
  138. {lastDeploy?.dateFinished && ` \u007C ${lastDeploy.environment}`}
  139. </ReleaseInfoSubheader>
  140. </ReleaseInfo>
  141. <ReleaseProjects>
  142. {/* projects is the table */}
  143. <ReleaseProjectsHeader lightText>
  144. <ReleaseProjectsLayout
  145. showReleaseAdoptionStages={showReleaseAdoptionStages}
  146. hasThresholds={hasThresholds}
  147. >
  148. <ReleaseProjectColumn>{t('Project Name')}</ReleaseProjectColumn>
  149. {showReleaseAdoptionStages && (
  150. <AdoptionStageColumn>{t('Adoption Stage')}</AdoptionStageColumn>
  151. )}
  152. <AdoptionColumn>
  153. <span>{t('Adoption')}</span>
  154. <ReleaseCardStatsPeriod location={location} />
  155. </AdoptionColumn>
  156. <CrashFreeRateColumn>{t('Crash Free Rate')}</CrashFreeRateColumn>
  157. <DisplaySmallCol>{t('Crashes')}</DisplaySmallCol>
  158. <NewIssuesColumn>{t('New Issues')}</NewIssuesColumn>
  159. {hasThresholds && <DisplaySmallCol>{t('Thresholds')}</DisplaySmallCol>}
  160. </ReleaseProjectsLayout>
  161. </ReleaseProjectsHeader>
  162. <ProjectRows>
  163. <Collapsible
  164. expandButton={({onExpand, numberOfHiddenItems}) => (
  165. <ExpandButtonWrapper>
  166. <Button priority="primary" size="xs" onClick={onExpand}>
  167. {tct('Show [numberOfHiddenItems] More', {numberOfHiddenItems})}
  168. </Button>
  169. </ExpandButtonWrapper>
  170. )}
  171. collapseButton={({onCollapse}) => (
  172. <CollapseButtonWrapper>
  173. <Button priority="primary" size="xs" onClick={onCollapse}>
  174. {t('Collapse')}
  175. </Button>
  176. </CollapseButtonWrapper>
  177. )}
  178. >
  179. {projectsToShow.map((project, index) => {
  180. const key = `${project.slug}-${version}`;
  181. return (
  182. <ReleaseCardProjectRow
  183. key={`${key}-row`}
  184. activeDisplay={activeDisplay}
  185. adoptionStages={adoptionStages}
  186. getHealthData={getHealthData}
  187. hasThresholds={hasThresholds}
  188. index={index}
  189. isTopRelease={isTopRelease}
  190. location={location}
  191. organization={organization}
  192. project={project}
  193. releaseVersion={version}
  194. lastDeploy={lastDeploy}
  195. showPlaceholders={showHealthPlaceholders}
  196. showReleaseAdoptionStages={showReleaseAdoptionStages}
  197. thresholdStatuses={hasThresholds ? thresholdStatuses[`${key}`] : []}
  198. />
  199. );
  200. })}
  201. </Collapsible>
  202. </ProjectRows>
  203. {projectsToHide.length > 0 && (
  204. <HiddenProjectsMessage data-test-id="hidden-projects">
  205. <Tooltip title={getHiddenProjectsTooltip()}>
  206. <TextOverflow>
  207. {projectsToHide.length === 1
  208. ? tct('[number:1] hidden project', {number: <strong />})
  209. : tct('[number] hidden projects', {
  210. number: <strong>{projectsToHide.length}</strong>,
  211. })}
  212. </TextOverflow>
  213. </Tooltip>
  214. </HiddenProjectsMessage>
  215. )}
  216. </ReleaseProjects>
  217. </StyledPanel>
  218. );
  219. }
  220. const VersionWrapper = styled('div')`
  221. display: flex;
  222. align-items: center;
  223. `;
  224. const StyledVersion = styled(Version)`
  225. ${p => p.theme.overflowEllipsis};
  226. `;
  227. const StyledPanel = styled(Panel)<{reloading: number}>`
  228. opacity: ${p => (p.reloading ? 0.5 : 1)};
  229. pointer-events: ${p => (p.reloading ? 'none' : 'auto')};
  230. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  231. display: flex;
  232. }
  233. `;
  234. const ReleaseInfo = styled('div')`
  235. padding: ${space(1.5)} ${space(2)};
  236. flex-shrink: 0;
  237. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  238. border-right: 1px solid ${p => p.theme.border};
  239. min-width: 260px;
  240. width: 22%;
  241. max-width: 300px;
  242. }
  243. `;
  244. const ReleaseInfoSubheader = styled('div')`
  245. font-size: ${p => p.theme.fontSizeSmall};
  246. color: ${p => p.theme.gray400};
  247. `;
  248. const PackageName = styled('div')`
  249. font-size: ${p => p.theme.fontSizeMedium};
  250. color: ${p => p.theme.textColor};
  251. display: flex;
  252. align-items: center;
  253. gap: ${space(0.5)};
  254. `;
  255. const ReleaseProjects = styled('div')`
  256. border-top: 1px solid ${p => p.theme.border};
  257. flex-grow: 1;
  258. display: grid;
  259. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  260. border-top: none;
  261. }
  262. `;
  263. const ReleaseInfoHeader = styled('div')`
  264. font-size: ${p => p.theme.fontSizeExtraLarge};
  265. display: grid;
  266. grid-template-columns: minmax(0, 1fr) max-content;
  267. gap: ${space(2)};
  268. align-items: center;
  269. `;
  270. const ReleaseProjectsHeader = styled(PanelHeader)`
  271. border-top-left-radius: 0;
  272. padding: ${space(1.5)} ${space(2)};
  273. font-size: ${p => p.theme.fontSizeSmall};
  274. `;
  275. const ProjectRows = styled('div')`
  276. position: relative;
  277. `;
  278. const ExpandButtonWrapper = styled('div')`
  279. position: absolute;
  280. width: 100%;
  281. bottom: 0;
  282. display: flex;
  283. align-items: center;
  284. justify-content: center;
  285. background-image: linear-gradient(
  286. 180deg,
  287. ${p => color(p.theme.background).alpha(0).string()} 0,
  288. ${p => p.theme.background}
  289. );
  290. background-repeat: repeat-x;
  291. border-bottom: ${space(1)} solid ${p => p.theme.background};
  292. border-top: ${space(1)} solid transparent;
  293. border-bottom-right-radius: ${p => p.theme.borderRadius};
  294. @media (max-width: ${p => p.theme.breakpoints.medium}) {
  295. border-bottom-left-radius: ${p => p.theme.borderRadius};
  296. }
  297. `;
  298. const CollapseButtonWrapper = styled('div')`
  299. display: flex;
  300. align-items: center;
  301. justify-content: center;
  302. height: 41px;
  303. `;
  304. export const ReleaseProjectsLayout = styled('div')<{
  305. hasThresholds?: boolean;
  306. showReleaseAdoptionStages?: boolean;
  307. }>`
  308. display: grid;
  309. grid-template-columns: 1fr 1.4fr 0.6fr 0.7fr;
  310. grid-column-gap: ${space(1)};
  311. align-items: center;
  312. width: 100%;
  313. @media (min-width: ${p => p.theme.breakpoints.small}) {
  314. ${p => {
  315. const thresholdSize = p.hasThresholds ? '0.5fr' : '';
  316. return `grid-template-columns: 1fr 1fr 1fr 0.5fr 0.5fr ${thresholdSize} 0.5fr`;
  317. }}
  318. }
  319. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  320. ${p => {
  321. const thresholdSize = p.hasThresholds ? '0.5fr' : '';
  322. return `grid-template-columns: 1fr 1fr 1fr 0.5fr 0.5fr ${thresholdSize} 0.5fr`;
  323. }}
  324. }
  325. @media (min-width: ${p => p.theme.breakpoints.xlarge}) {
  326. ${p => {
  327. const adoptionStagesSize = p.showReleaseAdoptionStages ? '0.7fr' : '';
  328. const thresholdSize = p.hasThresholds ? '0.7fr' : '';
  329. return `grid-template-columns: 1fr ${adoptionStagesSize} 1fr 1fr 0.7fr 0.7fr ${thresholdSize} 0.5fr`;
  330. }}
  331. }
  332. `;
  333. export const ReleaseProjectColumn = styled('div')`
  334. ${p => p.theme.overflowEllipsis};
  335. line-height: 20px;
  336. `;
  337. export const NewIssuesColumn = styled(ReleaseProjectColumn)`
  338. font-variant-numeric: tabular-nums;
  339. @media (min-width: ${p => p.theme.breakpoints.small}) {
  340. text-align: right;
  341. }
  342. `;
  343. export const AdoptionColumn = styled(ReleaseProjectColumn)`
  344. display: none;
  345. font-variant-numeric: tabular-nums;
  346. @media (min-width: ${p => p.theme.breakpoints.small}) {
  347. display: flex;
  348. /* Chart tooltips need overflow */
  349. overflow: visible;
  350. }
  351. & > * {
  352. flex: 1;
  353. }
  354. `;
  355. export const AdoptionStageColumn = styled(ReleaseProjectColumn)`
  356. display: none;
  357. font-variant-numeric: tabular-nums;
  358. @media (min-width: ${p => p.theme.breakpoints.xlarge}) {
  359. display: flex;
  360. /* Need to show the edges of the tags */
  361. overflow: visible;
  362. }
  363. `;
  364. export const CrashFreeRateColumn = styled(ReleaseProjectColumn)`
  365. font-variant-numeric: tabular-nums;
  366. @media (min-width: ${p => p.theme.breakpoints.small}) {
  367. text-align: center;
  368. }
  369. @media (min-width: ${p => p.theme.breakpoints.xlarge}) {
  370. text-align: right;
  371. }
  372. `;
  373. export const DisplaySmallCol = styled(ReleaseProjectColumn)`
  374. display: none;
  375. font-variant-numeric: tabular-nums;
  376. @media (min-width: ${p => p.theme.breakpoints.small}) {
  377. display: block;
  378. text-align: right;
  379. }
  380. `;
  381. const HiddenProjectsMessage = styled('div')`
  382. display: flex;
  383. align-items: center;
  384. font-size: ${p => p.theme.fontSizeSmall};
  385. padding: 0 ${space(2)};
  386. border-top: 1px solid ${p => p.theme.border};
  387. overflow: hidden;
  388. height: 24px;
  389. line-height: 24px;
  390. color: ${p => p.theme.gray300};
  391. background-color: ${p => p.theme.backgroundSecondary};
  392. border-bottom-right-radius: ${p => p.theme.borderRadius};
  393. @media (max-width: ${p => p.theme.breakpoints.medium}) {
  394. border-bottom-left-radius: ${p => p.theme.borderRadius};
  395. }
  396. `;
  397. export default ReleaseCard;