index.tsx 11 KB

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