resolve.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. import {css} from '@emotion/react';
  2. import styled from '@emotion/styled';
  3. import {openModal} from 'sentry/actionCreators/modal';
  4. import {Button, LinkButton} from 'sentry/components/button';
  5. import ButtonBar from 'sentry/components/buttonBar';
  6. import {openConfirmModal} from 'sentry/components/confirm';
  7. import CustomCommitsResolutionModal from 'sentry/components/customCommitsResolutionModal';
  8. import CustomResolutionModal from 'sentry/components/customResolutionModal';
  9. import {DropdownMenu, MenuItemProps} from 'sentry/components/dropdownMenu';
  10. import {Tooltip} from 'sentry/components/tooltip';
  11. import {IconChevron, IconReleases} from 'sentry/icons';
  12. import {t} from 'sentry/locale';
  13. import {space} from 'sentry/styles/space';
  14. import {
  15. GroupStatus,
  16. GroupStatusResolution,
  17. GroupSubstatus,
  18. Project,
  19. ResolvedStatusDetails,
  20. } from 'sentry/types';
  21. import {trackAnalytics} from 'sentry/utils/analytics';
  22. import {formatVersion, isSemverRelease} from 'sentry/utils/formatters';
  23. import useOrganization from 'sentry/utils/useOrganization';
  24. function SetupReleasesPrompt() {
  25. return (
  26. <SetupReleases>
  27. <IconReleases size="xl" />
  28. <div>
  29. <SetupReleasesHeader>
  30. {t('Resolving is better with Releases')}
  31. </SetupReleasesHeader>
  32. {t(
  33. 'Set up Releases so Sentry can bother you when this problem comes back in a future release.'
  34. )}
  35. </div>
  36. <LinkButton
  37. priority="primary"
  38. external
  39. size="xs"
  40. href="https://docs.sentry.io/product/releases/setup/"
  41. analyticsEventName="Issue Actions: Resolve Release Setup Prompt Clicked"
  42. analyticsEventKey="issue_actions.resolve_release_setup_prompt_clicked"
  43. >
  44. {t('Set up Releases Now')}
  45. </LinkButton>
  46. </SetupReleases>
  47. );
  48. }
  49. export interface ResolveActionsProps {
  50. hasRelease: boolean;
  51. onUpdate: (data: GroupStatusResolution) => void;
  52. confirmLabel?: string;
  53. confirmMessage?: React.ReactNode;
  54. disableDropdown?: boolean;
  55. disableResolveInRelease?: boolean;
  56. disabled?: boolean;
  57. isAutoResolved?: boolean;
  58. isResolved?: boolean;
  59. latestRelease?: Project['latestRelease'];
  60. multipleProjectsSelected?: boolean;
  61. priority?: 'primary';
  62. projectFetchError?: boolean;
  63. projectSlug?: string;
  64. shouldConfirm?: boolean;
  65. size?: 'xs' | 'sm';
  66. }
  67. function ResolveActions({
  68. size = 'xs',
  69. isResolved = false,
  70. isAutoResolved = false,
  71. confirmLabel = t('Resolve'),
  72. projectSlug,
  73. hasRelease,
  74. latestRelease,
  75. confirmMessage,
  76. shouldConfirm,
  77. disabled,
  78. disableDropdown,
  79. disableResolveInRelease,
  80. priority,
  81. projectFetchError,
  82. multipleProjectsSelected,
  83. onUpdate,
  84. }: ResolveActionsProps) {
  85. const organization = useOrganization();
  86. function handleCommitResolution(statusDetails: ResolvedStatusDetails) {
  87. onUpdate({
  88. status: GroupStatus.RESOLVED,
  89. statusDetails,
  90. substatus: null,
  91. });
  92. }
  93. function handleAnotherExistingReleaseResolution(statusDetails: ResolvedStatusDetails) {
  94. onUpdate({
  95. status: GroupStatus.RESOLVED,
  96. statusDetails,
  97. substatus: null,
  98. });
  99. trackAnalytics('resolve_issue', {
  100. organization,
  101. release: 'anotherExisting',
  102. });
  103. }
  104. function handleCurrentReleaseResolution() {
  105. if (hasRelease) {
  106. onUpdate({
  107. status: GroupStatus.RESOLVED,
  108. statusDetails: {
  109. inRelease: latestRelease ? latestRelease.version : 'latest',
  110. },
  111. substatus: null,
  112. });
  113. }
  114. trackAnalytics('resolve_issue', {
  115. organization,
  116. release: 'current',
  117. });
  118. }
  119. function handleNextReleaseResolution() {
  120. if (hasRelease) {
  121. onUpdate({
  122. status: GroupStatus.RESOLVED,
  123. statusDetails: {
  124. inNextRelease: true,
  125. },
  126. substatus: null,
  127. });
  128. }
  129. trackAnalytics('resolve_issue', {
  130. organization,
  131. release: 'next',
  132. });
  133. }
  134. function renderResolved() {
  135. return (
  136. <Tooltip
  137. title={
  138. isAutoResolved
  139. ? t(
  140. 'This event is resolved due to the Auto Resolve configuration for this project'
  141. )
  142. : t('Unresolve')
  143. }
  144. >
  145. <Button
  146. priority="primary"
  147. size="xs"
  148. aria-label={t('Unresolve')}
  149. disabled={isAutoResolved}
  150. onClick={() =>
  151. onUpdate({
  152. status: GroupStatus.UNRESOLVED,
  153. statusDetails: {},
  154. substatus: GroupSubstatus.ONGOING,
  155. })
  156. }
  157. />
  158. </Tooltip>
  159. );
  160. }
  161. function renderDropdownMenu() {
  162. if (isResolved) {
  163. return renderResolved();
  164. }
  165. const shouldDisplayCta = !hasRelease && !multipleProjectsSelected;
  166. const actionTitle = shouldDisplayCta
  167. ? t('Set up release tracking in order to use this feature.')
  168. : '';
  169. const onActionOrConfirm = (onAction: () => void) => {
  170. openConfirmModal({
  171. bypass: !shouldConfirm,
  172. onConfirm: onAction,
  173. message: confirmMessage,
  174. confirmText: confirmLabel,
  175. });
  176. };
  177. const isSemver = latestRelease ? isSemverRelease(latestRelease.version) : false;
  178. const items: MenuItemProps[] = [
  179. {
  180. key: 'next-release',
  181. label: t('The next release'),
  182. details: actionTitle,
  183. onAction: () => onActionOrConfirm(handleNextReleaseResolution),
  184. },
  185. {
  186. key: 'current-release',
  187. label: t('The current release'),
  188. details: actionTitle
  189. ? actionTitle
  190. : latestRelease
  191. ? `${formatVersion(latestRelease.version)} (${
  192. isSemver ? t('semver') : t('non-semver')
  193. })`
  194. : null,
  195. onAction: () => onActionOrConfirm(handleCurrentReleaseResolution),
  196. },
  197. {
  198. key: 'another-release',
  199. label: t('Another existing release\u2026'),
  200. onAction: () => openCustomReleaseModal(),
  201. },
  202. {
  203. key: 'a-commit',
  204. label: t('A commit\u2026'),
  205. onAction: () => openCustomCommitModal(),
  206. },
  207. ];
  208. const isDisabled = !projectSlug ? disabled : disableDropdown;
  209. return (
  210. <StyledDropdownMenu
  211. itemsHidden={shouldDisplayCta}
  212. items={items}
  213. trigger={triggerProps => (
  214. <DropdownTrigger
  215. {...triggerProps}
  216. size={size}
  217. priority={priority}
  218. aria-label={t('More resolve options')}
  219. icon={<IconChevron direction="down" size="xs" />}
  220. disabled={isDisabled}
  221. />
  222. )}
  223. disabledKeys={
  224. multipleProjectsSelected
  225. ? ['next-release', 'current-release', 'another-release', 'a-commit']
  226. : disabled || !hasRelease
  227. ? ['next-release', 'current-release', 'another-release']
  228. : []
  229. }
  230. menuTitle={shouldDisplayCta ? <SetupReleasesPrompt /> : t('Resolved In')}
  231. isDisabled={isDisabled}
  232. />
  233. );
  234. }
  235. function openCustomCommitModal() {
  236. openModal(deps => (
  237. <CustomCommitsResolutionModal
  238. {...deps}
  239. onSelected={(statusDetails: ResolvedStatusDetails) =>
  240. handleCommitResolution(statusDetails)
  241. }
  242. orgSlug={organization.slug}
  243. projectSlug={projectSlug}
  244. />
  245. ));
  246. }
  247. function openCustomReleaseModal() {
  248. openModal(deps => (
  249. <CustomResolutionModal
  250. {...deps}
  251. onSelected={(statusDetails: ResolvedStatusDetails) =>
  252. handleAnotherExistingReleaseResolution(statusDetails)
  253. }
  254. organization={organization}
  255. projectSlug={projectSlug}
  256. />
  257. ));
  258. }
  259. if (isResolved) {
  260. return renderResolved();
  261. }
  262. return (
  263. <Tooltip disabled={!projectFetchError} title={t('Error fetching project')}>
  264. <ButtonBar merged>
  265. <ResolveButton
  266. priority={priority}
  267. size={size}
  268. title={t("We'll nag you with a notification if another event is seen.")}
  269. tooltipProps={{delay: 1000, disabled}}
  270. onClick={() =>
  271. openConfirmModal({
  272. bypass: !shouldConfirm,
  273. onConfirm: () =>
  274. onUpdate({
  275. status: GroupStatus.RESOLVED,
  276. statusDetails: {},
  277. substatus: null,
  278. }),
  279. message: confirmMessage,
  280. confirmText: confirmLabel,
  281. })
  282. }
  283. disabled={disabled}
  284. >
  285. {t('Resolve')}
  286. </ResolveButton>
  287. {!disableResolveInRelease && renderDropdownMenu()}
  288. </ButtonBar>
  289. </Tooltip>
  290. );
  291. }
  292. export default ResolveActions;
  293. const ResolveButton = styled(Button)<{priority?: 'primary'}>`
  294. box-shadow: none;
  295. ${p =>
  296. p.priority === 'primary' &&
  297. css`
  298. &::after {
  299. content: '';
  300. position: absolute;
  301. top: -1px;
  302. bottom: -1px;
  303. right: -1px;
  304. border-right: solid 1px currentColor;
  305. opacity: 0.25;
  306. }
  307. `}
  308. `;
  309. const DropdownTrigger = styled(Button)`
  310. box-shadow: none;
  311. border-radius: ${p => p.theme.borderRadiusRight};
  312. border-left: none;
  313. `;
  314. /**
  315. * Used to hide the list items when prompting to set up releases
  316. */
  317. const StyledDropdownMenu = styled(DropdownMenu)<{itemsHidden: boolean}>`
  318. ${p =>
  319. p.itemsHidden &&
  320. css`
  321. ul {
  322. display: none;
  323. }
  324. `}
  325. `;
  326. const SetupReleases = styled('div')`
  327. display: flex;
  328. flex-direction: column;
  329. gap: ${space(2)};
  330. align-items: center;
  331. padding: ${space(2)} 0;
  332. text-align: center;
  333. color: ${p => p.theme.gray400};
  334. width: 250px;
  335. white-space: normal;
  336. font-weight: normal;
  337. `;
  338. const SetupReleasesHeader = styled('h6')`
  339. font-size: ${p => p.theme.fontSizeMedium};
  340. margin-bottom: ${space(1)};
  341. `;