index.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. import {Fragment, useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {bulkDelete, bulkUpdate, mergeGroups} from 'sentry/actionCreators/group';
  4. import {
  5. addErrorMessage,
  6. addLoadingMessage,
  7. clearIndicators,
  8. } from 'sentry/actionCreators/indicator';
  9. import {Alert} from 'sentry/components/alert';
  10. import Checkbox from 'sentry/components/checkbox';
  11. import {Sticky} from 'sentry/components/sticky';
  12. import {t, tct, tn} from 'sentry/locale';
  13. import GroupStore from 'sentry/stores/groupStore';
  14. import ProjectsStore from 'sentry/stores/projectsStore';
  15. import SelectedGroupStore from 'sentry/stores/selectedGroupStore';
  16. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  17. import {space} from 'sentry/styles/space';
  18. import type {Group, PageFilters} from 'sentry/types';
  19. import {trackAnalytics} from 'sentry/utils/analytics';
  20. import {uniq} from 'sentry/utils/array/uniq';
  21. import theme from 'sentry/utils/theme';
  22. import useApi from 'sentry/utils/useApi';
  23. import useMedia from 'sentry/utils/useMedia';
  24. import useOrganization from 'sentry/utils/useOrganization';
  25. import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageState';
  26. import type {IssueUpdateData} from 'sentry/views/issueList/types';
  27. import {SAVED_SEARCHES_SIDEBAR_OPEN_LOCALSTORAGE_KEY} from 'sentry/views/issueList/utils';
  28. import ActionSet from './actionSet';
  29. import Headers from './headers';
  30. import IssueListSortOptions from './sortOptions';
  31. import {BULK_LIMIT, BULK_LIMIT_STR, ConfirmAction} from './utils';
  32. type IssueListActionsProps = {
  33. allResultsVisible: boolean;
  34. displayReprocessingActions: boolean;
  35. groupIds: string[];
  36. onDelete: () => void;
  37. onSelectStatsPeriod: (period: string) => void;
  38. onSortChange: (sort: string) => void;
  39. query: string;
  40. queryCount: number;
  41. selection: PageFilters;
  42. sort: string;
  43. statsPeriod: string;
  44. onActionTaken?: (itemIds: string[], data: IssueUpdateData) => void;
  45. };
  46. function ActionsBarPriority({
  47. anySelected,
  48. narrowViewport,
  49. displayReprocessingActions,
  50. pageSelected,
  51. queryCount,
  52. selectedIdsSet,
  53. multiSelected,
  54. allInQuerySelected,
  55. query,
  56. handleDelete,
  57. handleMerge,
  58. handleUpdate,
  59. sort,
  60. selectedProjectSlug,
  61. onSortChange,
  62. onSelectStatsPeriod,
  63. isSavedSearchesOpen,
  64. statsPeriod,
  65. selection,
  66. }: {
  67. allInQuerySelected: boolean;
  68. anySelected: boolean;
  69. displayReprocessingActions: boolean;
  70. handleDelete: () => void;
  71. handleMerge: () => void;
  72. handleUpdate: (data: IssueUpdateData) => void;
  73. isSavedSearchesOpen: boolean;
  74. multiSelected: boolean;
  75. narrowViewport: boolean;
  76. onSelectStatsPeriod: (period: string) => void;
  77. onSortChange: (sort: string) => void;
  78. pageSelected: boolean;
  79. query: string;
  80. queryCount: number;
  81. selectedIdsSet: Set<string>;
  82. selectedProjectSlug: string | undefined;
  83. selection: PageFilters;
  84. sort: string;
  85. statsPeriod: string;
  86. }) {
  87. const shouldDisplayActions = anySelected && !narrowViewport;
  88. const sortDropdown = (
  89. <IssueListSortOptions sort={sort} query={query} onSelect={onSortChange} />
  90. );
  91. return (
  92. <ActionsBarContainer>
  93. {!narrowViewport && (
  94. <ActionsCheckbox isReprocessingQuery={displayReprocessingActions}>
  95. <Checkbox
  96. onChange={() => SelectedGroupStore.toggleSelectAll()}
  97. checked={pageSelected || (anySelected ? 'indeterminate' : false)}
  98. disabled={displayReprocessingActions}
  99. />
  100. </ActionsCheckbox>
  101. )}
  102. {!displayReprocessingActions && (
  103. <HeaderButtonsWrapper>
  104. {shouldDisplayActions && (
  105. <ActionSet
  106. queryCount={queryCount}
  107. query={query}
  108. issues={selectedIdsSet}
  109. allInQuerySelected={allInQuerySelected}
  110. anySelected={anySelected}
  111. multiSelected={multiSelected}
  112. selectedProjectSlug={selectedProjectSlug}
  113. onShouldConfirm={action =>
  114. shouldConfirm(action, {pageSelected, selectedIdsSet})
  115. }
  116. onDelete={handleDelete}
  117. onMerge={handleMerge}
  118. onUpdate={handleUpdate}
  119. />
  120. )}
  121. {!anySelected ? sortDropdown : null}
  122. </HeaderButtonsWrapper>
  123. )}
  124. {!anySelected ? (
  125. <Headers
  126. onSelectStatsPeriod={onSelectStatsPeriod}
  127. selection={selection}
  128. statsPeriod={statsPeriod}
  129. isReprocessingQuery={displayReprocessingActions}
  130. isSavedSearchesOpen={isSavedSearchesOpen}
  131. />
  132. ) : (
  133. <SortDropdownMargin>{sortDropdown}</SortDropdownMargin>
  134. )}
  135. </ActionsBarContainer>
  136. );
  137. }
  138. function IssueListActions({
  139. allResultsVisible,
  140. displayReprocessingActions,
  141. groupIds,
  142. onActionTaken,
  143. onDelete,
  144. onSelectStatsPeriod,
  145. onSortChange,
  146. queryCount,
  147. query,
  148. selection,
  149. sort,
  150. statsPeriod,
  151. }: IssueListActionsProps) {
  152. const api = useApi();
  153. const organization = useOrganization();
  154. const {
  155. pageSelected,
  156. multiSelected,
  157. anySelected,
  158. allInQuerySelected,
  159. selectedIdsSet,
  160. selectedProjectSlug,
  161. setAllInQuerySelected,
  162. } = useSelectedGroupsState();
  163. const [isSavedSearchesOpen] = useSyncedLocalStorageState(
  164. SAVED_SEARCHES_SIDEBAR_OPEN_LOCALSTORAGE_KEY,
  165. false
  166. );
  167. const hasIssuePriority = organization.features.includes('issue-priority-ui');
  168. const disableActions = useMedia(
  169. `(max-width: ${
  170. isSavedSearchesOpen ? theme.breakpoints.xlarge : theme.breakpoints.medium
  171. })`
  172. );
  173. const numIssues = selectedIdsSet.size;
  174. function actionSelectedGroups(callback: (itemIds: string[] | undefined) => void) {
  175. const selectedIds = allInQuerySelected
  176. ? undefined // undefined means "all"
  177. : groupIds.filter(itemId => selectedIdsSet.has(itemId));
  178. callback(selectedIds);
  179. SelectedGroupStore.deselectAll();
  180. }
  181. // TODO: Remove issue.category:error filter when merging/deleting performance issues is supported
  182. // This silently avoids performance issues for bulk actions
  183. const queryExcludingPerformanceIssues = `${query ?? ''} issue.category:error`;
  184. function handleDelete() {
  185. actionSelectedGroups(itemIds => {
  186. bulkDelete(
  187. api,
  188. {
  189. orgId: organization.slug,
  190. itemIds,
  191. query: queryExcludingPerformanceIssues,
  192. project: selection.projects,
  193. environment: selection.environments,
  194. ...selection.datetime,
  195. },
  196. {
  197. complete: () => {
  198. onDelete();
  199. },
  200. }
  201. );
  202. });
  203. }
  204. function handleMerge() {
  205. actionSelectedGroups(itemIds => {
  206. mergeGroups(
  207. api,
  208. {
  209. orgId: organization.slug,
  210. itemIds,
  211. query: queryExcludingPerformanceIssues,
  212. project: selection.projects,
  213. environment: selection.environments,
  214. ...selection.datetime,
  215. },
  216. {}
  217. );
  218. if (selection.projects[0]) {
  219. const trackProject = ProjectsStore.getById(`${selection.projects[0]}`);
  220. trackAnalytics('issues_stream.merged', {
  221. organization,
  222. project_id: trackProject?.id,
  223. platform: trackProject?.platform,
  224. items_merged: allInQuerySelected ? 'all_in_query' : itemIds?.length,
  225. });
  226. }
  227. });
  228. }
  229. function handleUpdate(data: IssueUpdateData) {
  230. if ('status' in data && data.status === 'ignored') {
  231. const statusDetails =
  232. 'ignoreCount' in data.statusDetails
  233. ? 'ignoreCount'
  234. : 'ignoreDuration' in data.statusDetails
  235. ? 'ignoreDuration'
  236. : 'ignoreUserCount' in data.statusDetails
  237. ? 'ignoreUserCount'
  238. : undefined;
  239. trackAnalytics('issues_stream.archived', {
  240. action_status_details: statusDetails,
  241. action_substatus: data.substatus,
  242. organization,
  243. });
  244. }
  245. if ('priority' in data) {
  246. trackAnalytics('issues_stream.updated_priority', {
  247. organization,
  248. priority: data.priority,
  249. });
  250. }
  251. actionSelectedGroups(itemIds => {
  252. // If `itemIds` is undefined then it means we expect to bulk update all items
  253. // that match the query.
  254. //
  255. // We need to always respect the projects selected in the global selection header:
  256. // * users with no global views requires a project to be specified
  257. // * users with global views need to be explicit about what projects the query will run against
  258. const projectConstraints = {project: selection.projects};
  259. if (itemIds?.length) {
  260. addLoadingMessage(t('Saving changes\u2026'));
  261. }
  262. bulkUpdate(
  263. api,
  264. {
  265. orgId: organization.slug,
  266. itemIds,
  267. data,
  268. query,
  269. environment: selection.environments,
  270. failSilently: true,
  271. ...projectConstraints,
  272. ...selection.datetime,
  273. },
  274. {
  275. success: () => {
  276. clearIndicators();
  277. onActionTaken?.(itemIds ?? [], data);
  278. },
  279. error: () => {
  280. clearIndicators();
  281. addErrorMessage(t('Unable to update issues'));
  282. },
  283. }
  284. );
  285. });
  286. }
  287. return (
  288. <StickyActions>
  289. {hasIssuePriority ? (
  290. <ActionsBarPriority
  291. query={query}
  292. queryCount={queryCount}
  293. selection={selection}
  294. statsPeriod={statsPeriod}
  295. onSortChange={onSortChange}
  296. allInQuerySelected={allInQuerySelected}
  297. pageSelected={pageSelected}
  298. selectedIdsSet={selectedIdsSet}
  299. displayReprocessingActions={displayReprocessingActions}
  300. handleDelete={handleDelete}
  301. handleMerge={handleMerge}
  302. handleUpdate={handleUpdate}
  303. multiSelected={multiSelected}
  304. narrowViewport={disableActions}
  305. selectedProjectSlug={selectedProjectSlug}
  306. isSavedSearchesOpen={isSavedSearchesOpen}
  307. sort={sort}
  308. anySelected={anySelected}
  309. onSelectStatsPeriod={onSelectStatsPeriod}
  310. />
  311. ) : (
  312. <ActionsBarContainer>
  313. {!disableActions && (
  314. <ActionsCheckbox isReprocessingQuery={displayReprocessingActions}>
  315. <Checkbox
  316. onChange={() => SelectedGroupStore.toggleSelectAll()}
  317. checked={pageSelected || (anySelected ? 'indeterminate' : false)}
  318. disabled={displayReprocessingActions}
  319. />
  320. </ActionsCheckbox>
  321. )}
  322. {!displayReprocessingActions && (
  323. <HeaderButtonsWrapper>
  324. {!disableActions && (
  325. <ActionSet
  326. queryCount={queryCount}
  327. query={query}
  328. issues={selectedIdsSet}
  329. allInQuerySelected={allInQuerySelected}
  330. anySelected={anySelected}
  331. multiSelected={multiSelected}
  332. selectedProjectSlug={selectedProjectSlug}
  333. onShouldConfirm={action =>
  334. shouldConfirm(action, {pageSelected, selectedIdsSet})
  335. }
  336. onDelete={handleDelete}
  337. onMerge={handleMerge}
  338. onUpdate={handleUpdate}
  339. />
  340. )}
  341. <IssueListSortOptions sort={sort} query={query} onSelect={onSortChange} />
  342. </HeaderButtonsWrapper>
  343. )}
  344. <Headers
  345. onSelectStatsPeriod={onSelectStatsPeriod}
  346. selection={selection}
  347. statsPeriod={statsPeriod}
  348. isReprocessingQuery={displayReprocessingActions}
  349. isSavedSearchesOpen={isSavedSearchesOpen}
  350. />
  351. </ActionsBarContainer>
  352. )}
  353. {!allResultsVisible && pageSelected && (
  354. <Alert type="warning" system>
  355. <SelectAllNotice data-test-id="issue-list-select-all-notice">
  356. {allInQuerySelected ? (
  357. queryCount >= BULK_LIMIT ? (
  358. tct(
  359. 'Selected up to the first [count] issues that match this search query.',
  360. {
  361. count: BULK_LIMIT_STR,
  362. }
  363. )
  364. ) : (
  365. tct('Selected all [count] issues that match this search query.', {
  366. count: queryCount,
  367. })
  368. )
  369. ) : (
  370. <Fragment>
  371. {tn(
  372. '%s issue on this page selected.',
  373. '%s issues on this page selected.',
  374. numIssues
  375. )}
  376. <SelectAllLink
  377. onClick={() => setAllInQuerySelected(true)}
  378. data-test-id="issue-list-select-all-notice-link"
  379. >
  380. {queryCount >= BULK_LIMIT
  381. ? tct(
  382. 'Select the first [count] issues that match this search query.',
  383. {
  384. count: BULK_LIMIT_STR,
  385. }
  386. )
  387. : tct('Select all [count] issues that match this search query.', {
  388. count: queryCount,
  389. })}
  390. </SelectAllLink>
  391. </Fragment>
  392. )}
  393. </SelectAllNotice>
  394. </Alert>
  395. )}
  396. </StickyActions>
  397. );
  398. }
  399. function useSelectedGroupsState() {
  400. const [allInQuerySelected, setAllInQuerySelected] = useState(false);
  401. const selectedIds = useLegacyStore(SelectedGroupStore);
  402. const selected = SelectedGroupStore.getSelectedIds();
  403. const projects = [...selected]
  404. .map(id => GroupStore.get(id))
  405. .filter((group): group is Group => !!group?.project)
  406. .map(group => group.project.slug);
  407. const uniqProjects = uniq(projects);
  408. // we only want selectedProjectSlug set if there is 1 project
  409. // more or fewer should result in a null so that the action toolbar
  410. // can behave correctly.
  411. const selectedProjectSlug = uniqProjects.length === 1 ? uniqProjects[0] : undefined;
  412. const pageSelected = SelectedGroupStore.allSelected();
  413. const multiSelected = SelectedGroupStore.multiSelected();
  414. const anySelected = SelectedGroupStore.anySelected();
  415. const selectedIdsSet = SelectedGroupStore.getSelectedIds();
  416. useEffect(() => {
  417. setAllInQuerySelected(false);
  418. }, [selectedIds]);
  419. return {
  420. pageSelected,
  421. multiSelected,
  422. anySelected,
  423. allInQuerySelected,
  424. selectedIdsSet,
  425. selectedProjectSlug,
  426. setAllInQuerySelected,
  427. };
  428. }
  429. function shouldConfirm(
  430. action: ConfirmAction,
  431. {pageSelected, selectedIdsSet}: {pageSelected: boolean; selectedIdsSet: Set<string>}
  432. ) {
  433. switch (action) {
  434. case ConfirmAction.RESOLVE:
  435. case ConfirmAction.UNRESOLVE:
  436. case ConfirmAction.ARCHIVE:
  437. case ConfirmAction.SET_PRIORITY:
  438. case ConfirmAction.UNBOOKMARK: {
  439. return pageSelected && selectedIdsSet.size > 1;
  440. }
  441. case ConfirmAction.BOOKMARK:
  442. return selectedIdsSet.size > 1;
  443. case ConfirmAction.MERGE:
  444. case ConfirmAction.DELETE:
  445. default:
  446. return true; // By default, should confirm ...
  447. }
  448. }
  449. const StickyActions = styled(Sticky)`
  450. z-index: ${p => p.theme.zIndex.issuesList.stickyHeader};
  451. /* Remove border radius from the action bar when stuck. Without this there is
  452. * a small gap where color can peek through. */
  453. &[data-stuck] > div {
  454. border-radius: 0;
  455. }
  456. `;
  457. const ActionsBarContainer = styled('div')`
  458. display: flex;
  459. min-height: 45px;
  460. padding-top: ${space(1)};
  461. padding-bottom: ${space(1)};
  462. align-items: center;
  463. background: ${p => p.theme.backgroundSecondary};
  464. border: 1px solid ${p => p.theme.border};
  465. border-top: none;
  466. border-radius: ${p => p.theme.panelBorderRadius} ${p => p.theme.panelBorderRadius} 0 0;
  467. margin: 0 -1px -1px;
  468. `;
  469. const ActionsCheckbox = styled('div')<{isReprocessingQuery: boolean}>`
  470. display: flex;
  471. align-items: center;
  472. padding-left: ${space(2)};
  473. margin-bottom: 1px;
  474. ${p => p.isReprocessingQuery && 'flex: 1'};
  475. `;
  476. const HeaderButtonsWrapper = styled('div')`
  477. @media (min-width: ${p => p.theme.breakpoints.large}) {
  478. width: 50%;
  479. }
  480. flex: 1;
  481. margin: 0 ${space(1)};
  482. display: grid;
  483. gap: ${space(0.5)};
  484. grid-auto-flow: column;
  485. justify-content: flex-start;
  486. white-space: nowrap;
  487. `;
  488. const SelectAllNotice = styled('div')`
  489. display: flex;
  490. flex-wrap: wrap;
  491. justify-content: center;
  492. a:not([role='button']) {
  493. color: ${p => p.theme.linkColor};
  494. border-bottom: none;
  495. }
  496. `;
  497. const SelectAllLink = styled('a')`
  498. margin-left: ${space(1)};
  499. `;
  500. const SortDropdownMargin = styled('div')`
  501. margin-right: ${space(1)};
  502. `;
  503. export {IssueListActions};
  504. export default IssueListActions;