index.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  1. import {Fragment, MouseEvent, useMemo} from 'react';
  2. import {browserHistory} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {Query} from 'history';
  5. import {bulkDelete, bulkUpdate} from 'sentry/actionCreators/group';
  6. import {addLoadingMessage, clearIndicators} from 'sentry/actionCreators/indicator';
  7. import {
  8. ModalRenderProps,
  9. openModal,
  10. openReprocessEventModal,
  11. } from 'sentry/actionCreators/modal';
  12. import {Client} from 'sentry/api';
  13. import Feature from 'sentry/components/acl/feature';
  14. import FeatureDisabled from 'sentry/components/acl/featureDisabled';
  15. import ArchiveActions, {getArchiveActions} from 'sentry/components/actions/archive';
  16. import ActionButton from 'sentry/components/actions/button';
  17. import IgnoreActions, {getIgnoreActions} from 'sentry/components/actions/ignore';
  18. import ResolveActions from 'sentry/components/actions/resolve';
  19. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  20. import {Button} from 'sentry/components/button';
  21. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  22. import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
  23. import {
  24. IconCheckmark,
  25. IconEllipsis,
  26. IconMute,
  27. IconSubscribed,
  28. IconUnsubscribed,
  29. } from 'sentry/icons';
  30. import {t} from 'sentry/locale';
  31. import GroupStore from 'sentry/stores/groupStore';
  32. import {space} from 'sentry/styles/space';
  33. import {
  34. Group,
  35. GroupStatus,
  36. GroupStatusResolution,
  37. GroupSubstatus,
  38. IssueCategory,
  39. MarkReviewed,
  40. Organization,
  41. Project,
  42. SavedQueryVersions,
  43. } from 'sentry/types';
  44. import {Event} from 'sentry/types/event';
  45. import {trackAnalytics} from 'sentry/utils/analytics';
  46. import {getUtcDateString} from 'sentry/utils/dates';
  47. import EventView from 'sentry/utils/discover/eventView';
  48. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  49. import {displayReprocessEventAction} from 'sentry/utils/displayReprocessEventAction';
  50. import {getAnalyticsDataForGroup} from 'sentry/utils/events';
  51. import {uniqueId} from 'sentry/utils/guid';
  52. import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig';
  53. import {getAnalyicsDataForProject} from 'sentry/utils/projects';
  54. import withApi from 'sentry/utils/withApi';
  55. import {normalizeUrl} from 'sentry/utils/withDomainRequired';
  56. import withOrganization from 'sentry/utils/withOrganization';
  57. import ShareIssueModal from './shareModal';
  58. import SubscribeAction from './subscribeAction';
  59. type UpdateData =
  60. | {isBookmarked: boolean}
  61. | {isSubscribed: boolean}
  62. | MarkReviewed
  63. | GroupStatusResolution;
  64. const isResolutionStatus = (data: UpdateData): data is GroupStatusResolution => {
  65. return (data as GroupStatusResolution).status !== undefined;
  66. };
  67. type Props = {
  68. api: Client;
  69. disabled: boolean;
  70. group: Group;
  71. organization: Organization;
  72. project: Project;
  73. event?: Event;
  74. query?: Query;
  75. };
  76. export function Actions(props: Props) {
  77. const {api, group, project, organization, disabled, event, query = {}} = props;
  78. const {status, isBookmarked} = group;
  79. const bookmarkKey = isBookmarked ? 'unbookmark' : 'bookmark';
  80. const bookmarkTitle = isBookmarked ? t('Remove bookmark') : t('Bookmark');
  81. const hasRelease = !!project.features?.includes('releases');
  82. const isResolved = status === 'resolved';
  83. const isAutoResolved =
  84. group.status === 'resolved' ? group.statusDetails.autoResolved : undefined;
  85. const isIgnored = status === 'ignored';
  86. const hasEscalatingIssues = organization.features.includes('escalating-issues');
  87. const hasDeleteAccess = organization.access.includes('event:admin');
  88. const config = useMemo(() => getConfigForIssueType(group), [group]);
  89. const {
  90. actions: {
  91. archiveUntilOccurrence: archiveUntilOccurrenceCap,
  92. delete: deleteCap,
  93. deleteAndDiscard: deleteDiscardCap,
  94. share: shareCap,
  95. resolveInRelease: resolveInReleaseCap,
  96. },
  97. discover: discoverCap,
  98. } = config;
  99. const getDiscoverUrl = () => {
  100. const {title, type, shortId} = group;
  101. const groupIsOccurrenceBacked =
  102. group.issueCategory === IssueCategory.PERFORMANCE && !!event?.occurrence;
  103. const discoverQuery = {
  104. id: undefined,
  105. name: title || type,
  106. fields: ['title', 'release', 'environment', 'user.display', 'timestamp'],
  107. orderby: '-timestamp',
  108. query: `issue:${shortId}`,
  109. projects: [Number(project.id)],
  110. version: 2 as SavedQueryVersions,
  111. range: '90d',
  112. dataset:
  113. config.usesIssuePlatform || groupIsOccurrenceBacked
  114. ? DiscoverDatasets.ISSUE_PLATFORM
  115. : undefined,
  116. };
  117. const discoverView = EventView.fromSavedQuery(discoverQuery);
  118. return discoverView.getResultsViewUrlTarget(organization.slug);
  119. };
  120. const trackIssueAction = (
  121. action:
  122. | 'shared'
  123. | 'deleted'
  124. | 'bookmarked'
  125. | 'subscribed'
  126. | 'mark_reviewed'
  127. | 'discarded'
  128. | 'open_in_discover'
  129. | GroupStatus,
  130. substatus?: GroupSubstatus | null,
  131. statusDetailsKey?: string
  132. ) => {
  133. const {alert_date, alert_rule_id, alert_type} = query;
  134. trackAnalytics('issue_details.action_clicked', {
  135. organization,
  136. action_type: action,
  137. action_substatus: substatus ?? undefined,
  138. action_status_details: statusDetailsKey,
  139. // Alert properties track if the user came from email/slack alerts
  140. alert_date:
  141. typeof alert_date === 'string' ? getUtcDateString(Number(alert_date)) : undefined,
  142. alert_rule_id: typeof alert_rule_id === 'string' ? alert_rule_id : undefined,
  143. alert_type: typeof alert_type === 'string' ? alert_type : undefined,
  144. ...getAnalyticsDataForGroup(group),
  145. ...getAnalyicsDataForProject(project),
  146. });
  147. };
  148. const onDelete = () => {
  149. addLoadingMessage(t('Delete event\u2026'));
  150. bulkDelete(
  151. api,
  152. {
  153. orgId: organization.slug,
  154. projectId: project.slug,
  155. itemIds: [group.id],
  156. },
  157. {
  158. complete: () => {
  159. clearIndicators();
  160. browserHistory.push(
  161. normalizeUrl({
  162. pathname: `/organizations/${organization.slug}/issues/`,
  163. query: {project: project.id},
  164. })
  165. );
  166. },
  167. }
  168. );
  169. trackIssueAction('deleted');
  170. };
  171. const onUpdate = (data: UpdateData) => {
  172. addLoadingMessage(t('Saving changes\u2026'));
  173. bulkUpdate(
  174. api,
  175. {
  176. orgId: organization.slug,
  177. projectId: project.slug,
  178. itemIds: [group.id],
  179. data,
  180. },
  181. {
  182. complete: clearIndicators,
  183. }
  184. );
  185. if (isResolutionStatus(data)) {
  186. trackIssueAction(
  187. data.status,
  188. data.substatus,
  189. Object.keys(data.statusDetails || {})[0]
  190. );
  191. }
  192. if ((data as {inbox: boolean}).inbox !== undefined) {
  193. trackIssueAction('mark_reviewed');
  194. }
  195. };
  196. const onReprocessEvent = () => {
  197. openReprocessEventModal({organization, groupId: group.id});
  198. };
  199. const onToggleShare = () => {
  200. const newIsPublic = !group.isPublic;
  201. if (newIsPublic) {
  202. trackAnalytics('issue.shared_publicly', {
  203. organization,
  204. });
  205. }
  206. trackIssueAction('shared');
  207. };
  208. const onToggleBookmark = () => {
  209. onUpdate({isBookmarked: !group.isBookmarked});
  210. trackIssueAction('bookmarked');
  211. };
  212. const onToggleSubscribe = () => {
  213. onUpdate({isSubscribed: !group.isSubscribed});
  214. trackIssueAction('subscribed');
  215. };
  216. const onDiscard = () => {
  217. const id = uniqueId();
  218. addLoadingMessage(t('Discarding event\u2026'));
  219. GroupStore.onDiscard(id, group.id);
  220. api.request(`/issues/${group.id}/`, {
  221. method: 'PUT',
  222. data: {discard: true},
  223. success: response => {
  224. GroupStore.onDiscardSuccess(id, group.id, response);
  225. browserHistory.push(
  226. normalizeUrl({
  227. pathname: `/organizations/${organization.slug}/issues/`,
  228. query: {project: project.id},
  229. })
  230. );
  231. },
  232. error: error => {
  233. GroupStore.onDiscardError(id, group.id, error);
  234. },
  235. complete: clearIndicators,
  236. });
  237. trackIssueAction('discarded');
  238. };
  239. const renderDiscardModal = ({Body, Footer, closeModal}: ModalRenderProps) => {
  240. function renderDiscardDisabled({children, ...innerProps}) {
  241. return children({
  242. ...innerProps,
  243. renderDisabled: ({features}: {features: string[]}) => (
  244. <FeatureDisabled
  245. alert
  246. featureName={t('Discard and Delete')}
  247. features={features}
  248. />
  249. ),
  250. });
  251. }
  252. return (
  253. <Feature
  254. features="projects:discard-groups"
  255. hookName="feature-disabled:discard-groups"
  256. organization={organization}
  257. project={project}
  258. renderDisabled={renderDiscardDisabled}
  259. >
  260. {({hasFeature, renderDisabled, ...innerProps}) => (
  261. <Fragment>
  262. <Body>
  263. {!hasFeature &&
  264. typeof renderDisabled === 'function' &&
  265. renderDisabled({...innerProps, hasFeature, children: null})}
  266. {t(
  267. `Discarding this event will result in the deletion of most data associated with this issue and future events being discarded before reaching your stream. Are you sure you wish to continue?`
  268. )}
  269. </Body>
  270. <Footer>
  271. <Button onClick={closeModal}>{t('Cancel')}</Button>
  272. <Button
  273. style={{marginLeft: space(1)}}
  274. priority="primary"
  275. onClick={onDiscard}
  276. disabled={!hasFeature}
  277. >
  278. {t('Discard Future Events')}
  279. </Button>
  280. </Footer>
  281. </Fragment>
  282. )}
  283. </Feature>
  284. );
  285. };
  286. const openDeleteModal = () =>
  287. openModal(({Body, Footer, closeModal}: ModalRenderProps) => (
  288. <Fragment>
  289. <Body>
  290. {t('Deleting this issue is permanent. Are you sure you wish to continue?')}
  291. </Body>
  292. <Footer>
  293. <Button onClick={closeModal}>{t('Cancel')}</Button>
  294. <Button style={{marginLeft: space(1)}} priority="primary" onClick={onDelete}>
  295. {t('Delete')}
  296. </Button>
  297. </Footer>
  298. </Fragment>
  299. ));
  300. const openDiscardModal = () => {
  301. openModal(renderDiscardModal);
  302. };
  303. const openShareModal = () => {
  304. openModal(modalProps => (
  305. <ShareIssueModal
  306. {...modalProps}
  307. organization={organization}
  308. projectSlug={group.project.slug}
  309. groupId={group.id}
  310. onToggle={onToggleShare}
  311. />
  312. ));
  313. };
  314. const handleClick = (onClick: (event?: MouseEvent) => void) => {
  315. return function (innerEvent: MouseEvent) {
  316. if (disabled) {
  317. innerEvent.preventDefault();
  318. innerEvent.stopPropagation();
  319. return;
  320. }
  321. onClick(innerEvent);
  322. };
  323. };
  324. const {dropdownItems, onIgnore} = getIgnoreActions({onUpdate});
  325. const {dropdownItems: archiveDropdownItems} = getArchiveActions({
  326. onUpdate,
  327. });
  328. return (
  329. <ActionWrapper>
  330. <DropdownMenu
  331. triggerProps={{
  332. 'aria-label': t('More Actions'),
  333. icon: <IconEllipsis size="xs" />,
  334. showChevron: false,
  335. size: 'sm',
  336. }}
  337. items={[
  338. ...(isIgnored || hasEscalatingIssues
  339. ? []
  340. : [
  341. {
  342. key: 'ignore',
  343. className: 'hidden-sm hidden-md hidden-lg',
  344. label: t('Ignore'),
  345. isSubmenu: true,
  346. disabled,
  347. children: [
  348. {
  349. key: 'ignore-now',
  350. label: t('Ignore Issue'),
  351. onAction: () => onIgnore(),
  352. },
  353. ...dropdownItems,
  354. ],
  355. },
  356. ]),
  357. ...(hasEscalatingIssues
  358. ? isIgnored
  359. ? []
  360. : [
  361. {
  362. key: 'Archive',
  363. className: 'hidden-sm hidden-md hidden-lg',
  364. label: t('Archive'),
  365. isSubmenu: true,
  366. disabled,
  367. children: archiveDropdownItems,
  368. },
  369. ]
  370. : []),
  371. {
  372. key: 'open-in-discover',
  373. className: 'hidden-sm hidden-md hidden-lg',
  374. label: t('Open in Discover'),
  375. to: disabled ? '' : getDiscoverUrl(),
  376. onAction: () => trackIssueAction('open_in_discover'),
  377. },
  378. {
  379. key: group.isSubscribed ? 'unsubscribe' : 'subscribe',
  380. className: 'hidden-sm hidden-md hidden-lg',
  381. label: group.isSubscribed ? t('Unsubscribe') : t('Subscribe'),
  382. disabled: disabled || group.subscriptionDetails?.disabled,
  383. onAction: onToggleSubscribe,
  384. },
  385. {
  386. key: 'mark-review',
  387. label: t('Mark reviewed'),
  388. disabled: !group.inbox || disabled,
  389. details: !group.inbox || disabled ? t('Issue has been reviewed') : undefined,
  390. onAction: () => onUpdate({inbox: false}),
  391. },
  392. {
  393. key: 'share',
  394. label: t('Share'),
  395. disabled: disabled || !shareCap.enabled,
  396. hidden: !organization.features.includes('shared-issues'),
  397. onAction: openShareModal,
  398. },
  399. {
  400. key: bookmarkKey,
  401. label: bookmarkTitle,
  402. onAction: onToggleBookmark,
  403. },
  404. {
  405. key: 'reprocess',
  406. label: t('Reprocess events'),
  407. hidden: !displayReprocessEventAction(organization.features, event),
  408. onAction: onReprocessEvent,
  409. },
  410. {
  411. key: 'delete-issue',
  412. priority: 'danger',
  413. label: t('Delete'),
  414. hidden: !hasDeleteAccess,
  415. disabled: !deleteCap.enabled,
  416. details: deleteCap.disabledReason,
  417. onAction: openDeleteModal,
  418. },
  419. {
  420. key: 'delete-and-discard',
  421. priority: 'danger',
  422. label: t('Delete and discard future events'),
  423. hidden: !hasDeleteAccess,
  424. disabled: !deleteDiscardCap.enabled,
  425. details: deleteDiscardCap.disabledReason,
  426. onAction: openDiscardModal,
  427. },
  428. ]}
  429. />
  430. <SubscribeAction
  431. className="hidden-xs"
  432. disabled={disabled}
  433. disablePriority
  434. group={group}
  435. onClick={handleClick(onToggleSubscribe)}
  436. icon={group.isSubscribed ? <IconSubscribed /> : <IconUnsubscribed />}
  437. size="sm"
  438. />
  439. <div className="hidden-xs">
  440. <EnvironmentPageFilter position="bottom-end" size="sm" />
  441. </div>
  442. {discoverCap.enabled && (
  443. <Feature
  444. hookName="feature-disabled:open-in-discover"
  445. features="discover-basic"
  446. organization={organization}
  447. >
  448. <ActionButton
  449. className="hidden-xs"
  450. disabled={disabled}
  451. to={disabled ? '' : getDiscoverUrl()}
  452. onClick={() => trackIssueAction('open_in_discover')}
  453. size="sm"
  454. >
  455. <GuideAnchor target="open_in_discover">{t('Open in Discover')}</GuideAnchor>
  456. </ActionButton>
  457. </Feature>
  458. )}
  459. {isResolved || isIgnored ? (
  460. <ActionButton
  461. priority="primary"
  462. title={
  463. isAutoResolved
  464. ? t(
  465. 'This event is resolved due to the Auto Resolve configuration for this project'
  466. )
  467. : t('Change status to unresolved')
  468. }
  469. size="sm"
  470. icon={
  471. hasEscalatingIssues ? null : isResolved ? <IconCheckmark /> : <IconMute />
  472. }
  473. disabled={disabled || isAutoResolved}
  474. onClick={() =>
  475. onUpdate({
  476. status: GroupStatus.UNRESOLVED,
  477. statusDetails: {},
  478. substatus: GroupSubstatus.ONGOING,
  479. })
  480. }
  481. >
  482. {isIgnored
  483. ? hasEscalatingIssues
  484. ? t('Archived')
  485. : t('Ignored')
  486. : t('Resolved')}
  487. </ActionButton>
  488. ) : (
  489. <Fragment>
  490. {hasEscalatingIssues ? (
  491. <GuideAnchor target="issue_details_archive_button" position="bottom">
  492. <ArchiveActions
  493. className="hidden-xs"
  494. size="sm"
  495. isArchived={isIgnored}
  496. onUpdate={onUpdate}
  497. disabled={disabled}
  498. disableArchiveUntilOccurrence={!archiveUntilOccurrenceCap.enabled}
  499. />
  500. </GuideAnchor>
  501. ) : (
  502. <IgnoreActions
  503. className="hidden-xs"
  504. isIgnored={isIgnored}
  505. onUpdate={onUpdate}
  506. disabled={disabled}
  507. size="sm"
  508. />
  509. )}
  510. <GuideAnchor target="resolve" position="bottom" offset={20}>
  511. <ResolveActions
  512. disableResolveInRelease={!resolveInReleaseCap.enabled}
  513. disabled={disabled}
  514. disableDropdown={disabled}
  515. hasRelease={hasRelease}
  516. latestRelease={project.latestRelease}
  517. onUpdate={onUpdate}
  518. projectSlug={project.slug}
  519. isResolved={isResolved}
  520. isAutoResolved={isAutoResolved}
  521. size="sm"
  522. priority="primary"
  523. />
  524. </GuideAnchor>
  525. </Fragment>
  526. )}
  527. </ActionWrapper>
  528. );
  529. }
  530. const ActionWrapper = styled('div')`
  531. display: flex;
  532. align-items: center;
  533. gap: ${space(0.5)};
  534. `;
  535. export default withApi(withOrganization(Actions));