Browse Source

ref(issue-stream): Refactor issue stream action logic with better typing and flexibility (#65443)

Before adding priority to the list of issue actions that remove issues
for the stream (https://github.com/getsentry/sentry/issues/65212), I
wanted to do some cleanup since there is a lot of existing logic here
that can be simplified and typed better.

Some of the changes: 

- The "For Review" tab and other issue actions were using separate code
paths, I've combined them. This means that `onMarkReviewed` was removed,
and the `removeItems` state which was only used by that code path was
also removed.
- Removed the `actionTakenGroupData` state. Instead of storing that in
state, we send it in through the `onActionTaken` callback.
Malachi Willey 1 year ago
parent
commit
fc44acc3bc

+ 1 - 1
static/app/utils/analytics/issueAnalyticsEvents.tsx

@@ -191,7 +191,7 @@ export type IssueEventParameters = {
   };
   'issues_stream.archived': {
     action_status_details?: string;
-    action_substatus?: string;
+    action_substatus?: string | null;
   };
   'issues_stream.issue_assigned': IssueStream & {
     assigned_type: string;

+ 5 - 3
static/app/views/issueList/actions/actionSet.tsx

@@ -18,6 +18,7 @@ import type {IssueTypeConfig} from 'sentry/utils/issueTypeConfig/types';
 import Projects from 'sentry/utils/projects';
 import useMedia from 'sentry/utils/useMedia';
 import useOrganization from 'sentry/utils/useOrganization';
+import type {IssueUpdateData} from 'sentry/views/issueList/types';
 
 import ResolveActions from './resolveActions';
 import ReviewAction from './reviewAction';
@@ -31,7 +32,7 @@ type Props = {
   onDelete: () => void;
   onMerge: () => void;
   onShouldConfirm: (action: ConfirmAction) => boolean;
-  onUpdate: (data?: any) => void;
+  onUpdate: (data: IssueUpdateData) => void;
   query: string;
   queryCount: number;
   selectedProjectSlug?: string;
@@ -165,7 +166,7 @@ function ActionSet({
       onAction: () => {
         openConfirmModal({
           bypass: !onShouldConfirm(ConfirmAction.UNRESOLVE),
-          onConfirm: () => onUpdate({status: GroupStatus.UNRESOLVED}),
+          onConfirm: () => onUpdate({status: GroupStatus.UNRESOLVED, statusDetails: {}}),
           message: confirm({action: ConfirmAction.UNRESOLVE, canBeUndone: true}),
           confirmText: label('unresolve'),
         });
@@ -225,7 +226,8 @@ function ActionSet({
           onClick={() => {
             openConfirmModal({
               bypass: !onShouldConfirm(ConfirmAction.UNRESOLVE),
-              onConfirm: () => onUpdate({status: GroupStatus.UNRESOLVED}),
+              onConfirm: () =>
+                onUpdate({status: GroupStatus.UNRESOLVED, statusDetails: {}}),
               message: confirm({action: ConfirmAction.UNRESOLVE, canBeUndone: true}),
               confirmText: label('unarchive'),
             });

+ 4 - 4
static/app/views/issueList/actions/index.spec.tsx

@@ -300,7 +300,7 @@ describe('IssueListActions', function () {
       expect.anything(),
       expect.objectContaining({
         query: expect.objectContaining({id: ['1'], project: [1]}),
-        data: {status: 'unresolved'},
+        data: {status: 'unresolved', statusDetails: {}},
       })
     );
   });
@@ -327,7 +327,7 @@ describe('IssueListActions', function () {
 
   describe('mark reviewed', function () {
     it('acknowledges group', async function () {
-      const mockOnMarkReviewed = jest.fn();
+      const mockOnActionTaken = jest.fn();
 
       MockApiClient.addMockResponse({
         url: '/organizations/org-slug/issues/',
@@ -347,13 +347,13 @@ describe('IssueListActions', function () {
           },
         });
       });
-      render(<WrappedComponent onMarkReviewed={mockOnMarkReviewed} />);
+      render(<WrappedComponent onActionTaken={mockOnActionTaken} />);
 
       const reviewButton = screen.getByRole('button', {name: 'Mark Reviewed'});
       expect(reviewButton).toBeEnabled();
       await userEvent.click(reviewButton);
 
-      expect(mockOnMarkReviewed).toHaveBeenCalledWith(['1', '2', '3']);
+      expect(mockOnActionTaken).toHaveBeenCalledWith(['1', '2', '3'], {inbox: false});
     });
 
     it('mark reviewed disabled for group that is already reviewed', function () {

+ 17 - 19
static/app/views/issueList/actions/index.tsx

@@ -19,6 +19,7 @@ import useApi from 'sentry/utils/useApi';
 import useMedia from 'sentry/utils/useMedia';
 import useOrganization from 'sentry/utils/useOrganization';
 import {useSyncedLocalStorageState} from 'sentry/utils/useSyncedLocalStorageState';
+import type {IssueUpdateData} from 'sentry/views/issueList/types';
 import {SAVED_SEARCHES_SIDEBAR_OPEN_LOCALSTORAGE_KEY} from 'sentry/views/issueList/utils';
 
 import ActionSet from './actionSet';
@@ -38,8 +39,7 @@ type IssueListActionsProps = {
   selection: PageFilters;
   sort: string;
   statsPeriod: string;
-  onActionTaken?: (itemIds: string[]) => void;
-  onMarkReviewed?: (itemIds: string[]) => void;
+  onActionTaken?: (itemIds: string[], data: IssueUpdateData) => void;
 };
 
 function IssueListActions({
@@ -48,7 +48,6 @@ function IssueListActions({
   groupIds,
   onActionTaken,
   onDelete,
-  onMarkReviewed,
   onSelectStatsPeriod,
   onSortChange,
   queryCount,
@@ -142,15 +141,16 @@ function IssueListActions({
     });
   }
 
-  function handleUpdate(data?: any) {
-    if (data.status === 'ignored') {
-      const statusDetails = data.statusDetails.ignoreCount
-        ? 'ignoreCount'
-        : data.statusDetails.ignoreDuration
-          ? 'ignoreDuration'
-          : data.statusDetails.ignoreUserCount
-            ? 'ignoreUserCount'
-            : undefined;
+  function handleUpdate(data: IssueUpdateData) {
+    if ('status' in data && data.status === 'ignored') {
+      const statusDetails =
+        'ignoreCount' in data.statusDetails
+          ? 'ignoreCount'
+          : 'ignoreDuration' in data.statusDetails
+            ? 'ignoreDuration'
+            : 'ignoreUserCount' in data.statusDetails
+              ? 'ignoreUserCount'
+              : undefined;
       trackAnalytics('issues_stream.archived', {
         action_status_details: statusDetails,
         action_substatus: data.substatus,
@@ -159,12 +159,6 @@ function IssueListActions({
     }
 
     actionSelectedGroups(itemIds => {
-      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.
       //
@@ -184,7 +178,11 @@ function IssueListActions({
           ...projectConstraints,
           ...selection.datetime,
         },
-        {}
+        {
+          complete: () => {
+            onActionTaken?.(itemIds ?? [], data);
+          },
+        }
       );
     });
   }

+ 2 - 1
static/app/views/issueList/actions/reviewAction.tsx

@@ -2,9 +2,10 @@ import ActionLink from 'sentry/components/actions/actionLink';
 import type {TooltipProps} from 'sentry/components/tooltip';
 import {IconIssues} from 'sentry/icons';
 import {t} from 'sentry/locale';
+import type {IssueUpdateData} from 'sentry/views/issueList/types';
 
 type Props = {
-  onUpdate: (data: {inbox: boolean}) => void;
+  onUpdate: (data: IssueUpdateData) => void;
   disabled?: boolean;
   tooltip?: string;
   tooltipProps?: Omit<TooltipProps, 'children' | 'title' | 'skipWrapper'>;

+ 324 - 0
static/app/views/issueList/overview.actions.spec.tsx

@@ -0,0 +1,324 @@
+import {Fragment} from 'react';
+import {GlobalSelectionFixture} from 'sentry-fixture/globalSelection';
+import {GroupFixture} from 'sentry-fixture/group';
+import {GroupStatsFixture} from 'sentry-fixture/groupStats';
+import {LocationFixture} from 'sentry-fixture/locationFixture';
+import {OrganizationFixture} from 'sentry-fixture/organization';
+import {ProjectFixture} from 'sentry-fixture/project';
+import {RouteComponentPropsFixture} from 'sentry-fixture/routeComponentPropsFixture';
+import {TagsFixture} from 'sentry-fixture/tags';
+
+import {
+  render,
+  screen,
+  userEvent,
+  waitFor,
+  within,
+} from 'sentry-test/reactTestingLibrary';
+
+import Indicators from 'sentry/components/indicators';
+import GroupStore from 'sentry/stores/groupStore';
+import IssueListCacheStore from 'sentry/stores/IssueListCacheStore';
+import SelectedGroupStore from 'sentry/stores/selectedGroupStore';
+import TagStore from 'sentry/stores/tagStore';
+import IssueListOverview from 'sentry/views/issueList/overview';
+
+const DEFAULT_LINKS_HEADER =
+  '<http://127.0.0.1:8000/api/0/organizations/org-slug/issues/?cursor=1443575731:0:1>; rel="previous"; results="false"; cursor="1443575731:0:1", ' +
+  '<http://127.0.0.1:8000/api/0/organizations/org-slug/issues/?cursor=1443575000:0:0>; rel="next"; results="true"; cursor="1443575000:0:0"';
+
+describe('IssueListOverview (actions)', function () {
+  const project = ProjectFixture({
+    id: '3559',
+    name: 'Foo Project',
+    slug: 'project-slug',
+    firstEvent: new Date().toISOString(),
+  });
+  const tags = TagsFixture();
+  const groupStats = GroupStatsFixture();
+  const api = new MockApiClient();
+  const organization = OrganizationFixture({features: ['issue-priority-ui']});
+
+  beforeEach(function () {
+    MockApiClient.clearMockResponses();
+    GroupStore.reset();
+    SelectedGroupStore.reset();
+    IssueListCacheStore.reset();
+
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/issues-stats/',
+      body: [groupStats],
+    });
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/searches/',
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/recent-searches/',
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/recent-searches/',
+      method: 'POST',
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/issues-count/',
+      method: 'GET',
+      body: [{}],
+    });
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/processingissues/',
+      method: 'GET',
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/tags/',
+      method: 'GET',
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/users/',
+      method: 'GET',
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/sent-first-event/',
+      body: {sentFirstEvent: true},
+    });
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/projects/',
+      body: [],
+    });
+
+    TagStore.init?.();
+  });
+
+  const defaultProps = {
+    api,
+    savedSearchLoading: false,
+    savedSearches: [],
+    useOrgSavedSearches: true,
+    selection: GlobalSelectionFixture(),
+    organization,
+    tags: [
+      tags.reduce((acc, tag) => {
+        acc[tag.key] = tag;
+
+        return acc;
+      }),
+    ],
+    savedSearch: null,
+    selectedSearchId: null,
+    ...RouteComponentPropsFixture({
+      location: LocationFixture({
+        query: {query: 'is:unresolved issue.priority:[high,medium]'},
+      }),
+      params: {orgId: organization.slug, projectId: project.slug, searchId: undefined},
+    }),
+  };
+
+  describe('status', function () {
+    const group1 = GroupFixture({
+      id: '1',
+      culprit: 'Group 1',
+      shortId: 'JAVASCRIPT-1',
+    });
+    const group2 = GroupFixture({
+      id: '2',
+      culprit: 'Group 2',
+      shortId: 'JAVASCRIPT-2',
+    });
+
+    beforeEach(() => {
+      MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/issues/',
+        body: [group1, group2],
+        headers: {Link: DEFAULT_LINKS_HEADER},
+      });
+    });
+
+    it('removes issues after resolving', async function () {
+      const updateIssueMock = MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/issues/',
+        method: 'PUT',
+      });
+
+      render(<IssueListOverview {...defaultProps} />, {organization});
+
+      const groups = await screen.findAllByTestId('group');
+
+      await userEvent.click(
+        within(groups[0]).getByRole('checkbox', {name: /select issue/i})
+      );
+
+      expect(screen.getByText('Group 1')).toBeInTheDocument();
+      expect(screen.getByText('Group 2')).toBeInTheDocument();
+
+      // After action, will refetch so need to mock that response
+      MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/issues/',
+        body: [group2],
+        headers: {Link: DEFAULT_LINKS_HEADER},
+      });
+
+      await userEvent.click(screen.getByRole('button', {name: 'Resolve'}));
+
+      expect(updateIssueMock).toHaveBeenCalledWith(
+        '/organizations/org-slug/issues/',
+        expect.objectContaining({
+          query: expect.objectContaining({id: ['1']}),
+          data: {status: 'resolved', statusDetails: {}, substatus: null},
+        })
+      );
+
+      await waitFor(() => {
+        expect(screen.queryByText('Group 1')).not.toBeInTheDocument();
+        expect(screen.getByText('Group 2')).toBeInTheDocument();
+      });
+    });
+
+    it('can undo resolve action', async function () {
+      const updateIssueMock = MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/issues/',
+        method: 'PUT',
+      });
+
+      render(
+        <Fragment>
+          <IssueListOverview {...defaultProps} />
+          <Indicators />
+        </Fragment>,
+        {organization}
+      );
+
+      const groups = await screen.findAllByTestId('group');
+
+      await userEvent.click(
+        within(groups[0]).getByRole('checkbox', {name: /select issue/i})
+      );
+
+      expect(screen.getByText('Group 1')).toBeInTheDocument();
+      expect(screen.getByText('Group 2')).toBeInTheDocument();
+
+      // After action, will refetch so need to mock that response
+      MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/issues/',
+        body: [group2],
+        headers: {Link: DEFAULT_LINKS_HEADER},
+      });
+
+      await userEvent.click(screen.getByRole('button', {name: 'Resolve'}));
+
+      expect(updateIssueMock).toHaveBeenCalledWith(
+        '/organizations/org-slug/issues/',
+        expect.objectContaining({
+          query: expect.objectContaining({id: ['1']}),
+          data: {status: 'resolved', statusDetails: {}, substatus: null},
+        })
+      );
+
+      await waitFor(() => {
+        expect(screen.queryByText('Group 1')).not.toBeInTheDocument();
+        expect(screen.getByText('Group 2')).toBeInTheDocument();
+      });
+
+      // Should show a toast message
+      expect(screen.getByText('Resolved JAVASCRIPT-1')).toBeInTheDocument();
+
+      // Clicking the undo button makes a call to set the status back to unresolved
+      MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/issues/',
+        body: [group1, group2],
+        headers: {Link: DEFAULT_LINKS_HEADER},
+      });
+      await userEvent.click(screen.getByRole('button', {name: 'Undo'}));
+      expect(updateIssueMock).toHaveBeenLastCalledWith(
+        '/organizations/org-slug/issues/',
+        expect.objectContaining({
+          query: expect.objectContaining({id: ['1']}),
+          data: {status: 'unresolved', statusDetails: {}},
+        })
+      );
+      expect(await screen.findByText('Group 1')).toBeInTheDocument();
+    });
+  });
+
+  describe('mark reviewed', function () {
+    const group1 = GroupFixture({
+      id: '1',
+      culprit: 'Group 1',
+      shortId: 'JAVASCRIPT-1',
+      inbox: {},
+    });
+    const group2 = GroupFixture({
+      id: '2',
+      culprit: 'Group 2',
+      shortId: 'JAVASCRIPT-2',
+      inbox: {},
+    });
+
+    beforeEach(() => {
+      MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/issues/',
+        body: [group1, group2],
+        headers: {Link: DEFAULT_LINKS_HEADER},
+      });
+    });
+
+    it('removes issues after making reviewed (when on for review tab)', async function () {
+      const updateIssueMock = MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/issues/',
+        method: 'PUT',
+      });
+
+      render(
+        <IssueListOverview
+          {...defaultProps}
+          {...RouteComponentPropsFixture({
+            location: LocationFixture({
+              query: {query: 'is:for_review'},
+            }),
+            params: {
+              orgId: organization.slug,
+              projectId: project.slug,
+              searchId: undefined,
+            },
+          })}
+        />,
+        {organization}
+      );
+
+      const groups = await screen.findAllByTestId('group');
+
+      await userEvent.click(
+        within(groups[0]).getByRole('checkbox', {name: /select issue/i})
+      );
+
+      expect(screen.getByText('Group 1')).toBeInTheDocument();
+      expect(screen.getByText('Group 2')).toBeInTheDocument();
+
+      // After action, will refetch so need to mock that response
+      MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/issues/',
+        body: [group2],
+        headers: {Link: DEFAULT_LINKS_HEADER},
+      });
+
+      await userEvent.click(screen.getByRole('button', {name: 'Mark Reviewed'}));
+
+      expect(updateIssueMock).toHaveBeenCalledWith(
+        '/organizations/org-slug/issues/',
+        expect.objectContaining({
+          query: expect.objectContaining({id: ['1']}),
+          data: {inbox: false},
+        })
+      );
+
+      await waitFor(() => {
+        expect(screen.queryByText('Group 1')).not.toBeInTheDocument();
+        expect(screen.getByText('Group 2')).toBeInTheDocument();
+      });
+    });
+  });
+});

+ 69 - 106
static/app/views/issueList/overview.tsx

@@ -27,7 +27,7 @@ import PanelBody from 'sentry/components/panels/panelBody';
 import QueryCount from 'sentry/components/queryCount';
 import ProcessingIssueList from 'sentry/components/stream/processingIssueList';
 import {DEFAULT_QUERY, DEFAULT_STATS_PERIOD} from 'sentry/constants';
-import {t, tct, tn} from 'sentry/locale';
+import {t, tct} from 'sentry/locale';
 import GroupStore from 'sentry/stores/groupStore';
 import IssueListCacheStore from 'sentry/stores/IssueListCacheStore';
 import SelectedGroupStore from 'sentry/stores/selectedGroupStore';
@@ -40,7 +40,7 @@ import type {
   SavedSearch,
   TagCollection,
 } from 'sentry/types';
-import {IssueCategory} from 'sentry/types';
+import {GroupStatus, IssueCategory} from 'sentry/types';
 import {defined} from 'sentry/utils';
 import {trackAnalytics} from 'sentry/utils/analytics';
 import CursorPoller from 'sentry/utils/cursorPoller';
@@ -62,6 +62,7 @@ import withOrganization from 'sentry/utils/withOrganization';
 import withPageFilters from 'sentry/utils/withPageFilters';
 import withSavedSearches from 'sentry/utils/withSavedSearches';
 import SavedIssueSearches from 'sentry/views/issueList/savedIssueSearches';
+import type {IssueUpdateData} from 'sentry/views/issueList/types';
 
 import IssueListActions from './actions';
 import IssueListFilters from './filters';
@@ -106,11 +107,9 @@ type Props = {
 
 type State = {
   actionTaken: boolean;
-  actionTakenGroupData: Group[];
   error: string | null;
   groupIds: string[];
   issuesLoading: boolean;
-  itemsRemoved: number;
   memberList: ReturnType<typeof indexMembersByProject>;
   pageLinks: string;
   /**
@@ -162,12 +161,10 @@ class IssueListOverview extends Component<Props, State> {
     return {
       groupIds: [],
       actionTaken: false,
-      actionTakenGroupData: [],
       undo: false,
       selectAllActive: false,
       realtimeActive,
       pageLinks: '',
-      itemsRemoved: 0,
       queryCount: 0,
       queryCounts: {},
       queryMaxCount: 0,
@@ -582,7 +579,6 @@ class IssueListOverview extends Component<Props, State> {
         this.setState({
           issuesLoading: true,
           queryCount: 0,
-          itemsRemoved: 0,
           error: null,
         });
       }
@@ -593,7 +589,6 @@ class IssueListOverview extends Component<Props, State> {
         this.setState({
           issuesLoading: true,
           queryCount: 0,
-          itemsRemoved: 0,
           error: null,
         });
       }
@@ -603,7 +598,6 @@ class IssueListOverview extends Component<Props, State> {
     transaction?.setTag('query.sort', this.getSort());
 
     this.setState({
-      itemsRemoved: 0,
       error: null,
     });
 
@@ -809,56 +803,10 @@ class IssueListOverview extends Component<Props, State> {
   listener = GroupStore.listen(() => this.onGroupChange(), undefined);
 
   onGroupChange() {
-    const {actionTakenGroupData} = this.state;
-    const query = this.getQuery();
-
-    if (!this.state.realtimeActive && actionTakenGroupData.length > 0) {
-      const filteredItems = GroupStore.getAllItems().filter(item => {
-        return actionTakenGroupData.findIndex(data => data.id === item.id) !== -1;
-      });
-
-      const resolvedIds = filteredItems
-        .filter(item => item.status === 'resolved')
-        .map(id => id.id);
-      const ignoredIds = filteredItems
-        .filter(item => item.status === 'ignored')
-        .map(i => i.id);
-      // need to include resolve and ignored statuses because marking as resolved/ignored also
-      // counts as reviewed
-      const reviewedIds = filteredItems
-        .filter(
-          item => !item.inbox && item.status !== 'resolved' && item.status !== 'ignored'
-        )
-        .map(i => i.id);
-      // Remove Ignored and Resolved group ids from the issue stream if on the All Unresolved,
-      // For Review, or Ignored tab. Still include on the saved/custom search tab.
-      if (
-        resolvedIds.length > 0 &&
-        (query.includes('is:unresolved') ||
-          query.includes('is:ignored') ||
-          isForReviewQuery(query))
-      ) {
-        this.onIssueAction(resolvedIds, 'Resolved');
-      }
-      if (
-        ignoredIds.length > 0 &&
-        (query.includes('is:unresolved') || isForReviewQuery(query))
-      ) {
-        this.onIssueAction(ignoredIds, 'Archived');
-      }
-      // Remove issues that are marked as Reviewed from the For Review tab, but still include the
-      // issues if on the All Unresolved tab or saved/custom searches.
-      if (
-        reviewedIds.length > 0 &&
-        (isForReviewQuery(query) || query.includes('is:ignored'))
-      ) {
-        this.onIssueAction(reviewedIds, 'Reviewed');
-      }
-    }
-
     const groupIds = GroupStore.getAllItems()
       .map(item => item.id)
       .slice(0, MAX_ISSUES_COUNT);
+
     if (!isEqual(groupIds, this.state.groupIds)) {
       this.setState({groupIds});
     }
@@ -1046,12 +994,10 @@ class IssueListOverview extends Component<Props, State> {
     this.fetchData(true);
   };
 
-  onUndo = () => {
+  undoAction = ({data, groups}: {data: IssueUpdateData; groups: BaseGroup[]}) => {
     const {organization, selection} = this.props;
-    const {actionTakenGroupData} = this.state;
     const query = this.getQuery();
 
-    const groupIds = actionTakenGroupData.map(data => data.id);
     const projectIds = selection?.projects?.map(p => p.toString());
     const endpoint = `/organizations/${organization.slug}/issues/`;
 
@@ -1067,12 +1013,10 @@ class IssueListOverview extends Component<Props, State> {
 
     this.props.api.request(endpoint, {
       method: 'PUT',
-      data: {
-        status: 'unresolved',
-      },
+      data,
       query: {
         project: projectIds,
-        id: groupIds,
+        id: groups.map(group => group.id),
       },
       success: response => {
         if (!response) {
@@ -1082,7 +1026,7 @@ class IssueListOverview extends Component<Props, State> {
         // on this page for a second and then be removed (will show up on All Unresolved). This is to
         // stop this from happening and avoid confusion.
         if (!query.includes('is:ignored') && !isForReviewQuery(query)) {
-          GroupStore.add(actionTakenGroupData);
+          GroupStore.add(groups);
         }
         this.setState({undo: true});
       },
@@ -1093,73 +1037,94 @@ class IssueListOverview extends Component<Props, State> {
         });
       },
       complete: () => {
-        this.setState({actionTakenGroupData: []});
         this.fetchData();
       },
     });
   };
 
-  onMarkReviewed = (itemIds: string[]) => {
+  onActionTaken = (itemIds: string[], data: IssueUpdateData) => {
+    if (this.state.realtimeActive) {
+      return;
+    }
+
     const query = this.getQuery();
+    const groups = itemIds.map(id => GroupStore.get(id)).filter(defined);
+
+    if ('status' in data) {
+      if (data.status === 'resolved') {
+        this.onIssueAction({
+          itemIds,
+          actionType: 'Resolved',
+          shouldRemove:
+            query.includes('is:unresolved') ||
+            query.includes('is:ignored') ||
+            isForReviewQuery(query),
+          undo: () =>
+            this.undoAction({
+              data: {status: GroupStatus.UNRESOLVED, statusDetails: {}},
+              groups,
+            }),
+        });
+        return;
+      }
 
-    if (!isForReviewQuery(query)) {
-      if (itemIds.length > 1) {
-        addMessage(
-          tn('Reviewed %s Issue', 'Reviewed %s Issues', itemIds.length),
-          'success',
-          {duration: 4000}
-        );
-      } else {
-        const shortId = itemIds.map(item => GroupStore.get(item)?.shortId).toString();
-        addMessage(t('Reviewed %s', shortId), 'success', {duration: 4000});
+      if (data.status === 'ignored') {
+        this.onIssueAction({
+          itemIds,
+          actionType: 'Archived',
+          shouldRemove: query.includes('is:unresolved') || isForReviewQuery(query),
+          undo: () =>
+            this.undoAction({
+              data: {status: GroupStatus.UNRESOLVED, statusDetails: {}},
+              groups,
+            }),
+        });
+        return;
       }
-      return;
     }
 
-    const {queryCounts, itemsRemoved} = this.state;
-    const currentQueryCount = queryCounts[query as Query];
-    if (itemIds.length && currentQueryCount) {
-      const inInboxCount = itemIds.filter(id => GroupStore.get(id)?.inbox).length;
-      currentQueryCount.count -= inInboxCount;
-      this.setState({
-        queryCounts: {
-          ...queryCounts,
-          [query as Query]: currentQueryCount,
-        },
-        itemsRemoved: itemsRemoved + inInboxCount,
+    if ('inbox' in data && data.inbox === false) {
+      this.onIssueAction({
+        itemIds,
+        actionType: 'Reviewed',
+        shouldRemove: isForReviewQuery(query),
       });
+      return;
     }
   };
 
-  onActionTaken = (itemIds: string[]) => {
-    const actionTakenGroupData = itemIds
-      .map(id => GroupStore.get(id) as Group | undefined)
-      .filter(defined);
-    this.setState({
-      actionTakenGroupData,
-    });
-  };
-
-  onIssueAction = (
-    itemIds: string[],
-    actionType: 'Reviewed' | 'Resolved' | 'Ignored' | 'Archived'
-  ) => {
+  onIssueAction = ({
+    itemIds,
+    actionType,
+    shouldRemove,
+    undo,
+  }: {
+    actionType: 'Reviewed' | 'Resolved' | 'Ignored' | 'Archived';
+    itemIds: string[];
+    shouldRemove: boolean;
+    undo?: () => void;
+  }) => {
     if (itemIds.length > 1) {
       addMessage(`${actionType} ${itemIds.length} ${t('Issues')}`, 'success', {
         duration: 4000,
-        ...(actionType !== 'Reviewed' && {undo: this.onUndo}),
+        undo,
       });
     } else {
       const shortId = itemIds.map(item => GroupStore.get(item)?.shortId).toString();
       addMessage(`${actionType} ${shortId}`, 'success', {
         duration: 4000,
-        ...(actionType !== 'Reviewed' && {undo: this.onUndo}),
+        undo,
       });
     }
 
+    if (!shouldRemove) {
+      return;
+    }
+
     const links = parseLinkHeader(this.state.pageLinks);
 
     GroupStore.remove(itemIds);
+
     const queryCount = this.state.queryCount - itemIds.length;
     this.setState({
       actionTaken: true,
@@ -1233,14 +1198,13 @@ class IssueListOverview extends Component<Props, State> {
       realtimeActive,
       groupIds,
       queryMaxCount,
-      itemsRemoved,
       issuesLoading,
       error,
     } = this.state;
     const {organization, selection, router} = this.props;
     const query = this.getQuery();
 
-    const modifiedQueryCount = Math.max(queryCount - itemsRemoved, 0);
+    const modifiedQueryCount = Math.max(queryCount, 0);
     const projectIds = selection?.projects?.map(p => p.toString());
 
     const showReprocessingTab = this.displayReprocessingTab();
@@ -1275,7 +1239,6 @@ class IssueListOverview extends Component<Props, State> {
                 query={query}
                 queryCount={modifiedQueryCount}
                 onSelectStatsPeriod={this.onSelectStatsPeriod}
-                onMarkReviewed={this.onMarkReviewed}
                 onActionTaken={this.onActionTaken}
                 onDelete={this.onDelete}
                 statsPeriod={this.getGroupStatsPeriod()}

+ 13 - 1
static/app/views/issueList/types.tsx

@@ -1,3 +1,15 @@
-import type {TagValue} from 'sentry/types';
+import type {
+  GroupStatusResolution,
+  MarkReviewed,
+  PriorityLevel,
+  TagValue,
+} from 'sentry/types';
 
 export type TagValueLoader = (key: string, search: string) => Promise<TagValue[]>;
+
+export type IssueUpdateData =
+  | {isBookmarked: boolean}
+  | {isSubscribed: boolean}
+  | {priority: PriorityLevel}
+  | MarkReviewed
+  | GroupStatusResolution;