index.tsx 20 KB

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