index.tsx 18 KB

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