index.tsx 18 KB

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