index.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  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 {
  7. addErrorMessage,
  8. addLoadingMessage,
  9. clearIndicators,
  10. } from 'sentry/actionCreators/indicator';
  11. import {
  12. ModalRenderProps,
  13. openModal,
  14. openReprocessEventModal,
  15. } from 'sentry/actionCreators/modal';
  16. import {Client} from 'sentry/api';
  17. import Access from 'sentry/components/acl/access';
  18. import Feature from 'sentry/components/acl/feature';
  19. import FeatureDisabled from 'sentry/components/acl/featureDisabled';
  20. import ActionButton from 'sentry/components/actions/button';
  21. import IgnoreActions from 'sentry/components/actions/ignore';
  22. import ResolveActions from 'sentry/components/actions/resolve';
  23. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  24. import Button from 'sentry/components/button';
  25. import DropdownMenuControl from 'sentry/components/dropdownMenuControl';
  26. import {IconEllipsis} from 'sentry/icons';
  27. import {t} from 'sentry/locale';
  28. import GroupStore from 'sentry/stores/groupStore';
  29. import space from 'sentry/styles/space';
  30. import {
  31. Group,
  32. GroupStatusResolution,
  33. Organization,
  34. Project,
  35. ResolutionStatus,
  36. SavedQueryVersions,
  37. } from 'sentry/types';
  38. import {Event} from 'sentry/types/event';
  39. import {analytics} from 'sentry/utils/analytics';
  40. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  41. import {getUtcDateString} from 'sentry/utils/dates';
  42. import EventView from 'sentry/utils/discover/eventView';
  43. import {displayReprocessEventAction} from 'sentry/utils/displayReprocessEventAction';
  44. import {getIssueCapability} from 'sentry/utils/groupCapabilities';
  45. import {uniqueId} from 'sentry/utils/guid';
  46. import withApi from 'sentry/utils/withApi';
  47. import withOrganization from 'sentry/utils/withOrganization';
  48. import ReviewAction from 'sentry/views/issueList/actions/reviewAction';
  49. import ShareIssue from 'sentry/views/organizationGroupDetails/actions/shareIssue';
  50. import SubscribeAction from './subscribeAction';
  51. type Props = {
  52. api: Client;
  53. disabled: boolean;
  54. group: Group;
  55. organization: Organization;
  56. project: Project;
  57. event?: Event;
  58. query?: Query;
  59. };
  60. type State = {
  61. shareBusy: boolean;
  62. };
  63. class Actions extends Component<Props, State> {
  64. state: State = {
  65. shareBusy: false,
  66. };
  67. componentWillReceiveProps(nextProps: Props) {
  68. if (this.state.shareBusy && nextProps.group.shareId !== this.props.group.shareId) {
  69. this.setState({shareBusy: false});
  70. }
  71. }
  72. getShareUrl(shareId: string) {
  73. if (!shareId) {
  74. return '';
  75. }
  76. const path = `/share/issue/${shareId}/`;
  77. const {host, protocol} = window.location;
  78. return `${protocol}//${host}${path}`;
  79. }
  80. getDiscoverUrl() {
  81. const {group, project, organization} = this.props;
  82. const {title, type, shortId} = group;
  83. const discoverQuery = {
  84. id: undefined,
  85. name: title || type,
  86. fields: ['title', 'release', 'environment', 'user.display', 'timestamp'],
  87. orderby: '-timestamp',
  88. query: `issue:${shortId}`,
  89. projects: [Number(project.id)],
  90. version: 2 as SavedQueryVersions,
  91. range: '90d',
  92. };
  93. const discoverView = EventView.fromSavedQuery(discoverQuery);
  94. return discoverView.getResultsViewUrlTarget(organization.slug);
  95. }
  96. trackIssueAction(
  97. action:
  98. | 'shared'
  99. | 'deleted'
  100. | 'bookmarked'
  101. | 'subscribed'
  102. | 'mark_reviewed'
  103. | 'discarded'
  104. | 'open_in_discover'
  105. | ResolutionStatus
  106. ) {
  107. const {group, project, organization, query = {}} = this.props;
  108. const {alert_date, alert_rule_id, alert_type} = query;
  109. trackAdvancedAnalyticsEvent('issue_details.action_clicked', {
  110. organization,
  111. project_id: parseInt(project.id, 10),
  112. group_id: parseInt(group.id, 10),
  113. issue_category: group.issueCategory,
  114. action_type: action,
  115. // Alert properties track if the user came from email/slack alerts
  116. alert_date:
  117. typeof alert_date === 'string' ? getUtcDateString(Number(alert_date)) : undefined,
  118. alert_rule_id: typeof alert_rule_id === 'string' ? alert_rule_id : undefined,
  119. alert_type: typeof alert_type === 'string' ? alert_type : undefined,
  120. });
  121. }
  122. onDelete = () => {
  123. const {group, project, organization, api} = this.props;
  124. addLoadingMessage(t('Delete event\u2026'));
  125. bulkDelete(
  126. api,
  127. {
  128. orgId: organization.slug,
  129. projectId: project.slug,
  130. itemIds: [group.id],
  131. },
  132. {
  133. complete: () => {
  134. clearIndicators();
  135. browserHistory.push(`/${organization.slug}/${project.slug}/`);
  136. },
  137. }
  138. );
  139. this.trackIssueAction('deleted');
  140. };
  141. onUpdate = (
  142. data:
  143. | {isBookmarked: boolean}
  144. | {isSubscribed: boolean}
  145. | {inbox: boolean}
  146. | GroupStatusResolution
  147. ) => {
  148. const {group, project, organization, api} = this.props;
  149. addLoadingMessage(t('Saving changes\u2026'));
  150. bulkUpdate(
  151. api,
  152. {
  153. orgId: organization.slug,
  154. projectId: project.slug,
  155. itemIds: [group.id],
  156. data,
  157. },
  158. {
  159. complete: clearIndicators,
  160. }
  161. );
  162. if ((data as GroupStatusResolution).status) {
  163. this.trackIssueAction((data as GroupStatusResolution).status);
  164. }
  165. if ((data as {inbox: boolean}).inbox !== undefined) {
  166. this.trackIssueAction('mark_reviewed');
  167. }
  168. };
  169. onReprocessEvent = () => {
  170. const {group, organization} = this.props;
  171. openReprocessEventModal({organization, groupId: group.id});
  172. };
  173. onShare(shared: boolean) {
  174. const {group, project, organization, api} = this.props;
  175. this.setState({shareBusy: true});
  176. // not sure why this is a bulkUpdate
  177. bulkUpdate(
  178. api,
  179. {
  180. orgId: organization.slug,
  181. projectId: project.slug,
  182. itemIds: [group.id],
  183. data: {
  184. isPublic: shared,
  185. },
  186. },
  187. {
  188. error: () => {
  189. addErrorMessage(t('Error sharing'));
  190. },
  191. complete: () => {
  192. // shareBusy marked false in componentWillReceiveProps to sync
  193. // busy state update with shareId update
  194. },
  195. }
  196. );
  197. this.trackIssueAction('shared');
  198. }
  199. onToggleShare = () => {
  200. const newIsPublic = !this.props.group.isPublic;
  201. if (newIsPublic) {
  202. trackAdvancedAnalyticsEvent('issue.shared_publicly', {
  203. organization: this.props.organization,
  204. });
  205. }
  206. this.onShare(newIsPublic);
  207. };
  208. onToggleBookmark = () => {
  209. this.onUpdate({isBookmarked: !this.props.group.isBookmarked});
  210. this.trackIssueAction('bookmarked');
  211. };
  212. onToggleSubscribe = () => {
  213. this.onUpdate({isSubscribed: !this.props.group.isSubscribed});
  214. this.trackIssueAction('subscribed');
  215. };
  216. onDiscard = () => {
  217. const {group, project, organization, api} = this.props;
  218. const id = uniqueId();
  219. addLoadingMessage(t('Discarding event\u2026'));
  220. GroupStore.onDiscard(id, group.id);
  221. api.request(`/issues/${group.id}/`, {
  222. method: 'PUT',
  223. data: {discard: true},
  224. success: response => {
  225. GroupStore.onDiscardSuccess(id, group.id, response);
  226. browserHistory.push(`/${organization.slug}/${project.slug}/`);
  227. },
  228. error: error => {
  229. GroupStore.onDiscardError(id, group.id, error);
  230. },
  231. complete: clearIndicators,
  232. });
  233. this.trackIssueAction('discarded');
  234. };
  235. renderDiscardModal = ({Body, Footer, closeModal}: ModalRenderProps) => {
  236. const {organization, project} = this.props;
  237. function renderDiscardDisabled({children, ...props}) {
  238. return children({
  239. ...props,
  240. renderDisabled: ({features}: {features: string[]}) => (
  241. <FeatureDisabled
  242. alert
  243. featureName={t('Discard and Delete')}
  244. features={features}
  245. />
  246. ),
  247. });
  248. }
  249. return (
  250. <Feature
  251. features={['projects:discard-groups']}
  252. hookName="feature-disabled:discard-groups"
  253. organization={organization}
  254. project={project}
  255. renderDisabled={renderDiscardDisabled}
  256. >
  257. {({hasFeature, renderDisabled, ...props}) => (
  258. <Fragment>
  259. <Body>
  260. {!hasFeature &&
  261. typeof renderDisabled === 'function' &&
  262. renderDisabled({...props, hasFeature, children: null})}
  263. {t(
  264. `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?`
  265. )}
  266. </Body>
  267. <Footer>
  268. <Button onClick={closeModal}>{t('Cancel')}</Button>
  269. <Button
  270. style={{marginLeft: space(1)}}
  271. priority="primary"
  272. onClick={this.onDiscard}
  273. disabled={!hasFeature}
  274. >
  275. {t('Discard Future Events')}
  276. </Button>
  277. </Footer>
  278. </Fragment>
  279. )}
  280. </Feature>
  281. );
  282. };
  283. openDeleteModal = () =>
  284. openModal(({Body, Footer, closeModal}: ModalRenderProps) => (
  285. <Fragment>
  286. <Body>
  287. {t('Deleting this issue is permanent. Are you sure you wish to continue?')}
  288. </Body>
  289. <Footer>
  290. <Button onClick={closeModal}>{t('Cancel')}</Button>
  291. <Button
  292. style={{marginLeft: space(1)}}
  293. priority="primary"
  294. onClick={this.onDelete}
  295. >
  296. {t('Delete')}
  297. </Button>
  298. </Footer>
  299. </Fragment>
  300. ));
  301. openDiscardModal = () => {
  302. const {organization} = this.props;
  303. openModal(this.renderDiscardModal);
  304. analytics('feature.discard_group.modal_opened', {
  305. org_id: parseInt(organization.id, 10),
  306. });
  307. };
  308. handleClick(disabled: boolean, onClick: (event?: MouseEvent) => void) {
  309. return function (event: MouseEvent) {
  310. if (disabled) {
  311. event.preventDefault();
  312. event.stopPropagation();
  313. return;
  314. }
  315. onClick(event);
  316. };
  317. }
  318. render() {
  319. const {group, project, organization, disabled, event} = this.props;
  320. const {status, isBookmarked} = group;
  321. const orgFeatures = new Set(organization.features);
  322. const bookmarkKey = isBookmarked ? 'unbookmark' : 'bookmark';
  323. const bookmarkTitle = isBookmarked ? t('Remove bookmark') : t('Bookmark');
  324. const hasRelease = !!project.features?.includes('releases');
  325. const isResolved = status === 'resolved';
  326. const isIgnored = status === 'ignored';
  327. const deleteCap = getIssueCapability(group.issueCategory, 'delete');
  328. const deleteDiscardCap = getIssueCapability(group.issueCategory, 'deleteAndDiscard');
  329. const shareCap = getIssueCapability(group.issueCategory, 'share');
  330. return (
  331. <Wrapper>
  332. <GuideAnchor target="resolve" position="bottom" offset={20}>
  333. <ResolveActions
  334. disabled={disabled}
  335. disableDropdown={disabled}
  336. hasRelease={hasRelease}
  337. latestRelease={project.latestRelease}
  338. onUpdate={this.onUpdate}
  339. orgSlug={organization.slug}
  340. projectSlug={project.slug}
  341. isResolved={isResolved}
  342. isAutoResolved={
  343. group.status === 'resolved' ? group.statusDetails.autoResolved : undefined
  344. }
  345. />
  346. </GuideAnchor>
  347. <GuideAnchor target="ignore_delete_discard" position="bottom" offset={20}>
  348. <IgnoreActions
  349. issueCategory={group.issueCategory}
  350. isIgnored={isIgnored}
  351. onUpdate={this.onUpdate}
  352. disabled={disabled}
  353. />
  354. </GuideAnchor>
  355. <ReviewAction
  356. onUpdate={this.onUpdate}
  357. disabled={!group.inbox || disabled}
  358. tooltip={t('Issue has been reviewed')}
  359. tooltipProps={{disabled: !!group.inbox || disabled, delay: 300}}
  360. />
  361. <Feature
  362. hookName="feature-disabled:open-in-discover"
  363. features={['discover-basic']}
  364. organization={organization}
  365. >
  366. <ActionButton
  367. disabled={disabled}
  368. to={disabled ? '' : this.getDiscoverUrl()}
  369. onClick={() => this.trackIssueAction('open_in_discover')}
  370. >
  371. <GuideAnchor target="open_in_discover">{t('Open in Discover')}</GuideAnchor>
  372. </ActionButton>
  373. </Feature>
  374. {orgFeatures.has('shared-issues') && (
  375. <ShareIssue
  376. disabled={disabled || !shareCap.enabled}
  377. disabledReason={shareCap.disabledReason}
  378. loading={this.state.shareBusy}
  379. isShared={group.isPublic}
  380. shareUrl={this.getShareUrl(group.shareId)}
  381. onToggle={this.onToggleShare}
  382. onReshare={() => this.onShare(true)}
  383. />
  384. )}
  385. <SubscribeAction
  386. disabled={disabled}
  387. group={group}
  388. onClick={this.handleClick(disabled, this.onToggleSubscribe)}
  389. />
  390. <Access organization={organization} access={['event:admin']}>
  391. {({hasAccess}) => (
  392. <DropdownMenuControl
  393. triggerProps={{
  394. 'aria-label': t('More Actions'),
  395. icon: <IconEllipsis size="xs" />,
  396. showChevron: false,
  397. size: 'xs',
  398. }}
  399. items={[
  400. {
  401. key: bookmarkKey,
  402. label: bookmarkTitle,
  403. hidden: false,
  404. onAction: this.onToggleBookmark,
  405. },
  406. {
  407. key: 'reprocess',
  408. label: t('Reprocess events'),
  409. hidden: !displayReprocessEventAction(organization.features, event),
  410. onAction: this.onReprocessEvent,
  411. },
  412. {
  413. key: 'delete-issue',
  414. priority: 'danger',
  415. label: t('Delete'),
  416. hidden: !hasAccess,
  417. disabled: !deleteCap.enabled,
  418. details: deleteCap.disabledReason,
  419. onAction: this.openDeleteModal,
  420. },
  421. {
  422. key: 'delete-and-discard',
  423. priority: 'danger',
  424. label: t('Delete and discard future events'),
  425. hidden: !hasAccess,
  426. disabled: !deleteDiscardCap.enabled,
  427. details: deleteDiscardCap.disabledReason,
  428. onAction: this.openDiscardModal,
  429. },
  430. ]}
  431. />
  432. )}
  433. </Access>
  434. </Wrapper>
  435. );
  436. }
  437. }
  438. const Wrapper = styled('div')`
  439. display: flex;
  440. flex-wrap: wrap;
  441. justify-content: flex-start;
  442. align-items: center;
  443. grid-auto-flow: column;
  444. gap: ${space(0.5)};
  445. white-space: nowrap;
  446. `;
  447. export {Actions};
  448. export default withApi(withOrganization(Actions));