index.tsx 13 KB

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