Browse Source

feat(teamStats): Add team issues age histogram (#30578)

Adds a new component to the team insights page, this one calls two apis. One to fill in the histogram like chart of unresolved issues bucketed by age and the top 7 oldest issues in the table.
Scott Cooper 3 years ago
parent
commit
c342f6ec7d

+ 13 - 0
static/app/views/organizationStats/teamInsights/overview.tsx

@@ -27,6 +27,7 @@ import Header from '../header';
 
 import DescriptionCard from './descriptionCard';
 import TeamAlertsTriggered from './teamAlertsTriggered';
+import TeamIssuesAge from './teamIssuesAge';
 import TeamIssuesReviewed from './teamIssuesReviewed';
 import TeamMisery from './teamMisery';
 import TeamReleases from './teamReleases';
@@ -171,6 +172,8 @@ function TeamInsightsOverview({location, router}: Props) {
     );
   }
 
+  const isInsightsV2 = organization.features.includes('team-insights-v2');
+
   return (
     <Fragment>
       <Header organization={organization} activeTab="team" />
@@ -301,6 +304,16 @@ function TeamInsightsOverview({location, router}: Props) {
                 location={location}
               />
             </DescriptionCard>
+            {isInsightsV2 && (
+              <DescriptionCard
+                title={t('Age of Unresolved Issues')}
+                description={t(
+                  'How long ago since unresolved issues were first created.'
+                )}
+              >
+                <TeamIssuesAge organization={organization} teamSlug={currentTeam!.slug} />
+              </DescriptionCard>
+            )}
             <DescriptionCard
               title={t('Time to Resolution')}
               description={t(

+ 233 - 0
static/app/views/organizationStats/teamInsights/teamIssuesAge.tsx

@@ -0,0 +1,233 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+import moment from 'moment';
+
+import AsyncComponent from 'sentry/components/asyncComponent';
+import BarChart from 'sentry/components/charts/barChart';
+import Count from 'sentry/components/count';
+import ProjectBadge from 'sentry/components/idBadge/projectBadge';
+import Link from 'sentry/components/links/link';
+import PanelTable from 'sentry/components/panels/panelTable';
+import Placeholder from 'sentry/components/placeholder';
+import TimeSince from 'sentry/components/timeSince';
+import {IconArrow} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import overflowEllipsis from 'sentry/styles/overflowEllipsis';
+import space from 'sentry/styles/space';
+import {Group, Organization} from 'sentry/types';
+import {getTitle} from 'sentry/utils/events';
+
+type Props = AsyncComponent['props'] & {
+  organization: Organization;
+  teamSlug: string;
+};
+
+type State = AsyncComponent['state'] & {
+  oldestIssues: Group[] | null;
+  unresolvedIssueAge: Record<string, number> | null;
+};
+
+/**
+ * takes "< 1 hour" and returns a datetime of 1 hour ago
+ */
+function parseBucket(bucket: string): number {
+  if (bucket === '> 1 year') {
+    return moment().subtract(1, 'y').subtract(1, 'd').valueOf();
+  }
+
+  const [_, num, unit] = bucket.split(' ');
+  return moment()
+    .subtract(num, unit as any)
+    .valueOf();
+}
+
+const bucketLabels = {
+  '< 1 hour': t('1 hour'),
+  '< 4 hour': t('4 hours'),
+  '< 12 hour': t('12 hours'),
+  '< 1 day': t('1 day'),
+  '< 1 week': t('1 week'),
+  '< 4 week': t('1 month'),
+  '< 24 week': t('6 months'),
+  '< 1 year': t('1 year'),
+  '> 1 year': t('> 1 year'),
+};
+
+class TeamIssuesAge extends AsyncComponent<Props, State> {
+  shouldRenderBadRequests = true;
+
+  getDefaultState(): State {
+    return {
+      ...super.getDefaultState(),
+      oldestIssues: null,
+      unresolvedIssueAge: null,
+    };
+  }
+
+  getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
+    const {organization, teamSlug} = this.props;
+
+    return [
+      [
+        'oldestIssues',
+        `/teams/${organization.slug}/${teamSlug}/issues/old/`,
+        {query: {limit: 7}},
+      ],
+      [
+        'unresolvedIssueAge',
+        `/teams/${organization.slug}/${teamSlug}/unresolved-issue-age/`,
+      ],
+    ];
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    const {teamSlug} = this.props;
+
+    if (prevProps.teamSlug !== teamSlug) {
+      this.remountComponent();
+    }
+  }
+
+  renderLoading() {
+    return this.renderBody();
+  }
+
+  renderBody() {
+    const {organization} = this.props;
+    const {unresolvedIssueAge, oldestIssues, loading} = this.state;
+
+    const seriesData = Object.entries(unresolvedIssueAge ?? {})
+      .map(([bucket, value]) => ({
+        name: bucket,
+        value,
+      }))
+      .sort((a, b) => parseBucket(b.name) - parseBucket(a.name));
+
+    return (
+      <div>
+        <ChartWrapper>
+          {loading && <Placeholder height="200px" />}
+          {!loading && (
+            <BarChart
+              style={{height: 190}}
+              legend={{right: 3, top: 0}}
+              yAxis={{minInterval: 1}}
+              xAxis={{
+                splitNumber: seriesData.length,
+                type: 'category',
+                min: 0,
+                axisLabel: {
+                  showMaxLabel: true,
+                  showMinLabel: true,
+                  formatter: (bucket: string) => {
+                    return bucketLabels[bucket] ?? bucket;
+                  },
+                },
+              }}
+              series={[
+                {
+                  seriesName: t('Unresolved Issues'),
+                  silent: true,
+                  data: seriesData,
+                },
+              ]}
+            />
+          )}
+        </ChartWrapper>
+        <StyledPanelTable
+          isEmpty={!oldestIssues || oldestIssues.length === 0}
+          headers={[
+            t('Oldest Issues'),
+            <RightAligned key="events">{t('Events')}</RightAligned>,
+            <RightAligned key="users">{t('Users')}</RightAligned>,
+            <RightAligned key="age">
+              {t('Age')} <IconArrow direction="down" size="12px" color="gray300" />
+            </RightAligned>,
+          ]}
+          isLoading={loading}
+        >
+          {oldestIssues?.map(issue => {
+            const {title} = getTitle(issue, organization?.features, false);
+
+            return (
+              <Fragment key={issue.id}>
+                <ProjectTitleContainer>
+                  <ShadowlessProjectBadge
+                    disableLink
+                    hideName
+                    avatarSize={18}
+                    project={issue.project}
+                  />
+                  <TitleOverflow>
+                    <Link
+                      to={{
+                        pathname: `/organizations/${organization.slug}/issues/${issue.id}/`,
+                      }}
+                    >
+                      {title}
+                    </Link>
+                  </TitleOverflow>
+                </ProjectTitleContainer>
+                <RightAligned>
+                  <Count value={issue.count} />
+                </RightAligned>
+                <RightAligned>
+                  <Count value={issue.userCount} />
+                </RightAligned>
+                <RightAligned>
+                  <TimeSince date={issue.firstSeen} />
+                </RightAligned>
+              </Fragment>
+            );
+          })}
+        </StyledPanelTable>
+      </div>
+    );
+  }
+}
+
+export default TeamIssuesAge;
+
+const ChartWrapper = styled('div')`
+  padding: ${space(2)} ${space(2)} 0 ${space(2)};
+  border-bottom: 1px solid ${p => p.theme.border};
+`;
+
+const StyledPanelTable = styled(PanelTable)`
+  grid-template-columns: 1fr 0.15fr 0.15fr 0.25fr;
+  white-space: nowrap;
+  margin-bottom: 0;
+  border: 0;
+  font-size: ${p => p.theme.fontSizeMedium};
+  box-shadow: unset;
+
+  > * {
+    padding: ${space(1)} ${space(2)};
+  }
+`;
+
+const RightAligned = styled('span')`
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+`;
+
+const ProjectTitleContainer = styled('div')`
+  ${overflowEllipsis};
+  display: flex;
+  align-items: center;
+`;
+
+const TitleOverflow = styled('div')`
+  ${overflowEllipsis};
+`;
+
+const ShadowlessProjectBadge = styled(ProjectBadge)`
+  display: inline-flex;
+  align-items: center;
+  margin-right: ${space(1)};
+
+  * > img {
+    box-shadow: none;
+  }
+`;

+ 15 - 1
tests/js/spec/views/organizationStats/teamInsights/overview.spec.jsx

@@ -119,6 +119,16 @@ describe('TeamInsightsOverview', () => {
       url: `/teams/org-slug/${team2.slug}/release-count/`,
       body: [],
     });
+    MockApiClient.addMockResponse({
+      method: 'GET',
+      url: `/teams/org-slug/${team2.slug}/issues/old/`,
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      method: 'GET',
+      url: `/teams/org-slug/${team2.slug}/unresolved-issue-age/`,
+      body: [],
+    });
   });
 
   afterEach(() => {
@@ -128,7 +138,11 @@ describe('TeamInsightsOverview', () => {
   function createWrapper() {
     const teams = [team1, team2, team3];
     const projects = [project1, project2];
-    const organization = TestStubs.Organization({teams, projects});
+    const organization = TestStubs.Organization({
+      teams,
+      projects,
+      features: ['team-insights-v2'],
+    });
     const context = TestStubs.routerContext([{organization}]);
     TeamStore.loadInitialData(teams, false, null);
 

+ 38 - 0
tests/js/spec/views/organizationStats/teamInsights/teamIssuesAge.spec.jsx

@@ -0,0 +1,38 @@
+import {mountWithTheme, screen} from 'sentry-test/reactTestingLibrary';
+
+import TeamIssuesAge from 'sentry/views/organizationStats/teamInsights/teamIssuesAge';
+
+describe('TeamIssuesAge', () => {
+  it('should render graph with table of oldest issues', () => {
+    const team = TestStubs.Team();
+    const organization = TestStubs.Organization();
+    const timeToResolutionApi = MockApiClient.addMockResponse({
+      url: `/teams/${organization.slug}/${team.slug}/unresolved-issue-age/`,
+      body: {
+        '< 1 hour': 1,
+        '< 4 hour': 5,
+        '< 12 hour': 20,
+        '< 1 day': 80,
+        '< 1 week': 30,
+        '< 4 week': 100,
+        '< 24 week': 50,
+        '< 1 year': 100,
+        '> 1 year': 10,
+      },
+    });
+    const issuesApi = MockApiClient.addMockResponse({
+      url: `/teams/${organization.slug}/${team.slug}/issues/old/`,
+      body: [TestStubs.Group()],
+    });
+    mountWithTheme(<TeamIssuesAge organization={organization} teamSlug={team.slug} />);
+
+    // Title
+    expect(screen.getByText('RequestError')).toBeInTheDocument();
+    // Event count
+    expect(screen.getByText('327k')).toBeInTheDocument();
+    // User count
+    expect(screen.getByText('35k')).toBeInTheDocument();
+    expect(timeToResolutionApi).toHaveBeenCalledTimes(1);
+    expect(issuesApi).toHaveBeenCalledTimes(1);
+  });
+});