|
@@ -1,34 +1,32 @@
|
|
|
-import {Component, Fragment} from 'react';
|
|
|
+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 {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 {useLegacyStore} from 'sentry/stores/useLegacyStore';
|
|
|
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 {Group, PageFilters} from 'sentry/types';
|
|
|
+import useApi from 'sentry/utils/useApi';
|
|
|
+import useOrganization from 'sentry/utils/useOrganization';
|
|
|
|
|
|
import ActionSet from './actionSet';
|
|
|
import Headers from './headers';
|
|
|
import {BULK_LIMIT, BULK_LIMIT_STR, ConfirmAction} from './utils';
|
|
|
|
|
|
-type Props = {
|
|
|
+type IssueListActionsProps = {
|
|
|
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;
|
|
@@ -38,96 +36,91 @@ type Props = {
|
|
|
onMarkReviewed?: (itemIds: string[]) => void;
|
|
|
};
|
|
|
|
|
|
-type State = {
|
|
|
- allInQuerySelected: boolean;
|
|
|
- anySelected: boolean;
|
|
|
- multiSelected: boolean;
|
|
|
- pageSelected: boolean;
|
|
|
- selectedIds: Set<string>;
|
|
|
- selectedProjectSlug?: string;
|
|
|
-};
|
|
|
-
|
|
|
-class IssueListActions extends Component<Props, State> {
|
|
|
- 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));
|
|
|
- }
|
|
|
+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 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);
|
|
|
|
|
|
- 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,
|
|
|
+ function handleDelete() {
|
|
|
+ const orgId = organization.slug;
|
|
|
+
|
|
|
+ actionSelectedGroups(itemIds => {
|
|
|
+ bulkDelete(
|
|
|
+ api,
|
|
|
+ {
|
|
|
+ orgId,
|
|
|
+ itemIds,
|
|
|
+ query,
|
|
|
+ project: selection.projects,
|
|
|
+ environment: selection.environments,
|
|
|
+ ...selection.datetime,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ complete: () => {
|
|
|
+ onDelete();
|
|
|
+ },
|
|
|
+ }
|
|
|
+ );
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- handleSelectStatsPeriod = (period: string) => {
|
|
|
- return this.props.onSelectStatsPeriod(period);
|
|
|
- };
|
|
|
-
|
|
|
- handleApplyToAll = () => {
|
|
|
- this.setState({allInQuerySelected: true});
|
|
|
- };
|
|
|
+ function handleMerge() {
|
|
|
+ actionSelectedGroups(itemIds => {
|
|
|
+ mergeGroups(
|
|
|
+ api,
|
|
|
+ {
|
|
|
+ orgId: organization.slug,
|
|
|
+ itemIds,
|
|
|
+ query,
|
|
|
+ project: selection.projects,
|
|
|
+ environment: selection.environments,
|
|
|
+ ...selection.datetime,
|
|
|
+ },
|
|
|
+ {}
|
|
|
+ );
|
|
|
+ });
|
|
|
+ }
|
|
|
|
|
|
- handleUpdate = (data?: any) => {
|
|
|
- const {selection, api, organization, query, onMarkReviewed, onActionTaken} =
|
|
|
- this.props;
|
|
|
- const orgId = organization.slug;
|
|
|
+ function handleUpdate(data?: any) {
|
|
|
const hasIssueListRemovalAction = organization.features.includes(
|
|
|
'issue-list-removal-action'
|
|
|
);
|
|
|
|
|
|
- this.actionSelectedGroups(itemIds => {
|
|
|
+ actionSelectedGroups(itemIds => {
|
|
|
// TODO(Kelly): remove once issue-list-removal-action feature is stable
|
|
|
if (!hasIssueListRemovalAction) {
|
|
|
addLoadingMessage(t('Saving changes\u2026'));
|
|
@@ -150,7 +143,7 @@ class IssueListActions extends Component<Props, State> {
|
|
|
bulkUpdate(
|
|
|
api,
|
|
|
{
|
|
|
- orgId,
|
|
|
+ orgId: organization.slug,
|
|
|
itemIds,
|
|
|
data,
|
|
|
query,
|
|
@@ -167,178 +160,146 @@ class IssueListActions extends Component<Props, State> {
|
|
|
}
|
|
|
);
|
|
|
});
|
|
|
- };
|
|
|
-
|
|
|
- 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 (
|
|
|
- <Sticky>
|
|
|
- <StyledFlex>
|
|
|
- <ActionsCheckbox isReprocessingQuery={displayReprocessingActions}>
|
|
|
- <Checkbox
|
|
|
- onChange={this.handleSelectAll}
|
|
|
- checked={pageSelected || (anySelected ? 'indeterminate' : false)}
|
|
|
- disabled={displayReprocessingActions}
|
|
|
- />
|
|
|
- </ActionsCheckbox>
|
|
|
- {!displayReprocessingActions && (
|
|
|
- <ActionSet
|
|
|
- sort={this.props.sort}
|
|
|
- onSortChange={this.props.onSortChange}
|
|
|
- orgSlug={organization.slug}
|
|
|
- queryCount={queryCount}
|
|
|
- query={query}
|
|
|
- issues={issues}
|
|
|
- allInQuerySelected={allInQuerySelected}
|
|
|
- anySelected={anySelected}
|
|
|
- multiSelected={multiSelected}
|
|
|
- selectedProjectSlug={selectedProjectSlug}
|
|
|
- onShouldConfirm={this.shouldConfirm}
|
|
|
- onDelete={this.handleDelete}
|
|
|
- onMerge={this.handleMerge}
|
|
|
- onUpdate={this.handleUpdate}
|
|
|
- />
|
|
|
- )}
|
|
|
- <Headers
|
|
|
- onSelectStatsPeriod={this.handleSelectStatsPeriod}
|
|
|
+ return (
|
|
|
+ <Sticky>
|
|
|
+ <StyledFlex>
|
|
|
+ <ActionsCheckbox isReprocessingQuery={displayReprocessingActions}>
|
|
|
+ <Checkbox
|
|
|
+ onChange={() => SelectedGroupStore.toggleSelectAll()}
|
|
|
+ checked={pageSelected || (anySelected ? 'indeterminate' : false)}
|
|
|
+ disabled={displayReprocessingActions}
|
|
|
+ />
|
|
|
+ </ActionsCheckbox>
|
|
|
+ {!displayReprocessingActions && (
|
|
|
+ <ActionSet
|
|
|
+ sort={sort}
|
|
|
+ onSortChange={onSortChange}
|
|
|
+ orgSlug={organization.slug}
|
|
|
+ queryCount={queryCount}
|
|
|
+ query={query}
|
|
|
+ issues={selectedIdsSet}
|
|
|
+ allInQuerySelected={allInQuerySelected}
|
|
|
anySelected={anySelected}
|
|
|
- selection={selection}
|
|
|
- statsPeriod={statsPeriod}
|
|
|
- isReprocessingQuery={displayReprocessingActions}
|
|
|
+ multiSelected={multiSelected}
|
|
|
+ selectedProjectSlug={selectedProjectSlug}
|
|
|
+ onShouldConfirm={action =>
|
|
|
+ shouldConfirm(action, {pageSelected, selectedIdsSet})
|
|
|
+ }
|
|
|
+ onDelete={handleDelete}
|
|
|
+ onMerge={handleMerge}
|
|
|
+ onUpdate={handleUpdate}
|
|
|
/>
|
|
|
- </StyledFlex>
|
|
|
- {!allResultsVisible && pageSelected && (
|
|
|
- <Alert type="warning" system>
|
|
|
- <SelectAllNotice data-test-id="issue-list-select-all-notice">
|
|
|
- {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,
|
|
|
- })
|
|
|
+ )}
|
|
|
+ <Headers
|
|
|
+ onSelectStatsPeriod={onSelectStatsPeriod}
|
|
|
+ anySelected={anySelected}
|
|
|
+ selection={selection}
|
|
|
+ statsPeriod={statsPeriod}
|
|
|
+ isReprocessingQuery={displayReprocessingActions}
|
|
|
+ />
|
|
|
+ </StyledFlex>
|
|
|
+ {!allResultsVisible && pageSelected && (
|
|
|
+ <Alert type="warning" system>
|
|
|
+ <SelectAllNotice data-test-id="issue-list-select-all-notice">
|
|
|
+ {allInQuerySelected ? (
|
|
|
+ queryCount >= BULK_LIMIT ? (
|
|
|
+ tct(
|
|
|
+ 'Selected up to the first [count] issues that match this search query.',
|
|
|
+ {
|
|
|
+ count: BULK_LIMIT_STR,
|
|
|
+ }
|
|
|
)
|
|
|
) : (
|
|
|
- <Fragment>
|
|
|
- {tn(
|
|
|
- '%s issue on this page selected.',
|
|
|
- '%s issues on this page selected.',
|
|
|
- numIssues
|
|
|
- )}
|
|
|
- <SelectAllLink
|
|
|
- onClick={this.handleApplyToAll}
|
|
|
- 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,
|
|
|
- })}
|
|
|
- </SelectAllLink>
|
|
|
- </Fragment>
|
|
|
- )}
|
|
|
- </SelectAllNotice>
|
|
|
- </Alert>
|
|
|
- )}
|
|
|
- </Sticky>
|
|
|
- );
|
|
|
+ tct('Selected all [count] issues that match this search query.', {
|
|
|
+ count: queryCount,
|
|
|
+ })
|
|
|
+ )
|
|
|
+ ) : (
|
|
|
+ <Fragment>
|
|
|
+ {tn(
|
|
|
+ '%s issue on this page selected.',
|
|
|
+ '%s issues on this page selected.',
|
|
|
+ numIssues
|
|
|
+ )}
|
|
|
+ <SelectAllLink
|
|
|
+ onClick={() => 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,
|
|
|
+ })}
|
|
|
+ </SelectAllLink>
|
|
|
+ </Fragment>
|
|
|
+ )}
|
|
|
+ </SelectAllNotice>
|
|
|
+ </Alert>
|
|
|
+ )}
|
|
|
+ </Sticky>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+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<string>}
|
|
|
+) {
|
|
|
+ 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 ...
|
|
|
}
|
|
|
}
|
|
|
|
|
@@ -388,4 +349,4 @@ const SelectAllLink = styled('a')`
|
|
|
|
|
|
export {IssueListActions};
|
|
|
|
|
|
-export default withApi(IssueListActions);
|
|
|
+export default IssueListActions;
|