index.tsx 12 KB

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