index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. import {Component, Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import uniq from 'lodash/uniq';
  4. import {bulkDelete, bulkUpdate, mergeGroups} from 'sentry/actionCreators/group';
  5. import {addLoadingMessage, clearIndicators} from 'sentry/actionCreators/indicator';
  6. import {Client} from 'sentry/api';
  7. import Alert from 'sentry/components/alert';
  8. import Checkbox from 'sentry/components/checkbox';
  9. import {t, tct, tn} from 'sentry/locale';
  10. import GroupStore from 'sentry/stores/groupStore';
  11. import SelectedGroupStore from 'sentry/stores/selectedGroupStore';
  12. import space from 'sentry/styles/space';
  13. import {Group, Organization, PageFilters} from 'sentry/types';
  14. import {callIfFunction} from 'sentry/utils/callIfFunction';
  15. import withApi from 'sentry/utils/withApi';
  16. import ActionSet from './actionSet';
  17. import Headers from './headers';
  18. import {BULK_LIMIT, BULK_LIMIT_STR, ConfirmAction} from './utils';
  19. type Props = {
  20. allResultsVisible: boolean;
  21. api: Client;
  22. displayCount: React.ReactNode;
  23. displayReprocessingActions: boolean;
  24. groupIds: string[];
  25. onDelete: () => void;
  26. onSelectStatsPeriod: (period: string) => void;
  27. onSortChange: (sort: string) => void;
  28. organization: Organization;
  29. query: string;
  30. queryCount: number;
  31. selection: PageFilters;
  32. sort: string;
  33. statsPeriod: string;
  34. onActionTaken?: (itemIds: string[]) => void;
  35. onMarkReviewed?: (itemIds: string[]) => void;
  36. };
  37. type State = {
  38. allInQuerySelected: boolean;
  39. anySelected: boolean;
  40. multiSelected: boolean;
  41. pageSelected: boolean;
  42. selectedIds: Set<string>;
  43. selectedProjectSlug?: string;
  44. };
  45. class IssueListActions extends Component<Props, State> {
  46. state: State = {
  47. anySelected: false,
  48. multiSelected: false, // more than one selected
  49. pageSelected: false, // all on current page selected (e.g. 25)
  50. allInQuerySelected: false, // all in current search query selected (e.g. 1000+)
  51. selectedIds: new Set(),
  52. };
  53. componentDidMount() {
  54. this.handleSelectedGroupChange();
  55. }
  56. componentWillUnmount() {
  57. callIfFunction(this.listener);
  58. }
  59. listener = SelectedGroupStore.listen(() => this.handleSelectedGroupChange(), undefined);
  60. actionSelectedGroups(callback: (itemIds: string[] | undefined) => void) {
  61. let selectedIds: string[] | undefined;
  62. if (this.state.allInQuerySelected) {
  63. selectedIds = undefined; // undefined means "all"
  64. } else {
  65. const itemIdSet = SelectedGroupStore.getSelectedIds();
  66. selectedIds = this.props.groupIds.filter(itemId => itemIdSet.has(itemId));
  67. }
  68. callback(selectedIds);
  69. this.deselectAll();
  70. }
  71. deselectAll() {
  72. SelectedGroupStore.deselectAll();
  73. this.setState({allInQuerySelected: false});
  74. }
  75. // Handler for when `SelectedGroupStore` changes
  76. handleSelectedGroupChange() {
  77. const selected = SelectedGroupStore.getSelectedIds();
  78. const projects = [...selected]
  79. .map(id => GroupStore.get(id))
  80. .filter((group): group is Group => !!(group && group.project))
  81. .map(group => group.project.slug);
  82. const uniqProjects = uniq(projects);
  83. // we only want selectedProjectSlug set if there is 1 project
  84. // more or fewer should result in a null so that the action toolbar
  85. // can behave correctly.
  86. const selectedProjectSlug = uniqProjects.length === 1 ? uniqProjects[0] : undefined;
  87. this.setState({
  88. pageSelected: SelectedGroupStore.allSelected(),
  89. multiSelected: SelectedGroupStore.multiSelected(),
  90. anySelected: SelectedGroupStore.anySelected(),
  91. allInQuerySelected: false, // any change resets
  92. selectedIds: SelectedGroupStore.getSelectedIds(),
  93. selectedProjectSlug,
  94. });
  95. }
  96. handleSelectStatsPeriod = (period: string) => {
  97. return this.props.onSelectStatsPeriod(period);
  98. };
  99. handleApplyToAll = () => {
  100. this.setState({allInQuerySelected: true});
  101. };
  102. handleUpdate = (data?: any) => {
  103. const {selection, api, organization, query, onMarkReviewed, onActionTaken} =
  104. this.props;
  105. const orgId = organization.slug;
  106. const hasIssueListRemovalAction = organization.features.includes(
  107. 'issue-list-removal-action'
  108. );
  109. this.actionSelectedGroups(itemIds => {
  110. // TODO(Kelly): remove once issue-list-removal-action feature is stable
  111. if (!hasIssueListRemovalAction) {
  112. addLoadingMessage(t('Saving changes\u2026'));
  113. }
  114. if (data?.inbox === false) {
  115. onMarkReviewed?.(itemIds ?? []);
  116. }
  117. onActionTaken?.(itemIds ?? []);
  118. // If `itemIds` is undefined then it means we expect to bulk update all items
  119. // that match the query.
  120. //
  121. // We need to always respect the projects selected in the global selection header:
  122. // * users with no global views requires a project to be specified
  123. // * users with global views need to be explicit about what projects the query will run against
  124. const projectConstraints = {project: selection.projects};
  125. bulkUpdate(
  126. api,
  127. {
  128. orgId,
  129. itemIds,
  130. data,
  131. query,
  132. environment: selection.environments,
  133. ...projectConstraints,
  134. ...selection.datetime,
  135. },
  136. {
  137. complete: () => {
  138. if (!hasIssueListRemovalAction) {
  139. clearIndicators();
  140. }
  141. },
  142. }
  143. );
  144. });
  145. };
  146. handleDelete = () => {
  147. const {selection, api, organization, query, onDelete} = this.props;
  148. const orgId = organization.slug;
  149. this.actionSelectedGroups(itemIds => {
  150. bulkDelete(
  151. api,
  152. {
  153. orgId,
  154. itemIds,
  155. query,
  156. project: selection.projects,
  157. environment: selection.environments,
  158. ...selection.datetime,
  159. },
  160. {
  161. complete: () => {
  162. onDelete();
  163. },
  164. }
  165. );
  166. });
  167. };
  168. handleMerge = () => {
  169. const {selection, api, organization, query} = this.props;
  170. const orgId = organization.slug;
  171. this.actionSelectedGroups(itemIds => {
  172. mergeGroups(
  173. api,
  174. {
  175. orgId,
  176. itemIds,
  177. query,
  178. project: selection.projects,
  179. environment: selection.environments,
  180. ...selection.datetime,
  181. },
  182. {}
  183. );
  184. });
  185. };
  186. handleSelectAll() {
  187. SelectedGroupStore.toggleSelectAll();
  188. }
  189. shouldConfirm = (action: ConfirmAction) => {
  190. const selectedItems = SelectedGroupStore.getSelectedIds();
  191. switch (action) {
  192. case ConfirmAction.RESOLVE:
  193. case ConfirmAction.UNRESOLVE:
  194. case ConfirmAction.IGNORE:
  195. case ConfirmAction.UNBOOKMARK: {
  196. const {pageSelected} = this.state;
  197. return pageSelected && selectedItems.size > 1;
  198. }
  199. case ConfirmAction.BOOKMARK:
  200. return selectedItems.size > 1;
  201. case ConfirmAction.MERGE:
  202. case ConfirmAction.DELETE:
  203. default:
  204. return true; // By default, should confirm ...
  205. }
  206. };
  207. render() {
  208. const {
  209. allResultsVisible,
  210. queryCount,
  211. query,
  212. statsPeriod,
  213. selection,
  214. organization,
  215. displayReprocessingActions,
  216. } = this.props;
  217. const {
  218. allInQuerySelected,
  219. anySelected,
  220. pageSelected,
  221. selectedIds: issues,
  222. multiSelected,
  223. selectedProjectSlug,
  224. } = this.state;
  225. const numIssues = issues.size;
  226. return (
  227. <Sticky>
  228. <StyledFlex>
  229. <ActionsCheckbox isReprocessingQuery={displayReprocessingActions}>
  230. <Checkbox
  231. onChange={this.handleSelectAll}
  232. checked={pageSelected}
  233. disabled={displayReprocessingActions}
  234. />
  235. </ActionsCheckbox>
  236. {!displayReprocessingActions && (
  237. <ActionSet
  238. sort={this.props.sort}
  239. onSortChange={this.props.onSortChange}
  240. orgSlug={organization.slug}
  241. queryCount={queryCount}
  242. query={query}
  243. issues={issues}
  244. allInQuerySelected={allInQuerySelected}
  245. anySelected={anySelected}
  246. multiSelected={multiSelected}
  247. selectedProjectSlug={selectedProjectSlug}
  248. onShouldConfirm={this.shouldConfirm}
  249. onDelete={this.handleDelete}
  250. onMerge={this.handleMerge}
  251. onUpdate={this.handleUpdate}
  252. />
  253. )}
  254. <Headers
  255. onSelectStatsPeriod={this.handleSelectStatsPeriod}
  256. anySelected={anySelected}
  257. selection={selection}
  258. statsPeriod={statsPeriod}
  259. isReprocessingQuery={displayReprocessingActions}
  260. />
  261. </StyledFlex>
  262. {!allResultsVisible && pageSelected && (
  263. <Alert type="warning" system>
  264. <SelectAllNotice>
  265. {allInQuerySelected ? (
  266. queryCount >= BULK_LIMIT ? (
  267. tct(
  268. 'Selected up to the first [count] issues that match this search query.',
  269. {
  270. count: BULK_LIMIT_STR,
  271. }
  272. )
  273. ) : (
  274. tct('Selected all [count] issues that match this search query.', {
  275. count: queryCount,
  276. })
  277. )
  278. ) : (
  279. <Fragment>
  280. {tn(
  281. '%s issue on this page selected.',
  282. '%s issues on this page selected.',
  283. numIssues
  284. )}
  285. <SelectAllLink onClick={this.handleApplyToAll}>
  286. {queryCount >= BULK_LIMIT
  287. ? tct(
  288. 'Select the first [count] issues that match this search query.',
  289. {
  290. count: BULK_LIMIT_STR,
  291. }
  292. )
  293. : tct('Select all [count] issues that match this search query.', {
  294. count: queryCount,
  295. })}
  296. </SelectAllLink>
  297. </Fragment>
  298. )}
  299. </SelectAllNotice>
  300. </Alert>
  301. )}
  302. </Sticky>
  303. );
  304. }
  305. }
  306. const Sticky = styled('div')`
  307. position: sticky;
  308. z-index: ${p => p.theme.zIndex.issuesList.stickyHeader};
  309. top: -1px;
  310. `;
  311. const StyledFlex = styled('div')`
  312. display: flex;
  313. box-sizing: border-box;
  314. min-height: 45px;
  315. padding-top: ${space(1)};
  316. padding-bottom: ${space(1)};
  317. align-items: center;
  318. background: ${p => p.theme.backgroundSecondary};
  319. border: 1px solid ${p => p.theme.border};
  320. border-top: none;
  321. border-radius: ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0 0;
  322. margin: 0 -1px -1px;
  323. `;
  324. const ActionsCheckbox = styled('div')<{isReprocessingQuery: boolean}>`
  325. padding-left: ${space(2)};
  326. margin-bottom: 1px;
  327. & input[type='checkbox'] {
  328. margin: 0;
  329. display: block;
  330. }
  331. ${p => p.isReprocessingQuery && 'flex: 1'};
  332. `;
  333. const SelectAllNotice = styled('div')`
  334. display: flex;
  335. flex-wrap: wrap;
  336. justify-content: center;
  337. a:not([role='button']) {
  338. color: ${p => p.theme.linkColor};
  339. border-bottom: none;
  340. }
  341. `;
  342. const SelectAllLink = styled('a')`
  343. margin-left: ${space(1)};
  344. `;
  345. export {IssueListActions};
  346. export default withApi(IssueListActions);