index.tsx 19 KB

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