index.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. import {Fragment, useEffect, useState} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {AnimatePresence, type AnimationProps, motion} from 'framer-motion';
  5. import {bulkDelete, bulkUpdate, mergeGroups} from 'sentry/actionCreators/group';
  6. import {
  7. addErrorMessage,
  8. addLoadingMessage,
  9. clearIndicators,
  10. } from 'sentry/actionCreators/indicator';
  11. import {Alert} from 'sentry/components/core/alert';
  12. import {Checkbox} from 'sentry/components/core/checkbox';
  13. import IssueStreamHeaderLabel from 'sentry/components/IssueStreamHeaderLabel';
  14. import {Sticky} from 'sentry/components/sticky';
  15. import {t, tct, tn} from 'sentry/locale';
  16. import GroupStore from 'sentry/stores/groupStore';
  17. import ProjectsStore from 'sentry/stores/projectsStore';
  18. import SelectedGroupStore from 'sentry/stores/selectedGroupStore';
  19. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  20. import {space} from 'sentry/styles/space';
  21. import type {PageFilters} from 'sentry/types/core';
  22. import type {Group} from 'sentry/types/group';
  23. import {defined} from 'sentry/utils';
  24. import {trackAnalytics} from 'sentry/utils/analytics';
  25. import {uniq} from 'sentry/utils/array/uniq';
  26. import {useQueryClient} from 'sentry/utils/queryClient';
  27. import useApi from 'sentry/utils/useApi';
  28. import {useBreakpoints} from 'sentry/utils/useBreakpoints';
  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 theme = useTheme();
  204. const disableActions = useMedia(
  205. `(max-width: ${
  206. isSavedSearchesOpen ? theme.breakpoints.xlarge : theme.breakpoints.medium
  207. })`
  208. );
  209. const numIssues = selectedIdsSet.size;
  210. function actionSelectedGroups(callback: (itemIds: string[] | undefined) => void) {
  211. const selectedIds = allInQuerySelected
  212. ? undefined // undefined means "all"
  213. : groupIds.filter(itemId => selectedIdsSet.has(itemId));
  214. callback(selectedIds);
  215. SelectedGroupStore.deselectAll();
  216. }
  217. // TODO: Remove issue.category:error filter when merging/deleting performance issues is supported
  218. // This silently avoids performance issues for bulk actions
  219. const queryExcludingPerformanceIssues = `${query ?? ''} issue.category:error`;
  220. function handleDelete() {
  221. actionSelectedGroups(itemIds => {
  222. bulkDelete(
  223. api,
  224. {
  225. orgId: organization.slug,
  226. itemIds,
  227. query: queryExcludingPerformanceIssues,
  228. project: selection.projects,
  229. environment: selection.environments,
  230. ...selection.datetime,
  231. },
  232. {
  233. complete: () => {
  234. onDelete();
  235. },
  236. }
  237. );
  238. });
  239. }
  240. function handleMerge() {
  241. actionSelectedGroups(itemIds => {
  242. mergeGroups(
  243. api,
  244. {
  245. orgId: organization.slug,
  246. itemIds,
  247. query: queryExcludingPerformanceIssues,
  248. project: selection.projects,
  249. environment: selection.environments,
  250. ...selection.datetime,
  251. },
  252. {}
  253. );
  254. if (selection.projects[0]) {
  255. const trackProject = ProjectsStore.getById(`${selection.projects[0]}`);
  256. trackAnalytics('issues_stream.merged', {
  257. organization,
  258. project_id: trackProject?.id,
  259. platform: trackProject?.platform,
  260. items_merged: allInQuerySelected ? 'all_in_query' : itemIds?.length,
  261. });
  262. }
  263. });
  264. }
  265. // If all selected groups are from the same project, return the project ID.
  266. // Otherwise, return the global selection projects. This is important because
  267. // resolution in release requires that a project is specified, but the global
  268. // selection may not have that information if My Projects is selected.
  269. function getSelectedProjectIds(selectedGroupIds: string[] | undefined) {
  270. if (!selectedGroupIds) {
  271. return selection.projects;
  272. }
  273. const groups = selectedGroupIds.map(id => GroupStore.get(id));
  274. const projectIds = new Set(groups.map(group => group?.project?.id).filter(defined));
  275. if (projectIds.size === 1) {
  276. return [...projectIds];
  277. }
  278. return selection.projects;
  279. }
  280. function handleUpdate(data: IssueUpdateData) {
  281. if ('status' in data && data.status === 'ignored') {
  282. const statusDetails =
  283. 'ignoreCount' in data.statusDetails
  284. ? 'ignoreCount'
  285. : 'ignoreDuration' in data.statusDetails
  286. ? 'ignoreDuration'
  287. : 'ignoreUserCount' in data.statusDetails
  288. ? 'ignoreUserCount'
  289. : undefined;
  290. trackAnalytics('issues_stream.archived', {
  291. action_status_details: statusDetails,
  292. action_substatus: data.substatus,
  293. organization,
  294. });
  295. }
  296. if ('priority' in data) {
  297. trackAnalytics('issues_stream.updated_priority', {
  298. organization,
  299. priority: data.priority,
  300. });
  301. }
  302. actionSelectedGroups(itemIds => {
  303. // If `itemIds` is undefined then it means we expect to bulk update all items
  304. // that match the query.
  305. //
  306. // We need to always respect the projects selected in the global selection header:
  307. // * users with no global views requires a project to be specified
  308. // * users with global views need to be explicit about what projects the query will run against
  309. const projectConstraints = {project: getSelectedProjectIds(itemIds)};
  310. if (itemIds?.length) {
  311. addLoadingMessage(t('Saving changes\u2026'));
  312. }
  313. bulkUpdate(
  314. api,
  315. {
  316. orgId: organization.slug,
  317. itemIds,
  318. data,
  319. query,
  320. environment: selection.environments,
  321. failSilently: true,
  322. ...projectConstraints,
  323. ...selection.datetime,
  324. },
  325. {
  326. success: () => {
  327. clearIndicators();
  328. onActionTaken?.(itemIds ?? [], data);
  329. // Prevents stale data on issue details
  330. if (itemIds?.length) {
  331. for (const itemId of itemIds) {
  332. queryClient.invalidateQueries({
  333. queryKey: [`/organizations/${organization.slug}/issues/${itemId}/`],
  334. exact: false,
  335. });
  336. }
  337. } else {
  338. // If we're doing a full query update we invalidate all issue queries to be safe
  339. queryClient.invalidateQueries({
  340. predicate: apiQuery =>
  341. typeof apiQuery.queryKey[0] === 'string' &&
  342. apiQuery.queryKey[0].startsWith(
  343. `/organizations/${organization.slug}/issues/`
  344. ),
  345. });
  346. }
  347. },
  348. error: () => {
  349. clearIndicators();
  350. addErrorMessage(t('Unable to update issues'));
  351. },
  352. }
  353. );
  354. });
  355. }
  356. return (
  357. <StickyActions>
  358. <ActionsBarPriority
  359. query={query}
  360. queryCount={queryCount}
  361. selection={selection}
  362. statsPeriod={statsPeriod}
  363. onSortChange={onSortChange}
  364. allInQuerySelected={allInQuerySelected}
  365. pageSelected={pageSelected}
  366. selectedIdsSet={selectedIdsSet}
  367. displayReprocessingActions={displayReprocessingActions}
  368. handleDelete={handleDelete}
  369. handleMerge={handleMerge}
  370. handleUpdate={handleUpdate}
  371. multiSelected={multiSelected}
  372. narrowViewport={disableActions}
  373. selectedProjectSlug={selectedProjectSlug}
  374. isSavedSearchesOpen={isSavedSearchesOpen}
  375. sort={sort}
  376. anySelected={anySelected}
  377. onSelectStatsPeriod={onSelectStatsPeriod}
  378. />
  379. {!allResultsVisible && pageSelected && (
  380. <StyledAlert type="warning" system>
  381. <SelectAllNotice data-test-id="issue-list-select-all-notice">
  382. {allInQuerySelected ? (
  383. queryCount >= BULK_LIMIT ? (
  384. tct(
  385. 'Selected up to the first [count] issues that match this search query.',
  386. {
  387. count: BULK_LIMIT_STR,
  388. }
  389. )
  390. ) : (
  391. tct('Selected all [count] issues that match this search query.', {
  392. count: queryCount,
  393. })
  394. )
  395. ) : (
  396. <Fragment>
  397. {tn(
  398. '%s issue on this page selected.',
  399. '%s issues on this page selected.',
  400. numIssues
  401. )}
  402. <SelectAllLink
  403. onClick={() => setAllInQuerySelected(true)}
  404. data-test-id="issue-list-select-all-notice-link"
  405. >
  406. {queryCount >= BULK_LIMIT
  407. ? tct(
  408. 'Select the first [count] issues that match this search query.',
  409. {
  410. count: BULK_LIMIT_STR,
  411. }
  412. )
  413. : tct('Select all [count] issues that match this search query.', {
  414. count: queryCount,
  415. })}
  416. </SelectAllLink>
  417. </Fragment>
  418. )}
  419. </SelectAllNotice>
  420. </StyledAlert>
  421. )}
  422. </StickyActions>
  423. );
  424. }
  425. function useSelectedGroupsState() {
  426. const [allInQuerySelected, setAllInQuerySelected] = useState(false);
  427. const selectedGroupState = useLegacyStore(SelectedGroupStore);
  428. const selectedIds = SelectedGroupStore.getSelectedIds();
  429. const projects = [...selectedIds]
  430. .map(id => GroupStore.get(id))
  431. .filter((group): group is Group => !!group?.project)
  432. .map(group => group.project.slug);
  433. const uniqProjects = uniq(projects);
  434. // we only want selectedProjectSlug set if there is 1 project
  435. // more or fewer should result in a null so that the action toolbar
  436. // can behave correctly.
  437. const selectedProjectSlug = uniqProjects.length === 1 ? uniqProjects[0] : undefined;
  438. const pageSelected = SelectedGroupStore.allSelected();
  439. const multiSelected = SelectedGroupStore.multiSelected();
  440. const anySelected = SelectedGroupStore.anySelected();
  441. const selectedIdsSet = SelectedGroupStore.getSelectedIds();
  442. useEffect(() => {
  443. setAllInQuerySelected(false);
  444. }, [selectedGroupState]);
  445. return {
  446. pageSelected,
  447. multiSelected,
  448. anySelected,
  449. allInQuerySelected,
  450. selectedIdsSet,
  451. selectedProjectSlug,
  452. setAllInQuerySelected,
  453. };
  454. }
  455. function shouldConfirm(
  456. action: ConfirmAction,
  457. {pageSelected, selectedIdsSet}: {pageSelected: boolean; selectedIdsSet: Set<string>}
  458. ) {
  459. switch (action) {
  460. case ConfirmAction.RESOLVE:
  461. case ConfirmAction.UNRESOLVE:
  462. case ConfirmAction.ARCHIVE:
  463. case ConfirmAction.SET_PRIORITY:
  464. case ConfirmAction.UNBOOKMARK: {
  465. return pageSelected && selectedIdsSet.size > 1;
  466. }
  467. case ConfirmAction.BOOKMARK:
  468. return selectedIdsSet.size > 1;
  469. case ConfirmAction.MERGE:
  470. case ConfirmAction.DELETE:
  471. default:
  472. return true; // By default, should confirm ...
  473. }
  474. }
  475. export const HeaderDivider = styled(motion.div)`
  476. background-color: ${p => p.theme.gray200};
  477. width: 1px;
  478. border-radius: ${p => p.theme.borderRadius};
  479. `;
  480. const StickyActions = styled(Sticky)`
  481. z-index: ${p => p.theme.zIndex.issuesList.stickyHeader};
  482. /* Remove border radius from the action bar when stuck. Without this there is
  483. * a small gap where color can peek through. */
  484. &[data-stuck] > div {
  485. border-radius: 0;
  486. }
  487. border-bottom: 1px solid ${p => p.theme.border};
  488. border-top: none;
  489. border-radius: ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0 0;
  490. `;
  491. const ActionsBarContainer = styled('div')<{narrowHeader: boolean}>`
  492. display: flex;
  493. min-height: ${p => (p.narrowHeader ? '36px' : '45px')};
  494. padding-top: ${p => (p.narrowHeader ? space(0.5) : space(1))};
  495. padding-bottom: ${p => (p.narrowHeader ? space(0.5) : space(1))};
  496. align-items: center;
  497. background: ${p => p.theme.backgroundSecondary};
  498. border-radius: 6px 6px 0 0;
  499. `;
  500. const ActionsCheckbox = styled('div')<{isReprocessingQuery: boolean}>`
  501. display: flex;
  502. align-items: center;
  503. padding-left: ${space(2)};
  504. margin-bottom: 1px;
  505. ${p => p.isReprocessingQuery && 'flex: 1'};
  506. `;
  507. const HeaderButtonsWrapper = styled(motion.div)`
  508. @media (min-width: ${p => p.theme.breakpoints.large}) {
  509. width: 50%;
  510. }
  511. flex: 1;
  512. margin: 0 ${space(1)};
  513. display: grid;
  514. gap: ${space(0.5)};
  515. grid-auto-flow: column;
  516. justify-content: flex-start;
  517. white-space: nowrap;
  518. `;
  519. const NarrowHeaderButtonsWrapper = styled(motion.div)`
  520. @media (min-width: ${p => p.theme.breakpoints.large}) {
  521. width: 50%;
  522. }
  523. flex: 1;
  524. margin-left: ${space(1)};
  525. margin-right: ${space(2)};
  526. display: grid;
  527. gap: ${space(0.5)};
  528. grid-auto-flow: column;
  529. justify-content: space-between;
  530. white-space: nowrap;
  531. `;
  532. const SelectAllNotice = styled('div')`
  533. display: flex;
  534. flex-wrap: wrap;
  535. justify-content: center;
  536. a:not([role='button']) {
  537. color: ${p => p.theme.linkColor};
  538. border-bottom: none;
  539. }
  540. `;
  541. const SelectAllLink = styled('a')`
  542. margin-left: ${space(1)};
  543. `;
  544. const SortDropdownMargin = styled('div')`
  545. margin-right: ${space(1)};
  546. `;
  547. const AnimatedHeaderItemsContainer = styled(motion.div)`
  548. display: flex;
  549. align-items: center;
  550. `;
  551. const StyledAlert = styled(Alert)`
  552. border-bottom: none;
  553. `;
  554. export {IssueListActions};
  555. export default IssueListActions;