index.tsx 21 KB

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