Browse Source

feat(ui): Allow project selector on project detail page (#23690)

This PR enables switching projects in the global header on the project detail page.
Matej Minar 4 years ago
parent
commit
aeb2810477

+ 1 - 1
src/sentry/static/sentry/app/components/organizations/globalSelectionHeader/globalSelectionHeader.tsx

@@ -135,7 +135,7 @@ type Props = {
 
   // Callbacks //
   onChangeProjects?: (val: number[]) => void;
-  onUpdateProjects?: () => void;
+  onUpdateProjects?: (selectedProjects: number[]) => void;
   onChangeEnvironments?: (environments: Environment[]) => void;
   onUpdateEnvironments?: (environments: Environment[]) => void;
   onChangeTime?: (datetime: any) => void;

+ 6 - 1
src/sentry/static/sentry/app/views/projectDetail/index.tsx

@@ -8,7 +8,12 @@ import withOrganization from 'app/utils/withOrganization';
 
 import ProjectDetail from './projectDetail';
 
-function ProjectDetailContainer(props: ProjectDetail['props']) {
+function ProjectDetailContainer(
+  props: Omit<
+    React.ComponentProps<typeof ProjectDetail>,
+    'projects' | 'loadingProjects' | 'selection'
+  >
+) {
   function renderNoAccess() {
     return (
       <PageContent>

+ 110 - 25
src/sentry/static/sentry/app/views/projectDetail/projectDetail.tsx

@@ -2,7 +2,9 @@ import React from 'react';
 import {RouteComponentProps} from 'react-router';
 import styled from '@emotion/styled';
 
+import {updateProjects} from 'app/actionCreators/globalSelection';
 import Feature from 'app/components/acl/feature';
+import Alert from 'app/components/alert';
 import Breadcrumbs from 'app/components/breadcrumbs';
 import Button from 'app/components/button';
 import ButtonBar from 'app/components/buttonBar';
@@ -13,11 +15,14 @@ import * as Layout from 'app/components/layouts/thirds';
 import LightWeightNoProjectMessage from 'app/components/lightWeightNoProjectMessage';
 import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader';
 import TextOverflow from 'app/components/textOverflow';
-import {IconSettings} from 'app/icons';
+import {IconSettings, IconWarning} from 'app/icons';
 import {t} from 'app/locale';
 import {PageContent} from 'app/styles/organization';
-import {Organization, Project} from 'app/types';
+import {GlobalSelection, Organization, Project} from 'app/types';
+import {defined} from 'app/utils';
 import routeTitleGen from 'app/utils/routeTitle';
+import withGlobalSelection from 'app/utils/withGlobalSelection';
+import withProjects from 'app/utils/withProjects';
 import AsyncView from 'app/views/asyncView';
 
 import ProjectScoreCards from './projectScoreCards/projectScoreCards';
@@ -35,11 +40,12 @@ type RouteParams = {
 
 type Props = RouteComponentProps<RouteParams, {}> & {
   organization: Organization;
+  projects: Project[];
+  loadingProjects: boolean;
+  selection: GlobalSelection;
 };
 
-type State = {
-  project: Project | null;
-} & AsyncView['state'];
+type State = AsyncView['state'];
 
 class ProjectDetail extends AsyncView<Props, State> {
   getTitle() {
@@ -48,26 +54,95 @@ class ProjectDetail extends AsyncView<Props, State> {
     return routeTitleGen(t('Project %s', params.projectId), params.orgId, false);
   }
 
-  getEndpoints(): ReturnType<AsyncView['getEndpoints']> {
-    const {params} = this.props;
+  componentDidMount() {
+    this.syncProjectWithSlug();
+  }
+
+  componentDidUpdate() {
+    this.syncProjectWithSlug();
+  }
+
+  get project() {
+    const {projects, params} = this.props;
+
+    return projects.find(p => p.slug === params.projectId);
+  }
 
-    if (this.state?.project) {
-      return [];
+  handleProjectChange = (selectedProjects: number[]) => {
+    const {projects, router, location, organization} = this.props;
+
+    const newlySelectedProject = projects.find(p => p.id === String(selectedProjects[0]));
+
+    // if we change project in global header, we need to sync the project slug in the URL
+    if (newlySelectedProject?.id) {
+      router.replace({
+        pathname: `/organizations/${organization.slug}/projects/${newlySelectedProject.slug}/`,
+        query: {
+          ...location.query,
+          project: newlySelectedProject.id,
+          environment: undefined,
+        },
+      });
     }
+  };
+
+  syncProjectWithSlug() {
+    const {router, location} = this.props;
+    const projectId = this.project?.id;
+
+    if (projectId && projectId !== location.query.project) {
+      // if someone visits /organizations/sentry/projects/javascript/ (without ?project=XXX) we need to update URL and globalSelection with the right project ID
+      updateProjects([Number(projectId)], router);
+    }
+  }
+
+  isProjectStabilized() {
+    const {selection, location} = this.props;
+    const projectId = this.project?.id;
 
-    return [['project', `/projects/${params.orgId}/${params.projectId}/`]];
+    return (
+      defined(projectId) &&
+      projectId === location.query.project &&
+      projectId === String(selection.projects[0])
+    );
   }
 
   renderLoading() {
     return this.renderBody();
   }
 
+  renderProjectNotFound() {
+    return (
+      <PageContent>
+        <Alert type="error" icon={<IconWarning />}>
+          {t('This project could not be found.')}
+        </Alert>
+      </PageContent>
+    );
+  }
+
   renderBody() {
-    const {organization, params, location, router} = this.props;
-    const {project} = this.state;
+    const {
+      organization,
+      params,
+      location,
+      router,
+      loadingProjects,
+      selection,
+    } = this.props;
+    const project = this.project;
+    const isProjectStabilized = this.isProjectStabilized();
+
+    if (!loadingProjects && !project) {
+      return this.renderProjectNotFound();
+    }
 
     return (
-      <GlobalSelectionHeader shouldForceProject forceProject={project}>
+      <GlobalSelectionHeader
+        disableMultipleProjectSelection
+        skipLoadLastUsed
+        onUpdateProjects={this.handleProjectChange}
+      >
         <LightWeightNoProjectMessage organization={organization}>
           <StyledPageContent>
             <Layout.Header>
@@ -122,17 +197,25 @@ class ProjectDetail extends AsyncView<Props, State> {
             <Layout.Body>
               <StyledSdkUpdatesAlert />
               <Layout.Main>
-                <ProjectScoreCards organization={organization} />
-                {[0, 1].map(id => (
-                  <ProjectCharts
-                    location={location}
-                    organization={organization}
-                    router={router}
-                    key={`project-charts-${id}`}
-                    index={id}
-                  />
-                ))}
-                <ProjectIssues organization={organization} location={location} />
+                <ProjectScoreCards
+                  organization={organization}
+                  isProjectStabilized={isProjectStabilized}
+                  selection={selection}
+                />
+                {isProjectStabilized && (
+                  <React.Fragment>
+                    {[0, 1].map(id => (
+                      <ProjectCharts
+                        location={location}
+                        organization={organization}
+                        router={router}
+                        key={`project-charts-${id}`}
+                        index={id}
+                      />
+                    ))}
+                    <ProjectIssues organization={organization} location={location} />
+                  </React.Fragment>
+                )}
               </Layout.Main>
               <Layout.Side>
                 <ProjectTeamAccess organization={organization} project={project} />
@@ -141,6 +224,7 @@ class ProjectDetail extends AsyncView<Props, State> {
                     organization={organization}
                     projectSlug={params.projectId}
                     location={location}
+                    isProjectStabilized={isProjectStabilized}
                   />
                 </Feature>
                 <ProjectLatestReleases
@@ -148,6 +232,7 @@ class ProjectDetail extends AsyncView<Props, State> {
                   projectSlug={params.projectId}
                   projectId={project?.id}
                   location={location}
+                  isProjectStabilized={isProjectStabilized}
                 />
                 <ProjectQuickLinks
                   organization={organization}
@@ -177,4 +262,4 @@ StyledSdkUpdatesAlert.defaultProps = {
   Wrapper: p => <Layout.Main fullWidth {...p} />,
 };
 
-export default ProjectDetail;
+export default withProjects(withGlobalSelection(ProjectDetail));

+ 28 - 5
src/sentry/static/sentry/app/views/projectDetail/projectLatestAlerts.tsx

@@ -21,12 +21,14 @@ import {Incident, IncidentStatus} from '../alerts/types';
 
 import MissingAlertsButtons from './missingFeatureButtons/missingAlertsButtons';
 import {SectionHeadingLink, SectionHeadingWrapper, SidebarSection} from './styles';
+import {didProjectOrEnvironmentChange} from './utils';
 
 type Props = AsyncComponent['props'] & {
   organization: Organization;
   projectSlug: string;
   location: Location;
   theme: Theme;
+  isProjectStabilized: boolean;
 };
 
 type State = {
@@ -37,10 +39,12 @@ type State = {
 
 class ProjectLatestAlerts extends AsyncComponent<Props, State> {
   shouldComponentUpdate(nextProps: Props, nextState: State) {
+    const {location, isProjectStabilized} = this.props;
     // TODO(project-detail): we temporarily removed refetching based on timeselector
     if (
       this.state !== nextState ||
-      this.props.location.query.environment !== nextProps.location.query.environment
+      didProjectOrEnvironmentChange(location, nextProps.location) ||
+      isProjectStabilized !== nextProps.isProjectStabilized
     ) {
       return true;
     }
@@ -48,8 +52,23 @@ class ProjectLatestAlerts extends AsyncComponent<Props, State> {
     return false;
   }
 
+  componentDidUpdate(prevProps: Props) {
+    const {location, isProjectStabilized} = this.props;
+
+    if (
+      didProjectOrEnvironmentChange(prevProps.location, location) ||
+      prevProps.isProjectStabilized !== isProjectStabilized
+    ) {
+      this.remountComponent();
+    }
+  }
+
   getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
-    const {location, organization} = this.props;
+    const {location, organization, isProjectStabilized} = this.props;
+
+    if (!isProjectStabilized) {
+      return [];
+    }
 
     const query = {
       ...pick(location.query, Object.values(URL_PARAM)),
@@ -76,7 +95,11 @@ class ProjectLatestAlerts extends AsyncComponent<Props, State> {
    */
   async onLoadAllEndpointsSuccess() {
     const {unresolvedAlerts, resolvedAlerts} = this.state;
-    const {location, organization} = this.props;
+    const {location, organization, isProjectStabilized} = this.props;
+
+    if (!isProjectStabilized) {
+      return;
+    }
 
     if ([...(unresolvedAlerts ?? []), ...(resolvedAlerts ?? [])].length !== 0) {
       this.setState({hasAlertRule: true});
@@ -152,7 +175,7 @@ class ProjectLatestAlerts extends AsyncComponent<Props, State> {
   };
 
   renderInnerBody() {
-    const {organization, projectSlug} = this.props;
+    const {organization, projectSlug, isProjectStabilized} = this.props;
     const {loading, unresolvedAlerts, resolvedAlerts, hasAlertRule} = this.state;
     const alertsUnresolvedAndResolved = [
       ...(unresolvedAlerts ?? []),
@@ -160,7 +183,7 @@ class ProjectLatestAlerts extends AsyncComponent<Props, State> {
     ];
     const checkingForAlertRules =
       alertsUnresolvedAndResolved.length === 0 && hasAlertRule === undefined;
-    const showLoadingIndicator = loading || checkingForAlertRules;
+    const showLoadingIndicator = loading || checkingForAlertRules || !isProjectStabilized;
 
     if (showLoadingIndicator) {
       return <Placeholder height="172px" />;

+ 29 - 5
src/sentry/static/sentry/app/views/projectDetail/projectLatestReleases.tsx

@@ -21,11 +21,13 @@ import {RELEASES_TOUR_STEPS} from 'app/views/releases/list/releaseLanding';
 
 import MissingReleasesButtons from './missingFeatureButtons/missingReleasesButtons';
 import {SectionHeadingLink, SectionHeadingWrapper, SidebarSection} from './styles';
+import {didProjectOrEnvironmentChange} from './utils';
 
 type Props = AsyncComponent['props'] & {
   organization: Organization;
   projectSlug: string;
   location: Location;
+  isProjectStabilized: boolean;
   projectId?: string;
 };
 
@@ -36,10 +38,12 @@ type State = {
 
 class ProjectLatestReleases extends AsyncComponent<Props, State> {
   shouldComponentUpdate(nextProps: Props, nextState: State) {
+    const {location, isProjectStabilized} = this.props;
     // TODO(project-detail): we temporarily removed refetching based on timeselector
     if (
       this.state !== nextState ||
-      this.props.location.query.environment !== nextProps.location.query.environment
+      didProjectOrEnvironmentChange(location, nextProps.location) ||
+      isProjectStabilized !== nextProps.isProjectStabilized
     ) {
       return true;
     }
@@ -47,8 +51,23 @@ class ProjectLatestReleases extends AsyncComponent<Props, State> {
     return false;
   }
 
+  componentDidUpdate(prevProps: Props) {
+    const {location, isProjectStabilized} = this.props;
+
+    if (
+      didProjectOrEnvironmentChange(prevProps.location, location) ||
+      prevProps.isProjectStabilized !== isProjectStabilized
+    ) {
+      this.remountComponent();
+    }
+  }
+
   getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
-    const {location, organization, projectSlug} = this.props;
+    const {location, organization, projectSlug, isProjectStabilized} = this.props;
+
+    if (!isProjectStabilized) {
+      return [];
+    }
 
     const query = {
       ...pick(location.query, Object.values(URL_PARAM)),
@@ -66,7 +85,11 @@ class ProjectLatestReleases extends AsyncComponent<Props, State> {
    */
   async onLoadAllEndpointsSuccess() {
     const {releases} = this.state;
-    const {organization, projectId} = this.props;
+    const {organization, projectId, isProjectStabilized} = this.props;
+
+    if (!isProjectStabilized) {
+      return;
+    }
 
     if ((releases ?? []).length !== 0 || !projectId) {
       this.setState({hasOlderReleases: true});
@@ -129,11 +152,12 @@ class ProjectLatestReleases extends AsyncComponent<Props, State> {
   };
 
   renderInnerBody() {
-    const {organization, projectId} = this.props;
+    const {organization, projectId, isProjectStabilized} = this.props;
     const {loading, releases, hasOlderReleases} = this.state;
     const checkingForOlderReleases =
       !(releases ?? []).length && hasOlderReleases === undefined;
-    const showLoadingIndicator = loading || checkingForOlderReleases;
+    const showLoadingIndicator =
+      loading || checkingForOlderReleases || !isProjectStabilized;
 
     if (showLoadingIndicator) {
       return <Placeholder height="160px" />;

+ 1 - 1
src/sentry/static/sentry/app/views/projectDetail/projectQuickLinks.tsx

@@ -21,7 +21,7 @@ import {SidebarSection} from './styles';
 type Props = {
   organization: Organization;
   location: Location;
-  project: Project | null;
+  project?: Project;
 };
 
 function ProjectQuickLinks({organization, project, location}: Props) {

+ 14 - 4
src/sentry/static/sentry/app/views/projectDetail/projectScoreCards/projectApdexScoreCard.tsx

@@ -21,6 +21,7 @@ import {shouldFetchPreviousPeriod} from '../utils';
 type Props = AsyncComponent['props'] & {
   organization: Organization;
   selection: GlobalSelection;
+  isProjectStabilized: boolean;
 };
 
 type State = AsyncComponent['state'] & {
@@ -40,9 +41,9 @@ class ProjectApdexScoreCard extends AsyncComponent<Props, State> {
   }
 
   getEndpoints() {
-    const {organization, selection} = this.props;
+    const {organization, selection, isProjectStabilized} = this.props;
 
-    if (!this.hasFeature()) {
+    if (!this.hasFeature() || !isProjectStabilized) {
       return [];
     }
 
@@ -87,9 +88,13 @@ class ProjectApdexScoreCard extends AsyncComponent<Props, State> {
    * If there's no apdex in the time frame, check if there is one in the last 90 days (empty message differs then)
    */
   async onLoadAllEndpointsSuccess() {
-    const {organization, selection} = this.props;
+    const {organization, selection, isProjectStabilized} = this.props;
     const {projects} = selection;
 
+    if (!isProjectStabilized) {
+      return;
+    }
+
     if (defined(this.currentApdex) || defined(this.previousApdex)) {
       this.setState({noApdexEver: false});
       return;
@@ -116,7 +121,12 @@ class ProjectApdexScoreCard extends AsyncComponent<Props, State> {
   }
 
   componentDidUpdate(prevProps: Props) {
-    if (prevProps.selection !== this.props.selection) {
+    const {selection, isProjectStabilized} = this.props;
+
+    if (
+      prevProps.selection !== selection ||
+      prevProps.isProjectStabilized !== isProjectStabilized
+    ) {
       this.remountComponent();
     }
   }

+ 20 - 8
src/sentry/static/sentry/app/views/projectDetail/projectScoreCards/projectScoreCards.tsx

@@ -3,7 +3,6 @@ import styled from '@emotion/styled';
 
 import space from 'app/styles/space';
 import {GlobalSelection, Organization} from 'app/types';
-import withGlobalSelection from 'app/utils/withGlobalSelection';
 
 import ProjectApdexScoreCard from './projectApdexScoreCard';
 import ProjectStabilityScoreCard from './projectStabilityScoreCard';
@@ -12,16 +11,29 @@ import ProjectVelocityScoreCard from './projectVelocityScoreCard';
 type Props = {
   organization: Organization;
   selection: GlobalSelection;
+  isProjectStabilized: boolean;
 };
 
-function ProjectScoreCards({organization, selection}: Props) {
+function ProjectScoreCards({organization, selection, isProjectStabilized}: Props) {
   return (
     <CardWrapper>
-      <ProjectStabilityScoreCard organization={organization} selection={selection} />
-
-      <ProjectVelocityScoreCard organization={organization} selection={selection} />
-
-      <ProjectApdexScoreCard organization={organization} selection={selection} />
+      <ProjectStabilityScoreCard
+        organization={organization}
+        selection={selection}
+        isProjectStabilized={isProjectStabilized}
+      />
+
+      <ProjectVelocityScoreCard
+        organization={organization}
+        selection={selection}
+        isProjectStabilized={isProjectStabilized}
+      />
+
+      <ProjectApdexScoreCard
+        organization={organization}
+        selection={selection}
+        isProjectStabilized={isProjectStabilized}
+      />
     </CardWrapper>
   );
 }
@@ -37,4 +49,4 @@ const CardWrapper = styled('div')`
   }
 `;
 
-export default withGlobalSelection(ProjectScoreCards);
+export default ProjectScoreCards;

+ 17 - 3
src/sentry/static/sentry/app/views/projectDetail/projectScoreCards/projectStabilityScoreCard.tsx

@@ -24,6 +24,7 @@ import {shouldFetchPreviousPeriod} from '../utils';
 type Props = AsyncComponent['props'] & {
   organization: Organization;
   selection: GlobalSelection;
+  isProjectStabilized: boolean;
 };
 
 type State = AsyncComponent['state'] & {
@@ -43,7 +44,11 @@ class ProjectStabilityScoreCard extends AsyncComponent<Props, State> {
   }
 
   getEndpoints() {
-    const {organization, selection} = this.props;
+    const {organization, selection, isProjectStabilized} = this.props;
+
+    if (!isProjectStabilized) {
+      return [];
+    }
 
     const {projects, environments: environment, datetime} = selection;
     const {period} = datetime;
@@ -97,7 +102,11 @@ class ProjectStabilityScoreCard extends AsyncComponent<Props, State> {
    * If there are no sessions in the time frame, check if there are any in the last 90 days (empty message differs then)
    */
   async onLoadAllEndpointsSuccess() {
-    const {organization, selection} = this.props;
+    const {organization, selection, isProjectStabilized} = this.props;
+
+    if (!isProjectStabilized) {
+      return;
+    }
 
     if (defined(this.score) || defined(this.trend)) {
       this.setState({noSessionEver: false});
@@ -158,7 +167,12 @@ class ProjectStabilityScoreCard extends AsyncComponent<Props, State> {
   }
 
   componentDidUpdate(prevProps: Props) {
-    if (prevProps.selection !== this.props.selection) {
+    const {selection, isProjectStabilized} = this.props;
+
+    if (
+      prevProps.selection !== selection ||
+      prevProps.isProjectStabilized !== isProjectStabilized
+    ) {
       this.remountComponent();
     }
   }

+ 17 - 3
src/sentry/static/sentry/app/views/projectDetail/projectScoreCards/projectVelocityScoreCard.tsx

@@ -21,6 +21,7 @@ type Release = {version: string; date: string};
 type Props = AsyncComponent['props'] & {
   organization: Organization;
   selection: GlobalSelection;
+  isProjectStabilized: boolean;
 };
 
 type State = AsyncComponent['state'] & {
@@ -40,7 +41,11 @@ class ProjectVelocityScoreCard extends AsyncComponent<Props, State> {
   }
 
   getEndpoints() {
-    const {organization, selection} = this.props;
+    const {organization, selection, isProjectStabilized} = this.props;
+
+    if (!isProjectStabilized) {
+      return [];
+    }
 
     const {projects, environments, datetime} = selection;
     const {period} = datetime;
@@ -95,7 +100,11 @@ class ProjectVelocityScoreCard extends AsyncComponent<Props, State> {
    */
   async onLoadAllEndpointsSuccess() {
     const {currentReleases, previousReleases} = this.state;
-    const {organization, selection} = this.props;
+    const {organization, selection, isProjectStabilized} = this.props;
+
+    if (!isProjectStabilized) {
+      return;
+    }
 
     if ([...(currentReleases ?? []), ...(previousReleases ?? [])].length !== 0) {
       this.setState({noReleaseEver: false});
@@ -142,7 +151,12 @@ class ProjectVelocityScoreCard extends AsyncComponent<Props, State> {
   }
 
   componentDidUpdate(prevProps: Props) {
-    if (prevProps.selection !== this.props.selection) {
+    const {selection, isProjectStabilized} = this.props;
+
+    if (
+      prevProps.selection !== selection ||
+      prevProps.isProjectStabilized !== isProjectStabilized
+    ) {
       this.remountComponent();
     }
   }

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