Browse Source

feat(ui): Let users navigate to project stats thru main list (ER-1600) (#50430)

[ER-1600](https://getsentry.atlassian.net/jira/software/c/projects/ER/boards/90?modal=detail&selectedIssue=ER-1600&assignee=712020%3A45bb2666-a66d-424e-94cd-55c5c73bdc57)
describes a UX problem in which users have to go through a 3-click
process to access individual project statistics.

This PR adds a button to the end of each row that when clicked, takes
you to that projects statistics, it also moves the settings link into a
button at the end of each row.

It looks like this:
<img width="1145" alt="Screenshot 2023-06-06 at 10 48 28 AM"
src="https://github.com/getsentry/sentry/assets/26236981/f2530c21-21f6-4c9c-b76e-415e3a0695f6">



https://github.com/getsentry/sentry/assets/26236981/6f1c4420-37d0-417b-92b4-ccbaa76cead5



[ER-1600]:
https://getsentry.atlassian.net/browse/ER-1600?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
Eric Hasegawa 1 year ago
parent
commit
7edf845c8f

+ 16 - 0
static/app/views/organizationStats/index.spec.tsx

@@ -353,6 +353,22 @@ describe('OrganizationStats', function () {
     );
   });
 
+  it('renders a project when its graph icon is clicked', async () => {
+    const newOrg = initializeOrg();
+    newOrg.organization.features = [
+      'global-views',
+      'team-insights',
+      // TODO(Leander): Remove the following check once the project-stats flag is GA
+      'project-stats',
+    ];
+    render(<OrganizationStats {...defaultProps} organization={newOrg.organization} />, {
+      context: newOrg.routerContext,
+    });
+    await userEvent.click(screen.getByTestId('proj-1'));
+    expect(screen.queryByText('My Projects')).not.toBeInTheDocument();
+    expect(screen.getAllByText('proj-1').length).toBe(2);
+  });
+
   /**
    * Feature Flagging
    */

+ 17 - 16
static/app/views/organizationStats/usageStatsProjects.tsx

@@ -255,21 +255,23 @@ class UsageStatsProjects extends AsyncComponent<Props, State> {
         direction: getArrowDirection(SortBy.DROPPED),
         onClick: () => this.handleChangeSort(SortBy.DROPPED),
       },
-    ].map(h => {
-      const Cell = h.key === SortBy.PROJECT ? CellProject : CellStat;
-
-      return (
-        <Cell key={h.key}>
-          <SortLink
-            canSort
-            title={h.title}
-            align={h.align as Alignments}
-            direction={h.direction}
-            generateSortLink={h.onClick}
-          />
-        </Cell>
-      );
-    });
+    ]
+      .map(h => {
+        const Cell = h.key === SortBy.PROJECT ? CellProject : CellStat;
+
+        return (
+          <Cell key={h.key}>
+            <SortLink
+              canSort
+              title={h.title}
+              align={h.align as Alignments}
+              direction={h.direction}
+              generateSortLink={h.onClick}
+            />
+          </Cell>
+        );
+      })
+      .concat([<CellStat key="empty" />]); // Extra column for displaying buttons etc.
   }
 
   getProjectLink(project: Project) {
@@ -423,7 +425,6 @@ class UsageStatsProjects extends AsyncComponent<Props, State> {
     const {error, errors, loading} = this.state;
     const {dataCategory, loadingProjects, tableQuery, isSingleProject} = this.props;
     const {headers, tableStats} = this.tableData;
-
     return (
       <Fragment>
         {isSingleProject && (

+ 54 - 17
static/app/views/organizationStats/usageTable/index.tsx

@@ -1,6 +1,9 @@
 import {Component} from 'react';
+import {WithRouterProps} from 'react-router';
 import styled from '@emotion/styled';
 
+import {updateProjects} from 'sentry/actionCreators/pageFilters';
+import {Button} from 'sentry/components/button';
 import ErrorPanel from 'sentry/components/charts/errorPanel';
 import EmptyMessage from 'sentry/components/emptyMessage';
 import IdBadge from 'sentry/components/idBadge';
@@ -8,10 +11,11 @@ import ExternalLink from 'sentry/components/links/externalLink';
 import Link from 'sentry/components/links/link';
 import {Panel} from 'sentry/components/panels';
 import PanelTable from 'sentry/components/panels/panelTable';
-import {IconSettings, IconWarning} from 'sentry/icons';
+import {IconGraph, IconSettings, IconWarning} from 'sentry/icons';
 import {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {DataCategoryInfo, Project} from 'sentry/types';
+import withSentryRouter from 'sentry/utils/withSentryRouter';
 
 import {formatUsageWithUnits, getFormatUsageOptions} from '../utils';
 
@@ -22,12 +26,10 @@ type Props = {
   headers: React.ReactNode[];
   usageStats: TableStat[];
   errors?: Record<string, Error>;
-
   isEmpty?: boolean;
-
   isError?: boolean;
   isLoading?: boolean;
-};
+} & WithRouterProps<{}, {}>;
 
 export type TableStat = {
   accepted: number;
@@ -57,6 +59,14 @@ class UsageTable extends Component<Props> {
     return <IconWarning color="gray300" legacySize="48px" />;
   };
 
+  loadProject(projectId: number) {
+    updateProjects([projectId], this.props.router, {
+      save: true,
+      environments: [], // Clear environments when switching projects
+    });
+    window.scrollTo({top: 0, left: 0, behavior: 'smooth'});
+  }
+
   renderTableRow(stat: TableStat & {project: Project}) {
     const {dataCategory} = this.props;
     const {project, total, accepted, filtered, dropped} = stat;
@@ -72,9 +82,6 @@ class UsageTable extends Component<Props> {
             displayName={project.slug}
           />
         </Link>
-        <SettingsIconLink to={stat.projectSettingsLink}>
-          <IconSettings size="sm" />
-        </SettingsIconLink>
       </CellProject>,
       <CellStat key={1}>
         {formatUsageWithUnits(total, dataCategory, getFormatUsageOptions(dataCategory))}
@@ -96,6 +103,23 @@ class UsageTable extends Component<Props> {
       <CellStat key={4}>
         {formatUsageWithUnits(dropped, dataCategory, getFormatUsageOptions(dataCategory))}
       </CellStat>,
+      <CellStat key={5}>
+        <Button
+          data-test-id={project.slug}
+          size="sm"
+          onClick={() => {
+            this.loadProject(parseInt(stat.project.id, 10));
+          }}
+        >
+          <StyledIconGraph type="bar" size="sm" />
+          <span>View Stats</span>
+        </Button>
+        <Link to={stat.projectSettingsLink}>
+          <StyledSettingsButton size="sm">
+            <SettingsIcon size="sm" />
+          </StyledSettingsButton>
+        </Link>
+      </CellStat>,
     ];
   }
 
@@ -118,26 +142,25 @@ class UsageTable extends Component<Props> {
   }
 }
 
-export default UsageTable;
+export default withSentryRouter(UsageTable);
 
 const StyledPanelTable = styled(PanelTable)`
-  grid-template-columns: repeat(5, auto);
+  grid-template-columns: repeat(6, auto);
 
   @media (min-width: ${p => p.theme.breakpoints.small}) {
-    grid-template-columns: 1fr repeat(4, minmax(0, auto));
+    grid-template-columns: 1fr repeat(5, minmax(0, auto));
   }
 `;
 
 export const CellStat = styled('div')`
-  flex-shrink: 1;
-  text-align: right;
+  display: flex;
+  align-items: center;
   font-variant-numeric: tabular-nums;
+  justify-content: right;
 `;
 
 export const CellProject = styled(CellStat)`
-  display: flex;
-  align-items: center;
-  text-align: left;
+  justify-content: left;
 `;
 
 const StyledIdBadge = styled(IdBadge)`
@@ -146,12 +169,12 @@ const StyledIdBadge = styled(IdBadge)`
   flex-shrink: 1;
 `;
 
-const SettingsIconLink = styled(Link)`
+const SettingsIcon = styled(IconSettings)`
   color: ${p => p.theme.gray300};
   align-items: center;
   display: inline-flex;
   justify-content: space-between;
-  margin-right: ${space(1.5)};
+  margin-right: ${space(1.0)};
   margin-left: ${space(1.0)};
   transition: 0.5s opacity ease-out;
 
@@ -159,3 +182,17 @@ const SettingsIconLink = styled(Link)`
     color: ${p => p.theme.textColor};
   }
 `;
+
+const StyledIconGraph = styled(IconGraph)`
+  color: ${p => p.theme.gray300};
+  margin-right: 5px;
+  &:hover {
+    color: ${p => p.theme.textColor};
+    cursor: pointer;
+  }
+`;
+
+const StyledSettingsButton = styled(Button)`
+  padding: 0px;
+  margin-left: 7px;
+`;