Browse Source

feat(workflow): Add issue quick filters to project details (#34326)

* feat(workflow): Add issue quick filters to project details

Add issue quick filters to project details.

FIXES WOR-1788

* refactor issueQuery

* update discover query
Kelly Carino 2 years ago
parent
commit
e6a1bfef95

+ 178 - 10
static/app/views/projectDetail/projectIssues.tsx

@@ -1,17 +1,19 @@
-import {Fragment, useState} from 'react';
+import {Fragment, useCallback, useEffect, useState} from 'react';
+import {browserHistory} from 'react-router';
 import styled from '@emotion/styled';
 import {Location} from 'history';
 import pick from 'lodash/pick';
+import * as qs from 'query-string';
 
 import {Client} from 'sentry/api';
-import Button from 'sentry/components/button';
+import Button, {ButtonLabel} from 'sentry/components/button';
 import ButtonBar from 'sentry/components/buttonBar';
-import {SectionHeading} from 'sentry/components/charts/styles';
 import DiscoverButton from 'sentry/components/discoverButton';
 import GroupList from 'sentry/components/issues/groupList';
 import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
 import Pagination from 'sentry/components/pagination';
 import {Panel, PanelBody} from 'sentry/components/panels';
+import QueryCount from 'sentry/components/queryCount';
 import {DEFAULT_RELATIVE_PERIODS, DEFAULT_STATS_PERIOD} from 'sentry/constants';
 import {URL_PARAM} from 'sentry/constants/pageFilters';
 import {t, tct} from 'sentry/locale';
@@ -22,6 +24,30 @@ import {decodeScalar} from 'sentry/utils/queryString';
 
 import NoGroupsHandler from '../issueList/noGroupsHandler';
 
+enum IssuesType {
+  NEW = 'new',
+  UNHANDLED = 'unhandled',
+  REGRESSED = 'regressed',
+  RESOLVED = 'resolved',
+  ALL = 'all',
+}
+
+enum IssuesQuery {
+  NEW = 'is:unresolved is:for_review',
+  UNHANDLED = 'error.unhandled:true is:unresolved',
+  REGRESSED = 'regressed_in_release:latest',
+  RESOLVED = 'is:resolved',
+  ALL = '',
+}
+
+type Count = {
+  all: number;
+  new: number;
+  regressed: number;
+  resolved: number;
+  unhandled: number;
+};
+
 type Props = {
   api: Client;
   location: Location;
@@ -33,6 +59,70 @@ type Props = {
 function ProjectIssues({organization, location, projectId, query, api}: Props) {
   const [pageLinks, setPageLinks] = useState<string | undefined>();
   const [onCursor, setOnCursor] = useState<(() => void) | undefined>();
+  const [issuesType, setIssuesType] = useState<IssuesType | string>(
+    (location.query.issuesType as string) || IssuesType.UNHANDLED
+  );
+  const [issuesCount, setIssuesCount] = useState<Count>({
+    all: 0,
+    new: 0,
+    regressed: 0,
+    resolved: 0,
+    unhandled: 0,
+  });
+
+  const fetchIssuesCount = useCallback(async () => {
+    const getIssueCountEndpoint = queryParameters => {
+      const issuesCountPath = `/organizations/${organization.slug}/issues-count/`;
+
+      return `${issuesCountPath}?${qs.stringify(queryParameters)}`;
+    };
+    const params = [
+      `${IssuesQuery.NEW}`,
+      `${IssuesQuery.ALL}`,
+      `${IssuesQuery.RESOLVED}`,
+      `${IssuesQuery.UNHANDLED}`,
+      `${IssuesQuery.REGRESSED}`,
+    ];
+    const queryParams = params.map(param => param);
+    const queryParameters = {
+      project: projectId,
+      query: queryParams,
+      ...(!location.query.start && {
+        statsPeriod: location.query.statsPeriod || DEFAULT_STATS_PERIOD,
+      }),
+      start: location.query.start,
+      end: location.query.end,
+      environment: location.query.environment,
+      cursor: location.query.cursor,
+    };
+
+    const issueCountEndpoint = getIssueCountEndpoint(queryParameters);
+
+    try {
+      const data = await api.requestPromise(issueCountEndpoint);
+      setIssuesCount({
+        all: data[`${IssuesQuery.ALL}`] || 0,
+        new: data[`${IssuesQuery.NEW}`] || 0,
+        resolved: data[`${IssuesQuery.RESOLVED}`] || 0,
+        unhandled: data[`${IssuesQuery.UNHANDLED}`] || 0,
+        regressed: data[`${IssuesQuery.REGRESSED}`] || 0,
+      });
+    } catch {
+      // do nothing
+    }
+  }, [
+    api,
+    location.query.cursor,
+    location.query.end,
+    location.query.environment,
+    location.query.start,
+    location.query.statsPeriod,
+    organization.slug,
+    projectId,
+  ]);
+  useEffect(() => {
+    fetchIssuesCount();
+  }, [fetchIssuesCount]);
 
   function handleOpenInIssuesClick() {
     trackAnalyticsEvent({
@@ -55,6 +145,11 @@ function ProjectIssues({organization, location, projectId, query, api}: Props) {
     setOnCursor(() => cursorHandler);
   }
 
+  const discoverQuery =
+    issuesType === 'unhandled'
+      ? ['event.type:error error.unhandled:true', query].join(' ').trim()
+      : ['event.type:error', query].join(' ').trim();
+
   function getDiscoverUrl() {
     return {
       pathname: `/organizations/${organization.slug}/discover/results/`,
@@ -62,7 +157,7 @@ function ProjectIssues({organization, location, projectId, query, api}: Props) {
         name: t('Frequent Unhandled Issues'),
         field: ['issue', 'title', 'count()', 'count_unique(user)', 'project'],
         sort: ['-count'],
-        query: ['event.type:error error.unhandled:true', query].join(' ').trim(),
+        query: discoverQuery,
         display: 'top5',
         ...normalizeDateTimeParams(pick(location.query, [...Object.values(URL_PARAM)])),
       },
@@ -70,7 +165,11 @@ function ProjectIssues({organization, location, projectId, query, api}: Props) {
   }
 
   const endpointPath = `/organizations/${organization.slug}/issues/`;
-  const issueQuery = ['is:unresolved error.unhandled:true ', query].join(' ').trim();
+
+  const issueQuery = (Object.values(IssuesType) as string[]).includes(issuesType)
+    ? [`${IssuesQuery[issuesType.toUpperCase()]}`, query].join(' ').trim()
+    : [`${IssuesQuery.ALL}`, query].join(' ').trim();
+
   const queryParams = {
     limit: 5,
     ...normalizeDateTimeParams(
@@ -85,6 +184,19 @@ function ProjectIssues({organization, location, projectId, query, api}: Props) {
     query: queryParams,
   };
 
+  function handleIssuesTypeSelection(issueType: IssuesType) {
+    const to = {
+      ...location,
+      query: {
+        ...location.query,
+        issuesType: issueType,
+      },
+    };
+
+    browserHistory.replace(to);
+    setIssuesType(issueType);
+  }
+
   function renderEmptyMessage() {
     const selectedTimePeriod = location.query.start
       ? null
@@ -104,7 +216,8 @@ function ProjectIssues({organization, location, projectId, query, api}: Props) {
             query={issueQuery}
             selectedProjectIds={[projectId]}
             groupIds={[]}
-            emptyMessage={tct('No unhandled issues for the [timePeriod].', {
+            emptyMessage={tct('No [issuesType] issues for the [timePeriod].', {
+              issuesType: issuesType === 'all' ? '' : issuesType,
               timePeriod: displayedPeriod,
             })}
           />
@@ -113,11 +226,44 @@ function ProjectIssues({organization, location, projectId, query, api}: Props) {
     );
   }
 
+  const issuesTypes = [
+    {value: IssuesType.ALL, label: t('All Issues'), issueCount: issuesCount.all},
+    {value: IssuesType.NEW, label: t('New Issues'), issueCount: issuesCount.new},
+    {
+      value: IssuesType.UNHANDLED,
+      label: t('Unhandled'),
+      issueCount: issuesCount.unhandled,
+    },
+    {
+      value: IssuesType.REGRESSED,
+      label: t('Regressed'),
+      issueCount: issuesCount.regressed,
+    },
+    {
+      value: IssuesType.RESOLVED,
+      label: t('Resolved'),
+      issueCount: issuesCount.resolved,
+    },
+  ];
+
   return (
     <Fragment>
       <ControlsWrapper>
-        <SectionHeading>{t('Frequent Unhandled Issues')}</SectionHeading>
-        <ButtonBar gap={1}>
+        <StyledButtonBar active={issuesType} merged>
+          {issuesTypes.map(({value, label, issueCount}) => (
+            <Button
+              key={value}
+              barId={value}
+              size="xsmall"
+              onClick={() => handleIssuesTypeSelection(value)}
+              data-test-id={`filter-${value}`}
+            >
+              {label}
+              <QueryCount count={issueCount} max={99} hideParens hideIfEmpty={false} />
+            </Button>
+          ))}
+        </StyledButtonBar>
+        <OpenInButtonBar gap={1}>
           <Button
             data-test-id="issues-open"
             size="xsmall"
@@ -134,7 +280,7 @@ function ProjectIssues({organization, location, projectId, query, api}: Props) {
             {t('Open in Discover')}
           </DiscoverButton>
           <StyledPagination pageLinks={pageLinks} onCursor={onCursor} size="xsmall" />
-        </ButtonBar>
+        </OpenInButtonBar>
       </ControlsWrapper>
 
       <GroupList
@@ -154,7 +300,7 @@ function ProjectIssues({organization, location, projectId, query, api}: Props) {
 
 const ControlsWrapper = styled('div')`
   display: flex;
-  align-items: center;
+  align-items: flex-end;
   justify-content: space-between;
   margin-bottom: ${space(1)};
   flex-wrap: wrap;
@@ -163,6 +309,28 @@ const ControlsWrapper = styled('div')`
   }
 `;
 
+const StyledButtonBar = styled(ButtonBar)`
+  grid-template-columns: repeat(4, 1fr);
+  ${ButtonLabel} {
+    white-space: nowrap;
+    gap: ${space(0.5)};
+    span:last-child {
+      color: ${p => p.theme.buttonCount};
+    }
+  }
+  .active {
+    ${ButtonLabel} {
+      span:last-child {
+        color: ${p => p.theme.buttonCountActive};
+      }
+    }
+  }
+`;
+
+const OpenInButtonBar = styled(ButtonBar)`
+  margin-top: ${space(1)};
+`;
+
 const StyledPagination = styled(Pagination)`
   margin: 0;
 `;

+ 7 - 5
tests/js/spec/views/projectDetail/projectIssues.spec.jsx

@@ -18,7 +18,7 @@ describe('ProjectDetail > ProjectIssues', function () {
     });
 
     filteredEndpointMock = MockApiClient.addMockResponse({
-      url: `/organizations/${organization.slug}/issues/?environment=staging&limit=5&query=is%3Aunresolved%20error.unhandled%3Atrue&sort=freq&statsPeriod=7d`,
+      url: `/organizations/${organization.slug}/issues/?environment=staging&limit=5&query=error.unhandled%3Atrue%20is%3Aunresolved&sort=freq&statsPeriod=7d`,
       body: [TestStubs.Group(), TestStubs.Group({id: '2'})],
     });
 
@@ -34,13 +34,15 @@ describe('ProjectDetail > ProjectIssues', function () {
   });
 
   it('renders a list', async function () {
+    MockApiClient.addMockResponse({
+      url: `/organizations/org-slug/issues/?limit=5&query=error.unhandled%3Atrue%20is%3Aunresolved&sort=freq&statsPeriod=14d`,
+      body: [TestStubs.Group(), TestStubs.Group({id: '2'})],
+    });
     wrapper = mountWithTheme(
       <ProjectIssues organization={organization} location={router.location} />,
       routerContext
     );
 
-    expect(wrapper.find('SectionHeading').text()).toBe('Frequent Unhandled Issues');
-
     await tick();
     wrapper.update();
 
@@ -59,7 +61,7 @@ describe('ProjectDetail > ProjectIssues', function () {
       pathname: `/organizations/${organization.slug}/issues/`,
       query: {
         limit: 5,
-        query: 'is:unresolved error.unhandled:true',
+        query: 'error.unhandled:true is:unresolved',
         sort: 'freq',
         statsPeriod: '14d',
       },
@@ -109,7 +111,7 @@ describe('ProjectDetail > ProjectIssues', function () {
         limit: 5,
         environment: 'staging',
         statsPeriod: '7d',
-        query: 'is:unresolved error.unhandled:true',
+        query: 'error.unhandled:true is:unresolved',
         sort: 'freq',
       },
     });