releaseActions.tsx 6.6 KB

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