Browse Source

feat(teamStats): Split team stats into two tabs (#31385)

Scott Cooper 3 years ago
parent
commit
465fe7c606

+ 28 - 17
static/app/routes.tsx

@@ -1157,25 +1157,37 @@ function buildRoutes() {
   );
 
   const statsRoutes = (
-    <Route
-      path="/organizations/:orgId/stats/"
-      componentPromise={() => import('sentry/views/organizationStats')}
-      component={SafeLazyLoad}
-    />
-  );
-
-  const teamStatsRoutes = (
-    <Route
-      path="/organizations/:orgId/stats/team/"
-      componentPromise={() => import('sentry/views/organizationStats/teamInsights')}
-      component={SafeLazyLoad}
-    >
+    <Route path="/organizations/:orgId/stats/">
       <IndexRoute
-        componentPromise={() =>
-          import('sentry/views/organizationStats/teamInsights/overview')
-        }
+        componentPromise={() => import('sentry/views/organizationStats')}
         component={SafeLazyLoad}
       />
+      <Route
+        path="issues/"
+        componentPromise={() => import('sentry/views/organizationStats/teamInsights')}
+        component={SafeLazyLoad}
+      >
+        <IndexRoute
+          componentPromise={() =>
+            import('sentry/views/organizationStats/teamInsights/issues')
+          }
+          component={SafeLazyLoad}
+        />
+      </Route>
+      <Route
+        path="health/"
+        componentPromise={() => import('sentry/views/organizationStats/teamInsights')}
+        component={SafeLazyLoad}
+      >
+        <IndexRoute
+          componentPromise={() =>
+            import('sentry/views/organizationStats/teamInsights/health')
+          }
+          component={SafeLazyLoad}
+        />
+      </Route>
+
+      <Redirect from="team/" to="/organizations/:orgId/stats/issues/" />
     </Route>
   );
 
@@ -1814,7 +1826,6 @@ function buildRoutes() {
       {releasesRoutes}
       {activityRoutes}
       {statsRoutes}
-      {teamStatsRoutes}
       {discoverRoutes}
       {performanceRoutes}
       {adminManageRoutes}

+ 11 - 5
static/app/views/organizationStats/header.tsx

@@ -10,7 +10,7 @@ import {Organization} from 'sentry/types';
 
 type Props = {
   organization: Organization;
-  activeTab: 'stats' | 'team';
+  activeTab: 'stats' | 'issues' | 'health';
 };
 
 function StatsHeader({organization, activeTab}: Props) {
@@ -20,7 +20,7 @@ function StatsHeader({organization, activeTab}: Props) {
         <StyledLayoutTitle>{t('Stats')}</StyledLayoutTitle>
       </Layout.HeaderContent>
       <Layout.HeaderActions>
-        {activeTab === 'team' && (
+        {activeTab !== 'stats' && (
           <Button
             title={t('Send us feedback via email')}
             size="small"
@@ -36,9 +36,15 @@ function StatsHeader({organization, activeTab}: Props) {
             {t('Usage Stats')}
           </Link>
         </li>
-        <li className={`${activeTab === 'team' ? 'active' : ''}`}>
-          <Link to={`/organizations/${organization.slug}/stats/team/`}>
-            {t('Team Stats')}
+        <li className={`${activeTab === 'issues' ? 'active' : ''}`}>
+          <Link to={`/organizations/${organization.slug}/stats/issues/`}>
+            {t('Issues')}
+            <FeatureBadge type="beta" />
+          </Link>
+        </li>
+        <li className={`${activeTab === 'health' ? 'active' : ''}`}>
+          <Link to={`/organizations/${organization.slug}/stats/health/`}>
+            {t('Health')}
             <FeatureBadge type="beta" />
           </Link>
         </li>

+ 183 - 0
static/app/views/organizationStats/teamInsights/controls.tsx

@@ -0,0 +1,183 @@
+import {RouteComponentProps} from 'react-router';
+import {useTheme} from '@emotion/react';
+import styled from '@emotion/styled';
+import {LocationDescriptorObject} from 'history';
+import pick from 'lodash/pick';
+import moment from 'moment';
+
+import TeamSelector from 'sentry/components/forms/teamSelector';
+import {ChangeData} from 'sentry/components/organizations/timeRangeSelector';
+import PageTimeRangeSelector from 'sentry/components/pageTimeRangeSelector';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {DateString, TeamWithProjects} from 'sentry/types';
+import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser';
+import localStorage from 'sentry/utils/localStorage';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import {dataDatetime} from './utils';
+
+const INSIGHTS_DEFAULT_STATS_PERIOD = '8w';
+
+const PAGE_QUERY_PARAMS = [
+  'pageStatsPeriod',
+  'pageStart',
+  'pageEnd',
+  'pageUtc',
+  'dataCategory',
+  'transform',
+  'sort',
+  'query',
+  'cursor',
+  'team',
+];
+
+type Props = Pick<RouteComponentProps<{orgId: string}, {}>, 'router' | 'location'> & {
+  currentTeam?: TeamWithProjects;
+};
+
+function TeamStatsControls({location, router, currentTeam}: Props) {
+  const organization = useOrganization();
+  const isSuperuser = isActiveSuperuser();
+  const theme = useTheme();
+
+  const query = location?.query ?? {};
+  const localStorageKey = `teamInsightsSelectedTeamId:${organization.slug}`;
+
+  function handleChangeTeam(teamId: string) {
+    localStorage.setItem(localStorageKey, teamId);
+    setStateOnUrl({team: teamId});
+  }
+
+  function handleUpdateDatetime(datetime: ChangeData): LocationDescriptorObject {
+    const {start, end, relative, utc} = datetime;
+
+    if (start && end) {
+      const parser = utc ? moment.utc : moment;
+
+      return setStateOnUrl({
+        pageStatsPeriod: undefined,
+        pageStart: parser(start).format(),
+        pageEnd: parser(end).format(),
+        pageUtc: utc ?? undefined,
+      });
+    }
+
+    return setStateOnUrl({
+      pageStatsPeriod: relative || undefined,
+      pageStart: undefined,
+      pageEnd: undefined,
+      pageUtc: undefined,
+    });
+  }
+
+  function setStateOnUrl(nextState: {
+    pageStatsPeriod?: string | null;
+    pageStart?: DateString;
+    pageEnd?: DateString;
+    pageUtc?: boolean | null;
+    team?: string;
+  }): LocationDescriptorObject {
+    const nextQueryParams = pick(nextState, PAGE_QUERY_PARAMS);
+
+    const nextLocation = {
+      ...location,
+      query: {
+        ...query,
+        ...nextQueryParams,
+      },
+    };
+
+    router.push(nextLocation);
+
+    return nextLocation;
+  }
+
+  const {period, start, end, utc} = dataDatetime(query);
+
+  return (
+    <ControlsWrapper>
+      <StyledTeamSelector
+        name="select-team"
+        inFieldLabel={t('Team: ')}
+        value={currentTeam?.slug}
+        onChange={choice => handleChangeTeam(choice.actor.id)}
+        teamFilter={isSuperuser ? undefined : filterTeam => filterTeam.isMember}
+        styles={{
+          singleValue(provided: any) {
+            const custom = {
+              display: 'flex',
+              justifyContent: 'space-between',
+              alignItems: 'center',
+              fontSize: theme.fontSizeMedium,
+              ':before': {
+                ...provided[':before'],
+                color: theme.textColor,
+                marginRight: space(1.5),
+                marginLeft: space(0.5),
+              },
+            };
+            return {...provided, ...custom};
+          },
+          input: (provided: any, state: any) => ({
+            ...provided,
+            display: 'grid',
+            gridTemplateColumns: 'max-content 1fr',
+            alignItems: 'center',
+            gridGap: space(1),
+            ':before': {
+              backgroundColor: state.theme.backgroundSecondary,
+              height: 24,
+              width: 38,
+              borderRadius: 3,
+              content: '""',
+              display: 'block',
+            },
+          }),
+        }}
+      />
+      <StyledPageTimeRangeSelector
+        organization={organization}
+        relative={period ?? ''}
+        start={start ?? null}
+        end={end ?? null}
+        utc={utc ?? null}
+        onUpdate={handleUpdateDatetime}
+        showAbsolute={false}
+        relativeOptions={{
+          '14d': t('Last 2 weeks'),
+          '4w': t('Last 4 weeks'),
+          [INSIGHTS_DEFAULT_STATS_PERIOD]: t('Last 8 weeks'),
+          '12w': t('Last 12 weeks'),
+        }}
+      />
+    </ControlsWrapper>
+  );
+}
+
+export default TeamStatsControls;
+
+const ControlsWrapper = styled('div')`
+  display: grid;
+  align-items: center;
+  gap: ${space(2)};
+  margin-bottom: ${space(2)};
+
+  @media (min-width: ${p => p.theme.breakpoints[0]}) {
+    grid-template-columns: 246px 1fr;
+  }
+`;
+
+const StyledTeamSelector = styled(TeamSelector)`
+  & > div {
+    box-shadow: ${p => p.theme.dropShadowLight};
+  }
+`;
+
+const StyledPageTimeRangeSelector = styled(PageTimeRangeSelector)`
+  height: 40px;
+
+  div {
+    min-height: unset;
+  }
+`;

+ 160 - 0
static/app/views/organizationStats/teamInsights/health.tsx

@@ -0,0 +1,160 @@
+import {Fragment, useEffect} from 'react';
+import {RouteComponentProps} from 'react-router';
+import styled from '@emotion/styled';
+
+import * as Layout from 'sentry/components/layouts/thirds';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import NoProjectMessage from 'sentry/components/noProjectMessage';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {TeamWithProjects} from 'sentry/types';
+import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
+import localStorage from 'sentry/utils/localStorage';
+import useOrganization from 'sentry/utils/useOrganization';
+import useTeams from 'sentry/utils/useTeams';
+
+import Header from '../header';
+
+import TeamStatsControls from './controls';
+import DescriptionCard from './descriptionCard';
+import TeamAlertsTriggered from './teamAlertsTriggered';
+import TeamMisery from './teamMisery';
+import TeamReleases from './teamReleases';
+import TeamStability from './teamStability';
+import {dataDatetime} from './utils';
+
+type Props = RouteComponentProps<{orgId: string}, {}>;
+
+function TeamStatsHealth({location, router}: Props) {
+  const organization = useOrganization();
+  const {teams, initiallyLoaded} = useTeams({provideUserTeams: true});
+
+  const query = location?.query ?? {};
+  const localStorageKey = `teamInsightsSelectedTeamId:${organization.slug}`;
+
+  let localTeamId: string | null | undefined =
+    query.team ?? localStorage.getItem(localStorageKey);
+  if (localTeamId && !teams.find(team => team.id === localTeamId)) {
+    localTeamId = null;
+  }
+  const currentTeamId = localTeamId ?? teams[0]?.id;
+  const currentTeam = teams.find(team => team.id === currentTeamId) as
+    | TeamWithProjects
+    | undefined;
+  const projects = currentTeam?.projects ?? [];
+
+  useEffect(() => {
+    trackAdvancedAnalyticsEvent('team_insights.viewed', {
+      organization,
+    });
+  }, []);
+
+  const {period, start, end, utc} = dataDatetime(query);
+
+  if (teams.length === 0) {
+    return (
+      <NoProjectMessage organization={organization} superuserNeedsToBeProjectMember />
+    );
+  }
+
+  return (
+    <Fragment>
+      <SentryDocumentTitle title={t('Project Health')} orgSlug={organization.slug} />
+      <Header organization={organization} activeTab="health" />
+
+      <Body>
+        <TeamStatsControls
+          location={location}
+          router={router}
+          currentTeam={currentTeam}
+        />
+
+        {!initiallyLoaded && <LoadingIndicator />}
+        {initiallyLoaded && (
+          <Layout.Main fullWidth>
+            <SectionTitle>{t('Project Health')}</SectionTitle>
+            <DescriptionCard
+              title={t('Crash Free Sessions')}
+              description={t(
+                'The percentage of healthy, errored, and abnormal sessions that didn’t cause a crash.'
+              )}
+            >
+              <TeamStability
+                projects={projects}
+                organization={organization}
+                period={period}
+                start={start}
+                end={end}
+                utc={utc}
+              />
+            </DescriptionCard>
+
+            <DescriptionCard
+              title={t('User Misery')}
+              description={t(
+                'The number of unique users that experienced load times 4x the project’s configured threshold.'
+              )}
+            >
+              <TeamMisery
+                organization={organization}
+                projects={projects}
+                teamId={currentTeam!.id}
+                period={period}
+                start={start?.toString()}
+                end={end?.toString()}
+                location={location}
+              />
+            </DescriptionCard>
+
+            <DescriptionCard
+              title={t('Metric Alerts Triggered')}
+              description={t('Alerts triggered from the Alert Rules your team created.')}
+            >
+              <TeamAlertsTriggered
+                organization={organization}
+                projects={projects}
+                teamSlug={currentTeam!.slug}
+                period={period}
+                start={start?.toString()}
+                end={end?.toString()}
+                location={location}
+              />
+            </DescriptionCard>
+
+            <DescriptionCard
+              title={t('Number of Releases')}
+              description={t('The releases that were created in your team’s projects.')}
+            >
+              <TeamReleases
+                projects={projects}
+                organization={organization}
+                teamSlug={currentTeam!.slug}
+                period={period}
+                start={start}
+                end={end}
+                utc={utc}
+              />
+            </DescriptionCard>
+          </Layout.Main>
+        )}
+      </Body>
+    </Fragment>
+  );
+}
+
+export default TeamStatsHealth;
+
+const Body = styled(Layout.Body)`
+  margin-bottom: -20px;
+
+  @media (min-width: ${p => p.theme.breakpoints[1]}) {
+    display: block;
+  }
+`;
+
+const SectionTitle = styled('h2')`
+  font-size: ${p => p.theme.fontSizeExtraLarge};
+  margin-top: ${space(2)};
+  margin-bottom: ${space(1)};
+`;

+ 5 - 9
static/app/views/organizationStats/teamInsights/index.tsx

@@ -2,8 +2,6 @@ import {cloneElement, isValidElement} from 'react';
 
 import Feature from 'sentry/components/acl/feature';
 import NoProjectMessage from 'sentry/components/noProjectMessage';
-import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
-import {t} from 'sentry/locale';
 import {Organization} from 'sentry/types';
 import withOrganization from 'sentry/utils/withOrganization';
 
@@ -16,13 +14,11 @@ function TeamInsightsContainer({children, organization}: Props) {
   return (
     <Feature organization={organization} features={['team-insights']}>
       <NoProjectMessage organization={organization}>
-        <SentryDocumentTitle title={t('Project Reports')} orgSlug={organization.slug}>
-          {children && isValidElement(children)
-            ? cloneElement(children, {
-                organization,
-              })
-            : (children as React.ReactChild)}
-        </SentryDocumentTitle>
+        {children && isValidElement(children)
+          ? cloneElement(children, {
+              organization,
+            })
+          : (children as React.ReactChild)}
       </NoProjectMessage>
     </Feature>
   );

+ 165 - 0
static/app/views/organizationStats/teamInsights/issues.tsx

@@ -0,0 +1,165 @@
+import {Fragment, useEffect} from 'react';
+import {RouteComponentProps} from 'react-router';
+import styled from '@emotion/styled';
+
+import * as Layout from 'sentry/components/layouts/thirds';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import NoProjectMessage from 'sentry/components/noProjectMessage';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {t} from 'sentry/locale';
+import {TeamWithProjects} from 'sentry/types';
+import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
+import localStorage from 'sentry/utils/localStorage';
+import useOrganization from 'sentry/utils/useOrganization';
+import useTeams from 'sentry/utils/useTeams';
+
+import Header from '../header';
+
+import TeamStatsControls from './controls';
+import DescriptionCard from './descriptionCard';
+import TeamIssuesAge from './teamIssuesAge';
+import TeamIssuesBreakdown from './teamIssuesBreakdown';
+import TeamResolutionTime from './teamResolutionTime';
+import TeamUnresolvedIssues from './teamUnresolvedIssues';
+import {dataDatetime} from './utils';
+
+type Props = RouteComponentProps<{orgId: string}, {}>;
+
+function TeamStatsIssues({location, router}: Props) {
+  const organization = useOrganization();
+  const {teams, initiallyLoaded} = useTeams({provideUserTeams: true});
+
+  const query = location?.query ?? {};
+  const localStorageKey = `teamInsightsSelectedTeamId:${organization.slug}`;
+
+  let localTeamId: string | null | undefined =
+    query.team ?? localStorage.getItem(localStorageKey);
+  if (localTeamId && !teams.find(team => team.id === localTeamId)) {
+    localTeamId = null;
+  }
+  const currentTeamId = localTeamId ?? teams[0]?.id;
+  const currentTeam = teams.find(team => team.id === currentTeamId) as
+    | TeamWithProjects
+    | undefined;
+  const projects = currentTeam?.projects ?? [];
+
+  useEffect(() => {
+    trackAdvancedAnalyticsEvent('team_insights.viewed', {
+      organization,
+    });
+  }, []);
+
+  const {period, start, end, utc} = dataDatetime(query);
+
+  if (teams.length === 0) {
+    return (
+      <NoProjectMessage organization={organization} superuserNeedsToBeProjectMember />
+    );
+  }
+
+  return (
+    <Fragment>
+      <SentryDocumentTitle title={t('Team Issues')} orgSlug={organization.slug} />
+      <Header organization={organization} activeTab="issues" />
+
+      <Body>
+        <TeamStatsControls
+          location={location}
+          router={router}
+          currentTeam={currentTeam}
+        />
+
+        {!initiallyLoaded && <LoadingIndicator />}
+        {initiallyLoaded && (
+          <Layout.Main fullWidth>
+            <DescriptionCard
+              title={t('All Unresolved Issues')}
+              description={t(
+                'This includes New and Returning issues in the last 7 days as well as those that haven’t been resolved or ignored in the past.'
+              )}
+            >
+              <TeamUnresolvedIssues
+                projects={projects}
+                organization={organization}
+                teamSlug={currentTeam!.slug}
+                period={period}
+                start={start}
+                end={end}
+                utc={utc}
+              />
+            </DescriptionCard>
+
+            <DescriptionCard
+              title={t('New and Returning Issues')}
+              description={t(
+                'The new, regressed, and unignored issues that were assigned to your team.'
+              )}
+            >
+              <TeamIssuesBreakdown
+                organization={organization}
+                projects={projects}
+                teamSlug={currentTeam!.slug}
+                period={period}
+                start={start?.toString()}
+                end={end?.toString()}
+                location={location}
+                statuses={['new', 'regressed', 'unignored']}
+              />
+            </DescriptionCard>
+
+            <DescriptionCard
+              title={t('Issues Triaged')}
+              description={t(
+                'How many new and returning issues were reviewed by your team each week. Reviewing an issue includes marking as reviewed, resolving, assigning to another team, or deleting.'
+              )}
+            >
+              <TeamIssuesBreakdown
+                organization={organization}
+                projects={projects}
+                teamSlug={currentTeam!.slug}
+                period={period}
+                start={start?.toString()}
+                end={end?.toString()}
+                location={location}
+                statuses={['resolved', 'ignored', 'deleted']}
+              />
+            </DescriptionCard>
+
+            <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(
+                `The mean time it took for issues to be resolved by your team.`
+              )}
+            >
+              <TeamResolutionTime
+                organization={organization}
+                teamSlug={currentTeam!.slug}
+                period={period}
+                start={start?.toString()}
+                end={end?.toString()}
+                location={location}
+              />
+            </DescriptionCard>
+          </Layout.Main>
+        )}
+      </Body>
+    </Fragment>
+  );
+}
+
+export default TeamStatsIssues;
+
+const Body = styled(Layout.Body)`
+  margin-bottom: -20px;
+
+  @media (min-width: ${p => p.theme.breakpoints[1]}) {
+    display: block;
+  }
+`;

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

@@ -1,453 +0,0 @@
-import {Fragment, useEffect} from 'react';
-import {RouteComponentProps} from 'react-router';
-import {useTheme} from '@emotion/react';
-import styled from '@emotion/styled';
-import {LocationDescriptorObject} from 'history';
-import pick from 'lodash/pick';
-import moment from 'moment';
-
-import {DateTimeObject} from 'sentry/components/charts/utils';
-import TeamSelector from 'sentry/components/forms/teamSelector';
-import * as Layout from 'sentry/components/layouts/thirds';
-import LoadingIndicator from 'sentry/components/loadingIndicator';
-import NoProjectMessage from 'sentry/components/noProjectMessage';
-import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
-import {ChangeData} from 'sentry/components/organizations/timeRangeSelector';
-import PageTimeRangeSelector from 'sentry/components/pageTimeRangeSelector';
-import {t} from 'sentry/locale';
-import space from 'sentry/styles/space';
-import {DateString, TeamWithProjects} from 'sentry/types';
-import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
-import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser';
-import localStorage from 'sentry/utils/localStorage';
-import useOrganization from 'sentry/utils/useOrganization';
-import useTeams from 'sentry/utils/useTeams';
-
-import Header from '../header';
-
-import DescriptionCard from './descriptionCard';
-import TeamAlertsTriggered from './teamAlertsTriggered';
-import TeamIssuesAge from './teamIssuesAge';
-import TeamIssuesBreakdown from './teamIssuesBreakdown';
-import TeamIssuesReviewed from './teamIssuesReviewed';
-import TeamMisery from './teamMisery';
-import TeamReleases from './teamReleases';
-import TeamResolutionTime from './teamResolutionTime';
-import TeamStability from './teamStability';
-import TeamUnresolvedIssues from './teamUnresolvedIssues';
-
-const INSIGHTS_DEFAULT_STATS_PERIOD = '8w';
-
-const PAGE_QUERY_PARAMS = [
-  'pageStatsPeriod',
-  'pageStart',
-  'pageEnd',
-  'pageUtc',
-  'dataCategory',
-  'transform',
-  'sort',
-  'query',
-  'cursor',
-  'team',
-];
-
-type Props = {} & RouteComponentProps<{orgId: string}, {}>;
-
-function TeamInsightsOverview({location, router}: Props) {
-  const isSuperuser = isActiveSuperuser();
-  const organization = useOrganization();
-  const {teams, initiallyLoaded} = useTeams({provideUserTeams: true});
-  const theme = useTheme();
-
-  const query = location?.query ?? {};
-  const localStorageKey = `teamInsightsSelectedTeamId:${organization.slug}`;
-
-  let localTeamId: string | null | undefined =
-    query.team ?? localStorage.getItem(localStorageKey);
-  if (localTeamId && !teams.find(team => team.id === localTeamId)) {
-    localTeamId = null;
-  }
-  const currentTeamId = localTeamId ?? teams[0]?.id;
-  const currentTeam = teams.find(team => team.id === currentTeamId) as
-    | TeamWithProjects
-    | undefined;
-  const projects = currentTeam?.projects ?? [];
-
-  useEffect(() => {
-    trackAdvancedAnalyticsEvent('team_insights.viewed', {
-      organization,
-    });
-  }, []);
-
-  function handleChangeTeam(teamId: string) {
-    localStorage.setItem(localStorageKey, teamId);
-    setStateOnUrl({team: teamId});
-  }
-
-  function handleUpdateDatetime(datetime: ChangeData): LocationDescriptorObject {
-    const {start, end, relative, utc} = datetime;
-
-    if (start && end) {
-      const parser = utc ? moment.utc : moment;
-
-      return setStateOnUrl({
-        pageStatsPeriod: undefined,
-        pageStart: parser(start).format(),
-        pageEnd: parser(end).format(),
-        pageUtc: utc ?? undefined,
-      });
-    }
-
-    return setStateOnUrl({
-      pageStatsPeriod: relative || undefined,
-      pageStart: undefined,
-      pageEnd: undefined,
-      pageUtc: undefined,
-    });
-  }
-
-  function setStateOnUrl(nextState: {
-    pageStatsPeriod?: string | null;
-    pageStart?: DateString;
-    pageEnd?: DateString;
-    pageUtc?: boolean | null;
-    team?: string;
-  }): LocationDescriptorObject {
-    const nextQueryParams = pick(nextState, PAGE_QUERY_PARAMS);
-
-    const nextLocation = {
-      ...location,
-      query: {
-        ...query,
-        ...nextQueryParams,
-      },
-    };
-
-    router.push(nextLocation);
-
-    return nextLocation;
-  }
-
-  function dataDatetime(): DateTimeObject {
-    const {
-      start,
-      end,
-      statsPeriod,
-      utc: utcString,
-    } = normalizeDateTimeParams(query, {
-      allowEmptyPeriod: true,
-      allowAbsoluteDatetime: true,
-      allowAbsolutePageDatetime: true,
-    });
-
-    if (!statsPeriod && !start && !end) {
-      return {period: INSIGHTS_DEFAULT_STATS_PERIOD};
-    }
-
-    // Following getParams, statsPeriod will take priority over start/end
-    if (statsPeriod) {
-      return {period: statsPeriod};
-    }
-
-    const utc = utcString === 'true';
-    if (start && end) {
-      return utc
-        ? {
-            start: moment.utc(start).format(),
-            end: moment.utc(end).format(),
-            utc,
-          }
-        : {
-            start: moment(start).utc().format(),
-            end: moment(end).utc().format(),
-            utc,
-          };
-    }
-
-    return {period: INSIGHTS_DEFAULT_STATS_PERIOD};
-  }
-  const {period, start, end, utc} = dataDatetime();
-
-  if (teams.length === 0) {
-    return (
-      <NoProjectMessage organization={organization} superuserNeedsToBeProjectMember />
-    );
-  }
-
-  const isInsightsV2 = organization.features.includes('team-insights-v2');
-
-  return (
-    <Fragment>
-      <Header organization={organization} activeTab="team" />
-
-      <Body>
-        {!initiallyLoaded && <LoadingIndicator />}
-        {initiallyLoaded && (
-          <Layout.Main fullWidth>
-            <ControlsWrapper>
-              <StyledTeamSelector
-                name="select-team"
-                inFieldLabel={t('Team: ')}
-                value={currentTeam?.slug}
-                onChange={choice => handleChangeTeam(choice.actor.id)}
-                teamFilter={isSuperuser ? undefined : filterTeam => filterTeam.isMember}
-                styles={{
-                  singleValue(provided: any) {
-                    const custom = {
-                      display: 'flex',
-                      justifyContent: 'space-between',
-                      alignItems: 'center',
-                      fontSize: theme.fontSizeMedium,
-                      ':before': {
-                        ...provided[':before'],
-                        color: theme.textColor,
-                        marginRight: space(1.5),
-                        marginLeft: space(0.5),
-                      },
-                    };
-                    return {...provided, ...custom};
-                  },
-                  input: (provided: any, state: any) => ({
-                    ...provided,
-                    display: 'grid',
-                    gridTemplateColumns: 'max-content 1fr',
-                    alignItems: 'center',
-                    gridGap: space(1),
-                    ':before': {
-                      backgroundColor: state.theme.backgroundSecondary,
-                      height: 24,
-                      width: 38,
-                      borderRadius: 3,
-                      content: '""',
-                      display: 'block',
-                    },
-                  }),
-                }}
-              />
-              <StyledPageTimeRangeSelector
-                organization={organization}
-                relative={period ?? ''}
-                start={start ?? null}
-                end={end ?? null}
-                utc={utc ?? null}
-                onUpdate={handleUpdateDatetime}
-                showAbsolute={false}
-                relativeOptions={{
-                  '14d': t('Last 2 weeks'),
-                  '4w': t('Last 4 weeks'),
-                  [INSIGHTS_DEFAULT_STATS_PERIOD]: t('Last 8 weeks'),
-                  '12w': t('Last 12 weeks'),
-                }}
-              />
-            </ControlsWrapper>
-
-            <SectionTitle>{t('Project Health')}</SectionTitle>
-            <DescriptionCard
-              title={t('Crash Free Sessions')}
-              description={t(
-                'The percentage of healthy, errored, and abnormal sessions that didn’t cause a crash.'
-              )}
-            >
-              <TeamStability
-                projects={projects}
-                organization={organization}
-                period={period}
-                start={start}
-                end={end}
-                utc={utc}
-              />
-            </DescriptionCard>
-
-            <DescriptionCard
-              title={t('User Misery')}
-              description={t(
-                'The number of unique users that experienced load times 4x the project’s configured threshold.'
-              )}
-            >
-              <TeamMisery
-                organization={organization}
-                projects={projects}
-                teamId={currentTeam!.id}
-                period={period}
-                start={start?.toString()}
-                end={end?.toString()}
-                location={location}
-              />
-            </DescriptionCard>
-
-            <DescriptionCard
-              title={t('Metric Alerts Triggered')}
-              description={t('Alerts triggered from the Alert Rules your team created.')}
-            >
-              <TeamAlertsTriggered
-                organization={organization}
-                projects={projects}
-                teamSlug={currentTeam!.slug}
-                period={period}
-                start={start?.toString()}
-                end={end?.toString()}
-                location={location}
-              />
-            </DescriptionCard>
-
-            <SectionTitle>{t('Team Activity')}</SectionTitle>
-            {!isInsightsV2 && (
-              <DescriptionCard
-                title={t('Issues Reviewed')}
-                description={t(
-                  'Issues triaged by your team taking an action on them such as resolving, ignoring, marking as reviewed, or deleting.'
-                )}
-              >
-                <TeamIssuesReviewed
-                  organization={organization}
-                  projects={projects}
-                  teamSlug={currentTeam!.slug}
-                  period={period}
-                  start={start?.toString()}
-                  end={end?.toString()}
-                  location={location}
-                />
-              </DescriptionCard>
-            )}
-            {isInsightsV2 && (
-              <DescriptionCard
-                title={t('All Unresolved Issues')}
-                description={t(
-                  'This includes New and Returning issues in the last 7 days as well as those that haven’t been resolved or ignored in the past.'
-                )}
-              >
-                <TeamUnresolvedIssues
-                  projects={projects}
-                  organization={organization}
-                  teamSlug={currentTeam!.slug}
-                  period={period}
-                  start={start}
-                  end={end}
-                  utc={utc}
-                />
-              </DescriptionCard>
-            )}
-            {isInsightsV2 && (
-              <DescriptionCard
-                title={t('New and Returning Issues')}
-                description={t(
-                  'The new, regressed, and unignored issues that were assigned to your team.'
-                )}
-              >
-                <TeamIssuesBreakdown
-                  organization={organization}
-                  projects={projects}
-                  teamSlug={currentTeam!.slug}
-                  period={period}
-                  start={start?.toString()}
-                  end={end?.toString()}
-                  location={location}
-                  statuses={['new', 'regressed', 'unignored']}
-                />
-              </DescriptionCard>
-            )}
-            {isInsightsV2 && (
-              <DescriptionCard
-                title={t('Issues Triaged')}
-                description={t(
-                  'How many new and returning issues were reviewed by your team each week. Reviewing an issue includes marking as reviewed, resolving, assigning to another team, or deleting.'
-                )}
-              >
-                <TeamIssuesBreakdown
-                  organization={organization}
-                  projects={projects}
-                  teamSlug={currentTeam!.slug}
-                  period={period}
-                  start={start?.toString()}
-                  end={end?.toString()}
-                  location={location}
-                  statuses={['resolved', 'ignored', 'deleted']}
-                />
-              </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(
-                `The mean time it took for issues to be resolved by your team.`
-              )}
-            >
-              <TeamResolutionTime
-                organization={organization}
-                teamSlug={currentTeam!.slug}
-                period={period}
-                start={start?.toString()}
-                end={end?.toString()}
-                location={location}
-              />
-            </DescriptionCard>
-            <DescriptionCard
-              title={t('Number of Releases')}
-              description={t('The releases that were created in your team’s projects.')}
-            >
-              <TeamReleases
-                projects={projects}
-                organization={organization}
-                teamSlug={currentTeam!.slug}
-                period={period}
-                start={start}
-                end={end}
-                utc={utc}
-              />
-            </DescriptionCard>
-          </Layout.Main>
-        )}
-      </Body>
-    </Fragment>
-  );
-}
-
-export default TeamInsightsOverview;
-
-const Body = styled(Layout.Body)`
-  margin-bottom: -20px;
-
-  @media (min-width: ${p => p.theme.breakpoints[1]}) {
-    display: block;
-  }
-`;
-
-const ControlsWrapper = styled('div')`
-  display: grid;
-  align-items: center;
-  gap: ${space(2)};
-  margin-bottom: ${space(2)};
-
-  @media (min-width: ${p => p.theme.breakpoints[0]}) {
-    grid-template-columns: 246px 1fr;
-  }
-`;
-
-const StyledTeamSelector = styled(TeamSelector)`
-  & > div {
-    box-shadow: ${p => p.theme.dropShadowLight};
-  }
-`;
-
-const StyledPageTimeRangeSelector = styled(PageTimeRangeSelector)`
-  height: 40px;
-
-  div {
-    min-height: unset;
-  }
-`;
-
-const SectionTitle = styled('h2')`
-  font-size: ${p => p.theme.fontSizeExtraLarge};
-  margin-top: ${space(2)};
-  margin-bottom: ${space(1)};
-`;

+ 0 - 222
static/app/views/organizationStats/teamInsights/teamIssuesReviewed.tsx

@@ -1,222 +0,0 @@
-import {Fragment} from 'react';
-import {css} from '@emotion/react';
-import styled from '@emotion/styled';
-import isEqual from 'lodash/isEqual';
-
-import AsyncComponent from 'sentry/components/asyncComponent';
-import BarChart from 'sentry/components/charts/barChart';
-import {DateTimeObject} from 'sentry/components/charts/utils';
-import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
-import PanelTable from 'sentry/components/panels/panelTable';
-import Placeholder from 'sentry/components/placeholder';
-import {t} from 'sentry/locale';
-import space from 'sentry/styles/space';
-import {Organization, Project} from 'sentry/types';
-import {formatPercentage} from 'sentry/utils/formatters';
-
-import {ProjectBadge, ProjectBadgeContainer} from './styles';
-import {
-  barAxisLabel,
-  convertDaySeriesToWeeks,
-  convertDayValueObjectToSeries,
-} from './utils';
-
-type IssuesBreakdown = Record<string, Record<string, {reviewed: number; total: number}>>;
-
-type Props = AsyncComponent['props'] & {
-  organization: Organization;
-  projects: Project[];
-  teamSlug: string;
-} & DateTimeObject;
-
-type State = AsyncComponent['state'] & {
-  issuesBreakdown: IssuesBreakdown | null;
-};
-
-class TeamIssuesReviewed extends AsyncComponent<Props, State> {
-  shouldRenderBadRequests = true;
-
-  getDefaultState(): State {
-    return {
-      ...super.getDefaultState(),
-      issuesBreakdown: null,
-    };
-  }
-
-  getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
-    const {organization, start, end, period, utc, teamSlug} = this.props;
-    const datetime = {start, end, period, utc};
-
-    return [
-      [
-        'issuesBreakdown',
-        `/teams/${organization.slug}/${teamSlug}/issue-breakdown/`,
-        {
-          query: {
-            ...normalizeDateTimeParams(datetime),
-          },
-        },
-      ],
-    ];
-  }
-
-  componentDidUpdate(prevProps: Props) {
-    const {start, end, period, utc, teamSlug, projects} = this.props;
-
-    if (
-      prevProps.start !== start ||
-      prevProps.end !== end ||
-      prevProps.period !== period ||
-      prevProps.utc !== utc ||
-      prevProps.teamSlug !== teamSlug ||
-      !isEqual(prevProps.projects, projects)
-    ) {
-      this.remountComponent();
-    }
-  }
-
-  renderLoading() {
-    return this.renderBody();
-  }
-
-  renderBody() {
-    const {issuesBreakdown, loading} = this.state;
-    const {projects} = this.props;
-
-    const allReviewedByDay: Record<string, number> = {};
-    const allNotReviewedByDay: Record<string, number> = {};
-
-    // Total reviewed & total reviewed keyed by project ID
-    const projectTotals: Record<string, {reviewed: number; total: number}> = {};
-
-    if (issuesBreakdown) {
-      // The issues breakdown is split into projectId ->
-      for (const [projectId, entries] of Object.entries(issuesBreakdown)) {
-        for (const [bucket, {reviewed, total}] of Object.entries(entries)) {
-          if (!projectTotals[projectId]) {
-            projectTotals[projectId] = {reviewed: 0, total: 0};
-          }
-          projectTotals[projectId].reviewed += reviewed;
-          projectTotals[projectId].total += total;
-
-          if (allReviewedByDay[bucket] === undefined) {
-            allReviewedByDay[bucket] = reviewed;
-          } else {
-            allReviewedByDay[bucket] += reviewed;
-          }
-
-          const notReviewed = total - reviewed;
-          if (allNotReviewedByDay[bucket] === undefined) {
-            allNotReviewedByDay[bucket] = notReviewed;
-          } else {
-            allNotReviewedByDay[bucket] += notReviewed;
-          }
-        }
-      }
-    }
-
-    const reviewedSeries = convertDaySeriesToWeeks(
-      convertDayValueObjectToSeries(allReviewedByDay)
-    );
-    const notReviewedSeries = convertDaySeriesToWeeks(
-      convertDayValueObjectToSeries(allNotReviewedByDay)
-    );
-
-    return (
-      <Fragment>
-        <ChartWrapper>
-          {loading && <Placeholder height="200px" />}
-          {!loading && (
-            <BarChart
-              style={{height: 200}}
-              stacked
-              isGroupedByDate
-              useShortDate
-              legend={{right: 0, top: 0}}
-              xAxis={barAxisLabel(reviewedSeries.length)}
-              yAxis={{minInterval: 1}}
-              series={[
-                {
-                  seriesName: t('Reviewed'),
-                  data: reviewedSeries,
-                  silent: true,
-                  animationDuration: 500,
-                  animationDelay: 0,
-                  barCategoryGap: '5%',
-                },
-                {
-                  seriesName: t('Not Reviewed'),
-                  data: notReviewedSeries,
-                  silent: true,
-                  animationDuration: 500,
-                  animationDelay: 500,
-                  barCategoryGap: '5%',
-                },
-              ]}
-            />
-          )}
-        </ChartWrapper>
-        <StyledPanelTable
-          isEmpty={projects.length === 0}
-          emptyMessage={t('No projects assigned to this team')}
-          headers={[
-            t('Project'),
-            <AlignRight key="forReview">{t('For Review')}</AlignRight>,
-            <AlignRight key="reviewed">{t('Reviewed')}</AlignRight>,
-            <AlignRight key="change">{t('% Reviewed')}</AlignRight>,
-          ]}
-          isLoading={loading}
-        >
-          {projects.map(project => {
-            const {total, reviewed} = projectTotals[project.id] ?? {};
-            return (
-              <Fragment key={project.id}>
-                <ProjectBadgeContainer>
-                  <ProjectBadge avatarSize={18} project={project} />
-                </ProjectBadgeContainer>
-                <AlignRight>{total}</AlignRight>
-                <AlignRight>{reviewed}</AlignRight>
-                <AlignRight>
-                  {total === 0 ? '\u2014' : formatPercentage(reviewed / total)}
-                </AlignRight>
-              </Fragment>
-            );
-          })}
-        </StyledPanelTable>
-      </Fragment>
-    );
-  }
-}
-
-export default TeamIssuesReviewed;
-
-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.2fr 0.2fr 0.2fr;
-  font-size: ${p => p.theme.fontSizeMedium};
-  white-space: nowrap;
-  margin-bottom: 0;
-  border: 0;
-  box-shadow: unset;
-
-  & > div {
-    padding: ${space(1)} ${space(2)};
-  }
-
-  ${p =>
-    p.isEmpty &&
-    css`
-      & > div:last-child {
-        padding: 48px ${space(2)};
-      }
-    `}
-`;
-
-const AlignRight = styled('div')`
-  text-align: right;
-  font-variant-numeric: tabular-nums;
-`;

+ 45 - 0
static/app/views/organizationStats/teamInsights/utils.tsx

@@ -2,6 +2,8 @@ import chunk from 'lodash/chunk';
 import moment from 'moment';
 
 import BaseChart from 'sentry/components/charts/baseChart';
+import {DateTimeObject} from 'sentry/components/charts/utils';
+import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
 import type {SeriesDataUnit} from 'sentry/types/echarts';
 
 /**
@@ -57,3 +59,46 @@ export const barAxisLabel = (
     },
   };
 };
+
+const INSIGHTS_DEFAULT_STATS_PERIOD = '8w';
+
+export function dataDatetime(
+  query: Parameters<typeof normalizeDateTimeParams>[0]
+): DateTimeObject {
+  const {
+    start,
+    end,
+    statsPeriod,
+    utc: utcString,
+  } = normalizeDateTimeParams(query, {
+    allowEmptyPeriod: true,
+    allowAbsoluteDatetime: true,
+    allowAbsolutePageDatetime: true,
+  });
+
+  if (!statsPeriod && !start && !end) {
+    return {period: INSIGHTS_DEFAULT_STATS_PERIOD};
+  }
+
+  // Following getParams, statsPeriod will take priority over start/end
+  if (statsPeriod) {
+    return {period: statsPeriod};
+  }
+
+  const utc = utcString === 'true';
+  if (start && end) {
+    return utc
+      ? {
+          start: moment.utc(start).format(),
+          end: moment.utc(end).format(),
+          utc,
+        }
+      : {
+          start: moment(start).utc().format(),
+          end: moment(end).utc().format(),
+          utc,
+        };
+  }
+
+  return {period: INSIGHTS_DEFAULT_STATS_PERIOD};
+}

+ 6 - 60
tests/js/spec/views/organizationStats/teamInsights/overview.spec.jsx → tests/js/spec/views/organizationStats/teamInsights/health.spec.jsx

@@ -1,23 +1,18 @@
-import {
-  mountWithTheme,
-  screen,
-  userEvent,
-  waitForElementToBeRemoved,
-} from 'sentry-test/reactTestingLibrary';
+import {mountWithTheme, screen, userEvent} from 'sentry-test/reactTestingLibrary';
 
 import ProjectsStore from 'sentry/stores/projectsStore';
 import TeamStore from 'sentry/stores/teamStore';
 import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser';
 import localStorage from 'sentry/utils/localStorage';
 import {OrganizationContext} from 'sentry/views/organizationContext';
-import TeamInsightsOverview from 'sentry/views/organizationStats/teamInsights/overview';
+import TeamStatsHealth from 'sentry/views/organizationStats/teamInsights/health';
 
 jest.mock('sentry/utils/localStorage');
 jest.mock('sentry/utils/isActiveSuperuser', () => ({
   isActiveSuperuser: jest.fn(),
 }));
 
-describe('TeamInsightsOverview', () => {
+describe('TeamStatsHealth', () => {
   const project1 = TestStubs.Project({id: '2', name: 'js', slug: 'js'});
   const project2 = TestStubs.Project({id: '3', name: 'py', slug: 'py'});
   const team1 = TestStubs.Team({
@@ -129,10 +124,6 @@ describe('TeamInsightsOverview', () => {
       url: `/teams/org-slug/${team1.slug}/time-to-resolution/`,
       body: TestStubs.TeamResolutionTime(),
     });
-    MockApiClient.addMockResponse({
-      url: `/teams/org-slug/${team1.slug}/issue-breakdown/`,
-      body: TestStubs.TeamIssuesBreakdown(),
-    });
     MockApiClient.addMockResponse({
       method: 'GET',
       url: `/teams/org-slug/${team1.slug}/release-count/`,
@@ -150,53 +141,11 @@ describe('TeamInsightsOverview', () => {
       url: `/teams/org-slug/${team2.slug}/time-to-resolution/`,
       body: TestStubs.TeamResolutionTime(),
     });
-    MockApiClient.addMockResponse({
-      url: `/teams/org-slug/${team2.slug}/issue-breakdown/`,
-      body: TestStubs.TeamIssuesBreakdown(),
-    });
     MockApiClient.addMockResponse({
       method: 'GET',
       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: [],
-    });
-    const unresolvedStats = {
-      '2021-12-10T00:00:00+00:00': {unresolved: 45},
-      '2021-12-11T00:00:00+00:00': {unresolved: 45},
-      '2021-12-12T00:00:00+00:00': {unresolved: 45},
-      '2021-12-13T00:00:00+00:00': {unresolved: 49},
-      '2021-12-14T00:00:00+00:00': {unresolved: 50},
-      '2021-12-15T00:00:00+00:00': {unresolved: 45},
-      '2021-12-16T00:00:00+00:00': {unresolved: 44},
-      '2021-12-17T00:00:00+00:00': {unresolved: 44},
-      '2021-12-18T00:00:00+00:00': {unresolved: 44},
-      '2021-12-19T00:00:00+00:00': {unresolved: 43},
-      '2021-12-20T00:00:00+00:00': {unresolved: 40},
-      '2021-12-21T00:00:00+00:00': {unresolved: 37},
-      '2021-12-22T00:00:00+00:00': {unresolved: 36},
-      '2021-12-23T00:00:00+00:00': {unresolved: 37},
-    };
-    MockApiClient.addMockResponse({
-      url: `/teams/org-slug/${team1.slug}/all-unresolved-issues/`,
-      body: {
-        2: unresolvedStats,
-      },
-    });
-    MockApiClient.addMockResponse({
-      url: `/teams/org-slug/${team2.slug}/all-unresolved-issues/`,
-      body: {
-        3: unresolvedStats,
-      },
-    });
   });
 
   afterEach(() => {
@@ -209,14 +158,13 @@ describe('TeamInsightsOverview', () => {
     const organization = TestStubs.Organization({
       teams,
       projects,
-      features: ['team-insights-v2'],
     });
     const context = TestStubs.routerContext([{organization}]);
     TeamStore.loadInitialData(teams, false, null);
 
     return mountWithTheme(
       <OrganizationContext.Provider value={organization}>
-        <TeamInsightsOverview router={mockRouter} location={{}} />
+        <TeamStatsHealth router={mockRouter} location={{}} />
       </OrganizationContext.Provider>,
       {
         context,
@@ -224,17 +172,15 @@ describe('TeamInsightsOverview', () => {
     );
   }
 
-  it('defaults to first team', async () => {
+  it('defaults to first team', () => {
     createWrapper();
-    await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator'));
 
     expect(screen.getByText('#backend')).toBeInTheDocument();
     expect(screen.getByText('Key transaction')).toBeInTheDocument();
   });
 
-  it('allows team switching', async () => {
+  it('allows team switching', () => {
     createWrapper();
-    await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator'));
 
     expect(screen.getByText('#backend')).toBeInTheDocument();
     userEvent.type(screen.getByText('#backend'), '{mouseDown}');

Some files were not shown because too many files changed in this diff