actionSet.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. import {useTheme} from '@emotion/react';
  2. import styled from '@emotion/styled';
  3. import ActionLink from 'sentry/components/actions/actionLink';
  4. import IgnoreActions from 'sentry/components/actions/ignore';
  5. import {openConfirmModal} from 'sentry/components/confirm';
  6. import DropdownMenuControlV2 from 'sentry/components/dropdownMenuControlV2';
  7. import {MenuItemProps} from 'sentry/components/dropdownMenuItemV2';
  8. import {IconEllipsis} from 'sentry/icons';
  9. import {t} from 'sentry/locale';
  10. import GroupStore from 'sentry/stores/groupStore';
  11. import space from 'sentry/styles/space';
  12. import {Organization, Project, ResolutionStatus} from 'sentry/types';
  13. import Projects from 'sentry/utils/projects';
  14. import useMedia from 'sentry/utils/useMedia';
  15. import ResolveActions from './resolveActions';
  16. import ReviewAction from './reviewAction';
  17. import IssueListSortOptions from './sortOptions';
  18. import {ConfirmAction, getConfirm, getLabel} from './utils';
  19. type Props = {
  20. allInQuerySelected: boolean;
  21. anySelected: boolean;
  22. issues: Set<string>;
  23. multiSelected: boolean;
  24. onDelete: () => void;
  25. onMerge: () => void;
  26. onShouldConfirm: (action: ConfirmAction) => boolean;
  27. onSortChange: (sort: string) => void;
  28. onUpdate: (data?: any) => void;
  29. orgSlug: Organization['slug'];
  30. query: string;
  31. queryCount: number;
  32. sort: string;
  33. selectedProjectSlug?: string;
  34. };
  35. function ActionSet({
  36. orgSlug,
  37. queryCount,
  38. query,
  39. allInQuerySelected,
  40. anySelected,
  41. multiSelected,
  42. issues,
  43. onUpdate,
  44. onShouldConfirm,
  45. onDelete,
  46. onMerge,
  47. selectedProjectSlug,
  48. sort,
  49. onSortChange,
  50. }: Props) {
  51. const numIssues = issues.size;
  52. const confirm = getConfirm(numIssues, allInQuerySelected, query, queryCount);
  53. const label = getLabel(numIssues, allInQuerySelected);
  54. // merges require a single project to be active in an org context
  55. // selectedProjectSlug is null when 0 or >1 projects are selected.
  56. const mergeDisabled = !(multiSelected && selectedProjectSlug);
  57. const selectedIssues = [...issues].map(GroupStore.get);
  58. const canMarkReviewed =
  59. anySelected && (allInQuerySelected || selectedIssues.some(issue => !!issue?.inbox));
  60. // determine which ... dropdown options to show based on issue(s) selected
  61. const canAddBookmark =
  62. allInQuerySelected || selectedIssues.some(issue => !issue?.isBookmarked);
  63. const canRemoveBookmark =
  64. allInQuerySelected || selectedIssues.some(issue => issue?.isBookmarked);
  65. const canSetUnresolved =
  66. allInQuerySelected || selectedIssues.some(issue => issue?.status === 'resolved');
  67. // Determine whether to nest "Merge" and "Mark as Reviewed" buttons inside
  68. // the dropdown menu based on the current screen size
  69. const theme = useTheme();
  70. const nestMergeAndReview = useMedia(`(max-width: ${theme.breakpoints.xlarge})`);
  71. const menuItems: MenuItemProps[] = [
  72. {
  73. key: 'merge',
  74. label: t('Merge'),
  75. hidden: !nestMergeAndReview,
  76. onAction: () => {
  77. openConfirmModal({
  78. bypass: !onShouldConfirm(ConfirmAction.MERGE),
  79. onConfirm: onMerge,
  80. message: confirm(ConfirmAction.MERGE, false),
  81. confirmText: label('merge'),
  82. });
  83. },
  84. },
  85. {
  86. key: 'mark-reviewed',
  87. label: t('Mark Reviewed'),
  88. hidden: !nestMergeAndReview,
  89. onAction: () => onUpdate({inbox: false}),
  90. },
  91. {
  92. key: 'bookmark',
  93. label: t('Add to Bookmarks'),
  94. hidden: !canAddBookmark,
  95. onAction: () => {
  96. openConfirmModal({
  97. bypass: !onShouldConfirm(ConfirmAction.BOOKMARK),
  98. onConfirm: () => onUpdate({isBookmarked: true}),
  99. message: confirm(ConfirmAction.BOOKMARK, false),
  100. confirmText: label('bookmark'),
  101. });
  102. },
  103. },
  104. {
  105. key: 'remove-bookmark',
  106. label: t('Remove from Bookmarks'),
  107. hidden: !canRemoveBookmark,
  108. onAction: () => {
  109. openConfirmModal({
  110. bypass: !onShouldConfirm(ConfirmAction.UNBOOKMARK),
  111. onConfirm: () => onUpdate({isBookmarked: false}),
  112. message: confirm('remove', false, ' from your bookmarks'),
  113. confirmText: label('remove', ' from your bookmarks'),
  114. });
  115. },
  116. },
  117. {
  118. key: 'unresolve',
  119. label: t('Set status to: Unresolved'),
  120. hidden: !canSetUnresolved,
  121. onAction: () => {
  122. openConfirmModal({
  123. bypass: !onShouldConfirm(ConfirmAction.UNRESOLVE),
  124. onConfirm: () => onUpdate({status: ResolutionStatus.UNRESOLVED}),
  125. message: confirm(ConfirmAction.UNRESOLVE, true),
  126. confirmText: label('unresolve'),
  127. });
  128. },
  129. },
  130. {
  131. key: 'delete',
  132. label: t('Delete'),
  133. priority: 'danger',
  134. onAction: () => {
  135. openConfirmModal({
  136. bypass: !onShouldConfirm(ConfirmAction.DELETE),
  137. onConfirm: onDelete,
  138. priority: 'danger',
  139. message: confirm(ConfirmAction.DELETE, false),
  140. confirmText: label('delete'),
  141. });
  142. },
  143. },
  144. ];
  145. const disabledMenuItems = [
  146. ...(mergeDisabled ? ['merge'] : []),
  147. ...(canMarkReviewed ? [] : ['mark-reviewed']),
  148. ];
  149. return (
  150. <Wrapper>
  151. {selectedProjectSlug ? (
  152. <Projects orgId={orgSlug} slugs={[selectedProjectSlug]}>
  153. {({projects, initiallyLoaded, fetchError}) => {
  154. const selectedProject = projects[0];
  155. return (
  156. <ResolveActions
  157. onShouldConfirm={onShouldConfirm}
  158. onUpdate={onUpdate}
  159. anySelected={anySelected}
  160. orgSlug={orgSlug}
  161. params={{
  162. hasReleases: selectedProject.hasOwnProperty('features')
  163. ? (selectedProject as Project).features.includes('releases')
  164. : false,
  165. latestRelease: selectedProject.hasOwnProperty('latestRelease')
  166. ? (selectedProject as Project).latestRelease
  167. : undefined,
  168. projectId: selectedProject.slug,
  169. confirm,
  170. label,
  171. loadingProjects: !initiallyLoaded,
  172. projectFetchError: !!fetchError,
  173. }}
  174. />
  175. );
  176. }}
  177. </Projects>
  178. ) : (
  179. <ResolveActions
  180. onShouldConfirm={onShouldConfirm}
  181. onUpdate={onUpdate}
  182. anySelected={anySelected}
  183. orgSlug={orgSlug}
  184. params={{
  185. hasReleases: false,
  186. latestRelease: null,
  187. projectId: null,
  188. confirm,
  189. label,
  190. }}
  191. />
  192. )}
  193. <IgnoreActions
  194. onUpdate={onUpdate}
  195. shouldConfirm={onShouldConfirm(ConfirmAction.IGNORE)}
  196. confirmMessage={confirm(ConfirmAction.IGNORE, true)}
  197. confirmLabel={label('ignore')}
  198. disabled={!anySelected}
  199. />
  200. {!nestMergeAndReview && (
  201. <ReviewAction disabled={!canMarkReviewed} onUpdate={onUpdate} />
  202. )}
  203. {!nestMergeAndReview && (
  204. <ActionLink
  205. type="button"
  206. disabled={mergeDisabled}
  207. onAction={onMerge}
  208. shouldConfirm={onShouldConfirm(ConfirmAction.MERGE)}
  209. message={confirm(ConfirmAction.MERGE, false)}
  210. confirmLabel={label('merge')}
  211. title={t('Merge Selected Issues')}
  212. >
  213. {t('Merge')}
  214. </ActionLink>
  215. )}
  216. <DropdownMenuControlV2
  217. items={menuItems}
  218. triggerProps={{
  219. 'aria-label': t('More issue actions'),
  220. icon: <IconEllipsis size="xs" />,
  221. showChevron: false,
  222. size: 'xs',
  223. }}
  224. disabledKeys={disabledMenuItems}
  225. isDisabled={!anySelected}
  226. />
  227. <IssueListSortOptions sort={sort} query={query} onSelect={onSortChange} />
  228. </Wrapper>
  229. );
  230. }
  231. export default ActionSet;
  232. const Wrapper = styled('div')`
  233. @media (min-width: ${p => p.theme.breakpoints.small}) {
  234. width: 66.66%;
  235. }
  236. @media (min-width: ${p => p.theme.breakpoints.large}) {
  237. width: 50%;
  238. }
  239. flex: 1;
  240. margin: 0 ${space(1)};
  241. display: grid;
  242. gap: ${space(0.5)};
  243. grid-auto-flow: column;
  244. justify-content: flex-start;
  245. white-space: nowrap;
  246. `;