index.tsx 18 KB

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