releaseActions.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {Location} from 'history';
  4. import {archiveRelease, restoreRelease} from 'sentry/actionCreators/release';
  5. import {Client} from 'sentry/api';
  6. import {LinkButton} from 'sentry/components/button';
  7. import ButtonBar from 'sentry/components/buttonBar';
  8. import {openConfirmModal} from 'sentry/components/confirm';
  9. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  10. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  11. import TextOverflow from 'sentry/components/textOverflow';
  12. import {Tooltip} from 'sentry/components/tooltip';
  13. import {IconEllipsis, IconNext, IconPrevious} from 'sentry/icons';
  14. import {t, tct, tn} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import type {Organization} from 'sentry/types/organization';
  17. import type {Release, ReleaseMeta} from 'sentry/types/release';
  18. import {trackAnalytics} from 'sentry/utils/analytics';
  19. import {browserHistory} from 'sentry/utils/browserHistory';
  20. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  21. import {formatVersion} from 'sentry/utils/versions/formatVersion';
  22. import {isReleaseArchived} from '../../utils';
  23. type Props = {
  24. location: Location;
  25. organization: Organization;
  26. projectSlug: string;
  27. refetchData: () => void;
  28. release: Release;
  29. releaseMeta: ReleaseMeta;
  30. };
  31. function ReleaseActions({
  32. location,
  33. organization,
  34. projectSlug,
  35. release,
  36. releaseMeta,
  37. refetchData,
  38. }: Props) {
  39. async function handleArchive() {
  40. try {
  41. await archiveRelease(new Client(), {
  42. orgSlug: organization.slug,
  43. projectSlug,
  44. releaseVersion: release.version,
  45. });
  46. browserHistory.push(normalizeUrl(`/organizations/${organization.slug}/releases/`));
  47. } catch {
  48. // do nothing, action creator is already displaying error message
  49. }
  50. }
  51. async function handleRestore() {
  52. try {
  53. await restoreRelease(new Client(), {
  54. orgSlug: organization.slug,
  55. projectSlug,
  56. releaseVersion: release.version,
  57. });
  58. refetchData();
  59. } catch {
  60. // do nothing, action creator is already displaying error message
  61. }
  62. }
  63. function getProjectList() {
  64. const maxVisibleProjects = 5;
  65. const visibleProjects = releaseMeta.projects.slice(0, maxVisibleProjects);
  66. const numberOfCollapsedProjects =
  67. releaseMeta.projects.length - visibleProjects.length;
  68. return (
  69. <Fragment>
  70. {visibleProjects.map(project => (
  71. <ProjectBadge key={project.slug} project={project} avatarSize={18} />
  72. ))}
  73. {numberOfCollapsedProjects > 0 && (
  74. <span>
  75. <Tooltip
  76. title={release.projects
  77. .slice(maxVisibleProjects)
  78. .map(p => p.slug)
  79. .join(', ')}
  80. >
  81. + {tn('%s other project', '%s other projects', numberOfCollapsedProjects)}
  82. </Tooltip>
  83. </span>
  84. )}
  85. </Fragment>
  86. );
  87. }
  88. function getModalHeader(title: React.ReactNode) {
  89. return (
  90. <h4>
  91. <TextOverflow>{title}</TextOverflow>
  92. </h4>
  93. );
  94. }
  95. function getModalMessage(message: React.ReactNode) {
  96. return (
  97. <Fragment>
  98. {message}
  99. <ProjectsWrapper>{getProjectList()}</ProjectsWrapper>
  100. {t('Are you sure you want to do this?')}
  101. </Fragment>
  102. );
  103. }
  104. function replaceReleaseUrl(toRelease: string | null) {
  105. return toRelease
  106. ? {
  107. pathname: location.pathname
  108. .replace(encodeURIComponent(release.version), encodeURIComponent(toRelease))
  109. .replace(release.version, encodeURIComponent(toRelease)),
  110. query: {...location.query, activeRepo: undefined},
  111. }
  112. : '';
  113. }
  114. function handleNavigationClick(direction: string) {
  115. trackAnalytics(`release_detail.pagination`, {
  116. organization,
  117. direction,
  118. });
  119. }
  120. const menuItems = [
  121. isReleaseArchived(release)
  122. ? {
  123. key: 'restore',
  124. label: t('Restore'),
  125. onAction: () =>
  126. openConfirmModal({
  127. onConfirm: handleRestore,
  128. header: getModalHeader(
  129. tct('Restore Release [release]', {
  130. release: formatVersion(release.version),
  131. })
  132. ),
  133. message: getModalMessage(
  134. tn(
  135. 'You are restoring this release for the following project:',
  136. 'By restoring this release, you are also restoring it for the following projects:',
  137. releaseMeta.projects.length
  138. )
  139. ),
  140. cancelText: t('Nevermind'),
  141. confirmText: t('Restore'),
  142. }),
  143. }
  144. : {
  145. key: 'archive',
  146. label: t('Archive'),
  147. onAction: () =>
  148. openConfirmModal({
  149. onConfirm: handleArchive,
  150. header: getModalHeader(
  151. tct('Archive Release [release]', {
  152. release: formatVersion(release.version),
  153. })
  154. ),
  155. message: getModalMessage(
  156. tn(
  157. 'You are archiving this release for the following project:',
  158. 'By archiving this release, you are also archiving it for the following projects:',
  159. releaseMeta.projects.length
  160. )
  161. ),
  162. cancelText: t('Nevermind'),
  163. confirmText: t('Archive'),
  164. }),
  165. },
  166. ];
  167. const hasPrevious = !!release.currentProjectMeta.prevReleaseVersion;
  168. const hasNext = !!release.currentProjectMeta.nextReleaseVersion;
  169. return (
  170. <ButtonBar gap={1}>
  171. <ButtonBar merged>
  172. <LinkButton
  173. size="sm"
  174. to={replaceReleaseUrl(release.currentProjectMeta.firstReleaseVersion)}
  175. disabled={!hasPrevious}
  176. aria-label={t('Oldest')}
  177. icon={<IconPrevious />}
  178. onClick={() => handleNavigationClick('oldest')}
  179. />
  180. <LinkButton
  181. size="sm"
  182. to={replaceReleaseUrl(release.currentProjectMeta.prevReleaseVersion)}
  183. disabled={!hasPrevious}
  184. onClick={() => handleNavigationClick('older')}
  185. >
  186. {t('Older')}
  187. </LinkButton>
  188. <LinkButton
  189. size="sm"
  190. to={replaceReleaseUrl(release.currentProjectMeta.nextReleaseVersion)}
  191. disabled={!hasNext}
  192. onClick={() => handleNavigationClick('newer')}
  193. >
  194. {t('Newer')}
  195. </LinkButton>
  196. <LinkButton
  197. size="sm"
  198. to={replaceReleaseUrl(release.currentProjectMeta.lastReleaseVersion)}
  199. disabled={!hasNext}
  200. aria-label={t('Newest')}
  201. icon={<IconNext />}
  202. onClick={() => handleNavigationClick('newest')}
  203. />
  204. </ButtonBar>
  205. <DropdownMenu
  206. size="sm"
  207. items={menuItems}
  208. triggerProps={{
  209. showChevron: false,
  210. icon: <IconEllipsis />,
  211. 'aria-label': t('Actions'),
  212. }}
  213. position="bottom-end"
  214. />
  215. </ButtonBar>
  216. );
  217. }
  218. const ProjectsWrapper = styled('div')`
  219. margin: ${space(2)} 0 ${space(2)} ${space(2)};
  220. display: grid;
  221. gap: ${space(0.5)};
  222. img {
  223. border: none !important;
  224. box-shadow: none !important;
  225. }
  226. `;
  227. export default ReleaseActions;