index.tsx 11 KB

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