import {Component, Fragment} 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 {Client} from 'sentry/api'; 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 space from 'sentry/styles/space'; import {Group, Organization, PageFilters} from 'sentry/types'; import {callIfFunction} from 'sentry/utils/callIfFunction'; import withApi from 'sentry/utils/withApi'; import ActionSet from './actionSet'; import Headers from './headers'; import {BULK_LIMIT, BULK_LIMIT_STR, ConfirmAction} from './utils'; type Props = { allResultsVisible: boolean; api: Client; displayCount: React.ReactNode; displayReprocessingActions: boolean; groupIds: string[]; onDelete: () => void; onSelectStatsPeriod: (period: string) => void; onSortChange: (sort: string) => void; organization: Organization; query: string; queryCount: number; selection: PageFilters; sort: string; statsPeriod: string; onActionTaken?: (itemIds: string[]) => void; onMarkReviewed?: (itemIds: string[]) => void; }; type State = { allInQuerySelected: boolean; anySelected: boolean; multiSelected: boolean; pageSelected: boolean; selectedIds: Set; selectedProjectSlug?: string; }; class IssueListActions extends Component { state: State = { anySelected: false, multiSelected: false, // more than one selected pageSelected: false, // all on current page selected (e.g. 25) allInQuerySelected: false, // all in current search query selected (e.g. 1000+) selectedIds: new Set(), }; componentDidMount() { this.handleSelectedGroupChange(); } componentWillUnmount() { callIfFunction(this.listener); } listener = SelectedGroupStore.listen(() => this.handleSelectedGroupChange(), undefined); actionSelectedGroups(callback: (itemIds: string[] | undefined) => void) { let selectedIds: string[] | undefined; if (this.state.allInQuerySelected) { selectedIds = undefined; // undefined means "all" } else { const itemIdSet = SelectedGroupStore.getSelectedIds(); selectedIds = this.props.groupIds.filter(itemId => itemIdSet.has(itemId)); } callback(selectedIds); this.deselectAll(); } deselectAll() { SelectedGroupStore.deselectAll(); this.setState({allInQuerySelected: false}); } // Handler for when `SelectedGroupStore` changes handleSelectedGroupChange() { 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; this.setState({ pageSelected: SelectedGroupStore.allSelected(), multiSelected: SelectedGroupStore.multiSelected(), anySelected: SelectedGroupStore.anySelected(), allInQuerySelected: false, // any change resets selectedIds: SelectedGroupStore.getSelectedIds(), selectedProjectSlug, }); } handleSelectStatsPeriod = (period: string) => { return this.props.onSelectStatsPeriod(period); }; handleApplyToAll = () => { this.setState({allInQuerySelected: true}); }; handleUpdate = (data?: any) => { const {selection, api, organization, query, onMarkReviewed, onActionTaken} = this.props; const orgId = organization.slug; const hasIssueListRemovalAction = organization.features.includes( 'issue-list-removal-action' ); this.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, itemIds, data, query, environment: selection.environments, ...projectConstraints, ...selection.datetime, }, { complete: () => { if (!hasIssueListRemovalAction) { clearIndicators(); } }, } ); }); }; handleDelete = () => { const {selection, api, organization, query, onDelete} = this.props; const orgId = organization.slug; this.actionSelectedGroups(itemIds => { bulkDelete( api, { orgId, itemIds, query, project: selection.projects, environment: selection.environments, ...selection.datetime, }, { complete: () => { onDelete(); }, } ); }); }; handleMerge = () => { const {selection, api, organization, query} = this.props; const orgId = organization.slug; this.actionSelectedGroups(itemIds => { mergeGroups( api, { orgId, itemIds, query, project: selection.projects, environment: selection.environments, ...selection.datetime, }, {} ); }); }; handleSelectAll() { SelectedGroupStore.toggleSelectAll(); } shouldConfirm = (action: ConfirmAction) => { const selectedItems = SelectedGroupStore.getSelectedIds(); switch (action) { case ConfirmAction.RESOLVE: case ConfirmAction.UNRESOLVE: case ConfirmAction.IGNORE: case ConfirmAction.UNBOOKMARK: { const {pageSelected} = this.state; return pageSelected && selectedItems.size > 1; } case ConfirmAction.BOOKMARK: return selectedItems.size > 1; case ConfirmAction.MERGE: case ConfirmAction.DELETE: default: return true; // By default, should confirm ... } }; render() { const { allResultsVisible, queryCount, query, statsPeriod, selection, organization, displayReprocessingActions, } = this.props; const { allInQuerySelected, anySelected, pageSelected, selectedIds: issues, multiSelected, selectedProjectSlug, } = this.state; const numIssues = issues.size; return ( {!displayReprocessingActions && ( )} {!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 )} {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, })} )} )} ); } } const Sticky = styled('div')` position: sticky; z-index: ${p => p.theme.zIndex.issuesList.stickyHeader}; top: -1px; `; const StyledFlex = styled('div')` display: flex; box-sizing: border-box; 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 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 withApi(IssueListActions);