import {Fragment, useEffect, useState} from 'react'; import styled from '@emotion/styled'; import uniq from 'lodash/uniq'; import {bulkDelete, bulkUpdate, mergeGroups} from 'sentry/actionCreators/group'; import {addLoadingMessage, clearIndicators} from 'sentry/actionCreators/indicator'; import Alert from 'sentry/components/alert'; import Checkbox from 'sentry/components/checkbox'; import {t, tct, tn} from 'sentry/locale'; import GroupStore from 'sentry/stores/groupStore'; import SelectedGroupStore from 'sentry/stores/selectedGroupStore'; import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import space from 'sentry/styles/space'; import {Group, PageFilters} from 'sentry/types'; import theme from 'sentry/utils/theme'; import useApi from 'sentry/utils/useApi'; import useMedia from 'sentry/utils/useMedia'; import useOrganization from 'sentry/utils/useOrganization'; import ActionSet from './actionSet'; import Headers from './headers'; import IssueListSortOptions from './sortOptions'; import {BULK_LIMIT, BULK_LIMIT_STR, ConfirmAction} from './utils'; type IssueListActionsProps = { allResultsVisible: boolean; displayCount: React.ReactNode; displayReprocessingActions: boolean; groupIds: string[]; onDelete: () => void; onSelectStatsPeriod: (period: string) => void; onSortChange: (sort: string) => void; query: string; queryCount: number; selection: PageFilters; sort: string; statsPeriod: string; onActionTaken?: (itemIds: string[]) => void; onMarkReviewed?: (itemIds: string[]) => void; }; function IssueListActions({ allResultsVisible, displayReprocessingActions, groupIds, onActionTaken, onDelete, onMarkReviewed, onSelectStatsPeriod, onSortChange, queryCount, query, selection, sort, statsPeriod, }: IssueListActionsProps) { const api = useApi(); const organization = useOrganization(); const { pageSelected, multiSelected, anySelected, allInQuerySelected, selectedIdsSet, selectedProjectSlug, setAllInQuerySelected, } = useSelectedGroupsState(); const disableActions = useMedia(`(max-width: ${theme.breakpoints.small})`); const numIssues = selectedIdsSet.size; function actionSelectedGroups(callback: (itemIds: string[] | undefined) => void) { const selectedIds = allInQuerySelected ? undefined // undefined means "all" : groupIds.filter(itemId => selectedIdsSet.has(itemId)); callback(selectedIds); SelectedGroupStore.deselectAll(); } // TODO: Remove this when merging/deleting performance issues is supported // This silently avoids performance issues for bulk actions const queryExcludingPerformanceIssues = organization.features.includes( 'performance-issues' ) ? `${query ?? ''} issue.category:error` : query; function handleDelete() { actionSelectedGroups(itemIds => { bulkDelete( api, { orgId: organization.slug, itemIds, query: queryExcludingPerformanceIssues, project: selection.projects, environment: selection.environments, ...selection.datetime, }, { complete: () => { onDelete(); }, } ); }); } function handleMerge() { actionSelectedGroups(itemIds => { mergeGroups( api, { orgId: organization.slug, itemIds, query: queryExcludingPerformanceIssues, project: selection.projects, environment: selection.environments, ...selection.datetime, }, {} ); }); } function handleUpdate(data?: any) { const hasIssueListRemovalAction = organization.features.includes( 'issue-list-removal-action' ); actionSelectedGroups(itemIds => { // TODO(Kelly): remove once issue-list-removal-action feature is stable if (!hasIssueListRemovalAction) { addLoadingMessage(t('Saving changes\u2026')); } if (data?.inbox === false) { onMarkReviewed?.(itemIds ?? []); } onActionTaken?.(itemIds ?? []); // If `itemIds` is undefined then it means we expect to bulk update all items // that match the query. // // We need to always respect the projects selected in the global selection header: // * users with no global views requires a project to be specified // * users with global views need to be explicit about what projects the query will run against const projectConstraints = {project: selection.projects}; bulkUpdate( api, { orgId: organization.slug, itemIds, data, query, environment: selection.environments, ...projectConstraints, ...selection.datetime, }, { complete: () => { if (!hasIssueListRemovalAction) { clearIndicators(); } }, } ); }); } return ( {!disableActions && ( SelectedGroupStore.toggleSelectAll()} checked={pageSelected || (anySelected ? 'indeterminate' : false)} disabled={displayReprocessingActions} /> )} {!displayReprocessingActions && ( {!disableActions && ( shouldConfirm(action, {pageSelected, selectedIdsSet}) } onDelete={handleDelete} onMerge={handleMerge} onUpdate={handleUpdate} /> )} )} {!allResultsVisible && pageSelected && ( {allInQuerySelected ? ( queryCount >= BULK_LIMIT ? ( tct( 'Selected up to the first [count] issues that match this search query.', { count: BULK_LIMIT_STR, } ) ) : ( tct('Selected all [count] issues that match this search query.', { count: queryCount, }) ) ) : ( {tn( '%s issue on this page selected.', '%s issues on this page selected.', numIssues )} setAllInQuerySelected(true)} data-test-id="issue-list-select-all-notice-link" > {queryCount >= BULK_LIMIT ? tct( 'Select the first [count] issues that match this search query.', { count: BULK_LIMIT_STR, } ) : tct('Select all [count] issues that match this search query.', { count: queryCount, })} )} )} ); } function useSelectedGroupsState() { const [allInQuerySelected, setAllInQuerySelected] = useState(false); const selectedIds = useLegacyStore(SelectedGroupStore); const selected = SelectedGroupStore.getSelectedIds(); const projects = [...selected] .map(id => GroupStore.get(id)) .filter((group): group is Group => !!(group && group.project)) .map(group => group.project.slug); const uniqProjects = uniq(projects); // we only want selectedProjectSlug set if there is 1 project // more or fewer should result in a null so that the action toolbar // can behave correctly. const selectedProjectSlug = uniqProjects.length === 1 ? uniqProjects[0] : undefined; const pageSelected = SelectedGroupStore.allSelected(); const multiSelected = SelectedGroupStore.multiSelected(); const anySelected = SelectedGroupStore.anySelected(); const selectedIdsSet = SelectedGroupStore.getSelectedIds(); useEffect(() => { setAllInQuerySelected(false); }, [selectedIds]); return { pageSelected, multiSelected, anySelected, allInQuerySelected, selectedIdsSet, selectedProjectSlug, setAllInQuerySelected, }; } function shouldConfirm( action: ConfirmAction, {pageSelected, selectedIdsSet}: {pageSelected: boolean; selectedIdsSet: Set} ) { switch (action) { case ConfirmAction.RESOLVE: case ConfirmAction.UNRESOLVE: case ConfirmAction.IGNORE: case ConfirmAction.UNBOOKMARK: { return pageSelected && selectedIdsSet.size > 1; } case ConfirmAction.BOOKMARK: return selectedIdsSet.size > 1; case ConfirmAction.MERGE: case ConfirmAction.DELETE: default: return true; // By default, should confirm ... } } const Sticky = styled('div')` position: sticky; z-index: ${p => p.theme.zIndex.issuesList.stickyHeader}; top: -1px; `; const StyledFlex = styled('div')` display: flex; min-height: 45px; padding-top: ${space(1)}; padding-bottom: ${space(1)}; align-items: center; background: ${p => p.theme.backgroundSecondary}; border: 1px solid ${p => p.theme.border}; border-top: none; border-radius: ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0 0; margin: 0 -1px -1px; `; const ActionsCheckbox = styled('div')<{isReprocessingQuery: boolean}>` padding-left: ${space(2)}; margin-bottom: 1px; & input[type='checkbox'] { margin: 0; display: block; } ${p => p.isReprocessingQuery && 'flex: 1'}; `; const HeaderButtonsWrapper = styled('div')` @media (min-width: ${p => p.theme.breakpoints.large}) { width: 50%; } flex: 1; margin: 0 ${space(1)}; display: grid; gap: ${space(0.5)}; grid-auto-flow: column; justify-content: flex-start; white-space: nowrap; `; const SelectAllNotice = styled('div')` display: flex; flex-wrap: wrap; justify-content: center; a:not([role='button']) { color: ${p => p.theme.linkColor}; border-bottom: none; } `; const SelectAllLink = styled('a')` margin-left: ${space(1)}; `; export {IssueListActions}; export default IssueListActions;