index.tsx 19 KB

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