index.tsx 11 KB

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