Browse Source

ref(performance): Unify page layout on transaction summary tabs (#28417)

There's significant duplication across the transaction summary tabs. This
unifies the layout so it can be reused across all the tabs. Additionally, this
ensures that sdk updates and warnings are shown in a consistent manner.
Tony Xiao 3 years ago
parent
commit
8c62cae100

+ 181 - 0
static/app/views/performance/transactionSummary/pageLayout.tsx

@@ -0,0 +1,181 @@
+import {Dispatch, ReactNode, SetStateAction, useState} from 'react';
+import {browserHistory} from 'react-router';
+import styled from '@emotion/styled';
+import {Location} from 'history';
+
+import Feature from 'app/components/acl/feature';
+import Alert from 'app/components/alert';
+import GlobalSdkUpdateAlert from 'app/components/globalSdkUpdateAlert';
+import * as Layout from 'app/components/layouts/thirds';
+import LightWeightNoProjectMessage from 'app/components/lightWeightNoProjectMessage';
+import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader';
+import SentryDocumentTitle from 'app/components/sentryDocumentTitle';
+import {IconFlag} from 'app/icons';
+import {t} from 'app/locale';
+import {PageContent} from 'app/styles/organization';
+import {Organization, Project} from 'app/types';
+import {defined} from 'app/utils';
+import EventView from 'app/utils/discover/eventView';
+import {decodeScalar} from 'app/utils/queryString';
+
+import {getTransactionName} from '../utils';
+
+import TransactionHeader from './header';
+import Tab from './tabs';
+import {TransactionThresholdMetric} from './transactionThresholdModal';
+
+export type ChildProps = {
+  location: Location;
+  organization: Organization;
+  projects: Project[];
+  eventView: EventView;
+  transactionName: string;
+  setError: Dispatch<SetStateAction<string | undefined>>;
+  // These are used to trigger a reload when the threshold/metric changes.
+  transactionThreshold?: number;
+  transactionThresholdMetric?: TransactionThresholdMetric;
+};
+
+type Props = {
+  location: Location;
+  organization: Organization;
+  projects: Project[];
+  tab: Tab;
+  getDocumentTitle: (name: string) => string;
+  generateEventView: (location: Location, transactionName: string) => EventView;
+  childComponent: (props: ChildProps) => JSX.Element;
+  features?: string[];
+};
+
+function PageLayout(props: Props) {
+  const {
+    location,
+    organization,
+    projects,
+    tab,
+    getDocumentTitle,
+    generateEventView,
+    childComponent: ChildComponent,
+    features = [],
+  } = props;
+
+  const projectId = decodeScalar(location.query.project);
+  const transactionName = getTransactionName(location);
+
+  if (!defined(projectId) || !defined(transactionName)) {
+    // If there is no transaction name, redirect to the Performance landing page
+    browserHistory.replace({
+      pathname: `/organizations/${organization.slug}/performance/`,
+      query: {
+        ...location.query,
+      },
+    });
+    return null;
+  }
+
+  const project = projects.find(p => p.id === projectId);
+
+  const [error, setError] = useState<string | undefined>();
+
+  const [incompatibleAlertNotice, setIncompatibleAlertNotice] = useState<ReactNode>(null);
+  const handleIncompatibleQuery = (incompatibleAlertNoticeFn, _errors) => {
+    const notice = incompatibleAlertNoticeFn(() => setIncompatibleAlertNotice(null));
+    setIncompatibleAlertNotice(notice);
+  };
+
+  const [transactionThreshold, setTransactionThreshold] = useState<number | undefined>();
+  const [transactionThresholdMetric, setTransactionThresholdMetric] = useState<
+    TransactionThresholdMetric | undefined
+  >();
+
+  const eventView = generateEventView(location, transactionName);
+
+  return (
+    <SentryDocumentTitle
+      title={getDocumentTitle(transactionName)}
+      orgSlug={organization.slug}
+      projectSlug={project?.slug}
+    >
+      <Feature
+        features={['performance-view', ...features]}
+        organization={organization}
+        renderDisabled={NoAccess}
+      >
+        <GlobalSelectionHeader
+          lockedMessageSubject={t('transaction')}
+          shouldForceProject={defined(project)}
+          forceProject={project}
+          specificProjectSlugs={defined(project) ? [project.slug] : []}
+          disableMultipleProjectSelection
+          showProjectSettingsLink
+        >
+          <StyledPageContent>
+            <LightWeightNoProjectMessage organization={organization}>
+              <TransactionHeader
+                eventView={eventView}
+                location={location}
+                organization={organization}
+                projects={projects}
+                projectId={projectId}
+                transactionName={transactionName}
+                currentTab={tab}
+                hasWebVitals={tab === Tab.WebVitals ? 'yes' : 'maybe'}
+                handleIncompatibleQuery={handleIncompatibleQuery}
+                onChangeThreshold={(threshold, metric) => {
+                  setTransactionThreshold(threshold);
+                  setTransactionThresholdMetric(metric);
+                }}
+              />
+              <Layout.Body>
+                <StyledSdkUpdatesAlert />
+                {defined(error) && (
+                  <StyledAlert type="error" icon={<IconFlag size="md" />}>
+                    {error}
+                  </StyledAlert>
+                )}
+                {incompatibleAlertNotice && (
+                  <Layout.Main fullWidth>{incompatibleAlertNotice}</Layout.Main>
+                )}
+                <ChildComponent
+                  location={location}
+                  organization={organization}
+                  projects={projects}
+                  eventView={eventView}
+                  transactionName={transactionName}
+                  setError={setError}
+                  transactionThreshold={transactionThreshold}
+                  transactionThresholdMetric={transactionThresholdMetric}
+                />
+              </Layout.Body>
+            </LightWeightNoProjectMessage>
+          </StyledPageContent>
+        </GlobalSelectionHeader>
+      </Feature>
+    </SentryDocumentTitle>
+  );
+}
+
+function NoAccess() {
+  return <Alert type="warning">{t("You don't have access to this feature")}</Alert>;
+}
+
+const StyledPageContent = styled(PageContent)`
+  padding: 0;
+`;
+
+const StyledSdkUpdatesAlert = styled(GlobalSdkUpdateAlert)`
+  @media (min-width: ${p => p.theme.breakpoints[1]}) {
+    margin-bottom: 0;
+  }
+`;
+
+StyledSdkUpdatesAlert.defaultProps = {
+  Wrapper: p => <Layout.Main fullWidth {...p} />,
+};
+
+const StyledAlert = styled(Alert)`
+  grid-column: 1/3;
+  margin: 0;
+`;
+
+export default PageLayout;

+ 49 - 134
static/app/views/performance/transactionSummary/transactionEvents/index.tsx

@@ -1,20 +1,10 @@
-import {ReactNode, useState} from 'react';
 import {browserHistory} from 'react-router';
-import styled from '@emotion/styled';
 import {Location} from 'history';
 
-import Feature from 'app/components/acl/feature';
-import Alert from 'app/components/alert';
-import GlobalSdkUpdateAlert from 'app/components/globalSdkUpdateAlert';
 import * as Layout from 'app/components/layouts/thirds';
-import LightWeightNoProjectMessage from 'app/components/lightWeightNoProjectMessage';
 import LoadingIndicator from 'app/components/loadingIndicator';
-import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader';
-import SentryDocumentTitle from 'app/components/sentryDocumentTitle';
-import {IconFlag} from 'app/icons';
 import {t} from 'app/locale';
 import {Organization, Project} from 'app/types';
-import {defined} from 'app/utils';
 import {trackAnalyticsEvent} from 'app/utils/analytics';
 import DiscoverQuery from 'app/utils/discover/discoverQuery';
 import EventView from 'app/utils/discover/eventView';
@@ -31,13 +21,12 @@ import {MutableSearch} from 'app/utils/tokenizeSearch';
 import withOrganization from 'app/utils/withOrganization';
 import withProjects from 'app/utils/withProjects';
 
-import {getTransactionName} from '../../utils';
 import {
   decodeFilterFromLocation,
   filterToLocationQuery,
   SpanOperationBreakdownFilter,
 } from '../filter';
-import TransactionHeader from '../header';
+import PageLayout, {ChildProps} from '../pageLayout';
 import Tab from '../tabs';
 import {ZOOM_END, ZOOM_START} from '../transactionOverview/latencyChart';
 
@@ -59,35 +48,27 @@ type Props = {
 
 function TransactionEvents(props: Props) {
   const {location, organization, projects} = props;
-  const projectId = decodeScalar(location.query.project);
-  const transactionName = getTransactionName(location);
-
-  if (!defined(projectId) || !defined(transactionName)) {
-    // If there is no transaction name, redirect to the Performance landing page
-    browserHistory.replace({
-      pathname: `/organizations/${organization.slug}/performance/`,
-      query: {
-        ...location.query,
-      },
-    });
-    return null;
-  }
-
-  const project = projects.find(p => p.id === projectId);
-
-  const [incompatibleAlertNotice, setIncompatibleAlertNotice] = useState<ReactNode>(null);
-  const handleIncompatibleQuery = (incompatibleAlertNoticeFn, _errors) => {
-    const notice = incompatibleAlertNoticeFn(() => setIncompatibleAlertNotice(null));
-    setIncompatibleAlertNotice(notice);
-  };
 
-  const [error, setError] = useState<string | undefined>();
+  return (
+    <PageLayout
+      location={location}
+      organization={organization}
+      projects={projects}
+      tab={Tab.Events}
+      getDocumentTitle={getDocumentTitle}
+      generateEventView={generateEventView}
+      childComponent={EventsContentWrapper}
+      features={['performance-events-page']}
+    />
+  );
+}
 
+function EventsContentWrapper(props: ChildProps) {
+  const {location, organization, eventView, transactionName, setError} = props;
   const eventsDisplayFilterName = decodeEventsDisplayFilterFromLocation(location);
   const spanOperationBreakdownFilter = decodeFilterFromLocation(location);
   const webVital = getWebVital(location);
 
-  const eventView = generateEventView(location, transactionName);
   const percentilesView = getPercentilesEventView(eventView);
 
   const getFilteredEventView = (percentiles: PercentileValues) => {
@@ -167,88 +148,41 @@ function TransactionEvents(props: Props) {
   };
 
   return (
-    <SentryDocumentTitle
-      title={getDocumentTitle(transactionName)}
+    <DiscoverQuery
+      eventView={percentilesView}
       orgSlug={organization.slug}
-      projectSlug={project?.slug}
+      location={location}
+      referrer="api.performance.transaction-events"
     >
-      <Feature
-        features={['performance-events-page']}
-        organization={organization}
-        renderDisabled={NoAccess}
-      >
-        <GlobalSelectionHeader
-          lockedMessageSubject={t('transaction')}
-          shouldForceProject={defined(project)}
-          forceProject={project}
-          specificProjectSlugs={defined(project) ? [project.slug] : []}
-          disableMultipleProjectSelection
-          showProjectSettingsLink
-        >
-          <LightWeightNoProjectMessage organization={organization}>
-            <TransactionHeader
-              eventView={eventView}
-              location={location}
-              organization={organization}
-              projects={projects}
-              projectId={projectId}
-              transactionName={transactionName}
-              currentTab={Tab.Events}
-              hasWebVitals="maybe"
-              handleIncompatibleQuery={handleIncompatibleQuery}
-            />
-            <Layout.Body>
-              <StyledSdkUpdatesAlert />
-              {defined(error) && (
-                <StyledAlert type="error" icon={<IconFlag size="md" />}>
-                  {error}
-                </StyledAlert>
-              )}
-              {incompatibleAlertNotice && (
-                <Layout.Main fullWidth>{incompatibleAlertNotice}</Layout.Main>
-              )}
-              <DiscoverQuery
-                eventView={percentilesView}
-                orgSlug={organization.slug}
-                location={location}
-                referrer="api.performance.transaction-events"
-              >
-                {({isLoading, tableData}) => {
-                  if (isLoading) {
-                    return (
-                      <Layout.Main fullWidth>
-                        <LoadingIndicator />
-                      </Layout.Main>
-                    );
-                  }
-
-                  const percentiles: PercentileValues = tableData?.data?.[0];
-                  const filteredEventView = getFilteredEventView(percentiles);
-
-                  return (
-                    <EventsContent
-                      location={location}
-                      organization={organization}
-                      eventView={filteredEventView}
-                      transactionName={transactionName}
-                      spanOperationBreakdownFilter={spanOperationBreakdownFilter}
-                      onChangeSpanOperationBreakdownFilter={
-                        onChangeSpanOperationBreakdownFilter
-                      }
-                      eventsDisplayFilterName={eventsDisplayFilterName}
-                      onChangeEventsDisplayFilter={onChangeEventsDisplayFilter}
-                      percentileValues={percentiles}
-                      webVital={webVital}
-                      setError={setError}
-                    />
-                  );
-                }}
-              </DiscoverQuery>
-            </Layout.Body>
-          </LightWeightNoProjectMessage>
-        </GlobalSelectionHeader>
-      </Feature>
-    </SentryDocumentTitle>
+      {({isLoading, tableData}) => {
+        if (isLoading) {
+          return (
+            <Layout.Main fullWidth>
+              <LoadingIndicator />
+            </Layout.Main>
+          );
+        }
+
+        const percentiles: PercentileValues = tableData?.data?.[0];
+        const filteredEventView = getFilteredEventView(percentiles);
+
+        return (
+          <EventsContent
+            location={location}
+            organization={organization}
+            eventView={filteredEventView}
+            transactionName={transactionName}
+            spanOperationBreakdownFilter={spanOperationBreakdownFilter}
+            onChangeSpanOperationBreakdownFilter={onChangeSpanOperationBreakdownFilter}
+            eventsDisplayFilterName={eventsDisplayFilterName}
+            onChangeEventsDisplayFilter={onChangeEventsDisplayFilter}
+            percentileValues={percentiles}
+            webVital={webVital}
+            setError={setError}
+          />
+        );
+      }}
+    </DiscoverQuery>
   );
 }
 
@@ -263,10 +197,6 @@ function getDocumentTitle(transactionName: string): string {
   return [t('Summary'), t('Events')].join(' \u2014 ');
 }
 
-function NoAccess() {
-  return <Alert type="warning">{t("You don't have access to this feature")}</Alert>;
-}
-
 function getWebVital(location: Location): WebVital | undefined {
   const webVital = decodeScalar(location.query.webVital, '') as WebVital;
   if (Object.values(WebVital).includes(webVital)) {
@@ -347,19 +277,4 @@ function getPercentilesEventView(eventView: EventView): EventView {
   return eventView.withColumns(percentileColumns);
 }
 
-const StyledAlert = styled(Alert)`
-  grid-column: 1/3;
-  margin: 0;
-`;
-
-const StyledSdkUpdatesAlert = styled(GlobalSdkUpdateAlert)`
-  @media (min-width: ${p => p.theme.breakpoints[1]}) {
-    margin-bottom: 0;
-  }
-`;
-
-StyledSdkUpdatesAlert.defaultProps = {
-  Wrapper: p => <Layout.Main fullWidth {...p} />,
-};
-
 export default withProjects(withOrganization(TransactionEvents));

+ 50 - 116
static/app/views/performance/transactionSummary/transactionOverview/index.tsx

@@ -1,19 +1,11 @@
-import {ReactNode, useEffect, useState} from 'react';
-import {browserHistory, RouteComponentProps} from 'react-router';
-import styled from '@emotion/styled';
+import {useEffect} from 'react';
+import {browserHistory} from 'react-router';
 import {Location} from 'history';
 
 import {loadOrganizationTags} from 'app/actionCreators/tags';
 import {Client} from 'app/api';
-import GlobalSdkUpdateAlert from 'app/components/globalSdkUpdateAlert';
-import * as Layout from 'app/components/layouts/thirds';
-import LightWeightNoProjectMessage from 'app/components/lightWeightNoProjectMessage';
-import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader';
-import SentryDocumentTitle from 'app/components/sentryDocumentTitle';
 import {t} from 'app/locale';
-import {PageContent} from 'app/styles/organization';
 import {GlobalSelection, Organization, Project} from 'app/types';
-import {defined} from 'app/utils';
 import {trackAnalyticsEvent} from 'app/utils/analytics';
 import DiscoverQuery from 'app/utils/discover/discoverQuery';
 import EventView from 'app/utils/discover/eventView';
@@ -31,15 +23,14 @@ import withGlobalSelection from 'app/utils/withGlobalSelection';
 import withOrganization from 'app/utils/withOrganization';
 import withProjects from 'app/utils/withProjects';
 
-import {addRoutePerformanceContext, getTransactionName} from '../../utils';
+import {addRoutePerformanceContext} from '../../utils';
 import {
   decodeFilterFromLocation,
   filterToLocationQuery,
   SpanOperationBreakdownFilter,
 } from '../filter';
-import TransactionHeader from '../header';
+import PageLayout, {ChildProps} from '../pageLayout';
 import Tab from '../tabs';
-import {TransactionThresholdMetric} from '../transactionThresholdModal';
 import {
   PERCENTILE as VITAL_PERCENTILE,
   VITAL_GROUPS,
@@ -52,8 +43,9 @@ import {ZOOM_END, ZOOM_START} from './latencyChart';
 // as React.ReactText
 type TotalValues = Record<string, number>;
 
-type Props = RouteComponentProps<{}, {}> & {
+type Props = {
   api: Client;
+  location: Location;
   selection: GlobalSelection;
   organization: Organization;
   projects: Project[];
@@ -61,41 +53,37 @@ type Props = RouteComponentProps<{}, {}> & {
 
 function TransactionOverview(props: Props) {
   const {api, location, selection, organization, projects} = props;
-  const projectId = decodeScalar(location.query.project);
-  const transactionName = getTransactionName(location);
-
-  if (!defined(projectId) || !defined(transactionName)) {
-    // If there is no transaction name, redirect to the Performance landing page
-    browserHistory.replace({
-      pathname: `/organizations/${organization.slug}/performance/`,
-      query: {
-        ...location.query,
-      },
-    });
-    return null;
-  }
-
-  const project = projects.find(p => p.id === projectId);
 
   useEffect(() => {
     loadOrganizationTags(api, organization.slug, selection);
     addRoutePerformanceContext(selection);
   }, [selection]);
 
-  const [incompatibleAlertNotice, setIncompatibleAlertNotice] = useState<ReactNode>(null);
-  const handleIncompatibleQuery = (incompatibleAlertNoticeFn, _errors) => {
-    const notice = incompatibleAlertNoticeFn(() => setIncompatibleAlertNotice(null));
-    setIncompatibleAlertNotice(notice);
-  };
+  return (
+    <PageLayout
+      location={location}
+      organization={organization}
+      projects={projects}
+      tab={Tab.TransactionSummary}
+      getDocumentTitle={getDocumentTitle}
+      generateEventView={generateEventView}
+      childComponent={OverviewContentWrapper}
+    />
+  );
+}
 
-  const [transactionThreshold, setTransactionThreshold] = useState<number | undefined>();
-  const [transactionThresholdMetric, setTransactionThresholdMetric] = useState<
-    TransactionThresholdMetric | undefined
-  >();
+function OverviewContentWrapper(props: ChildProps) {
+  const {
+    location,
+    organization,
+    eventView,
+    transactionName,
+    transactionThreshold,
+    transactionThresholdMetric,
+  } = props;
 
   const spanOperationBreakdownFilter = decodeFilterFromLocation(location);
 
-  const eventView = generateEventView(location, transactionName);
   const totalsView = getTotalsEventView(organization, eventView);
 
   const onChangeFilter = (newFilter: SpanOperationBreakdownFilter) => {
@@ -121,71 +109,31 @@ function TransactionOverview(props: Props) {
   };
 
   return (
-    <SentryDocumentTitle
-      title={getDocumentTitle(transactionName)}
+    <DiscoverQuery
+      eventView={totalsView}
       orgSlug={organization.slug}
-      projectSlug={project?.slug}
+      location={location}
+      transactionThreshold={transactionThreshold}
+      transactionThresholdMetric={transactionThresholdMetric}
+      referrer="api.performance.transaction-summary"
     >
-      <GlobalSelectionHeader
-        lockedMessageSubject={t('transaction')}
-        shouldForceProject={defined(project)}
-        forceProject={project}
-        specificProjectSlugs={defined(project) ? [project.slug] : []}
-        disableMultipleProjectSelection
-        showProjectSettingsLink
-      >
-        <StyledPageContent>
-          <LightWeightNoProjectMessage organization={organization}>
-            <TransactionHeader
-              eventView={eventView}
-              location={location}
-              organization={organization}
-              projects={projects}
-              projectId={projectId}
-              transactionName={transactionName}
-              currentTab={Tab.TransactionSummary}
-              hasWebVitals="maybe"
-              handleIncompatibleQuery={handleIncompatibleQuery}
-              onChangeThreshold={(threshold, metric) => {
-                setTransactionThreshold(threshold);
-                setTransactionThresholdMetric(metric);
-              }}
-            />
-            <Layout.Body>
-              <StyledSdkUpdatesAlert />
-              {incompatibleAlertNotice && (
-                <Layout.Main fullWidth>{incompatibleAlertNotice}</Layout.Main>
-              )}
-              <DiscoverQuery
-                eventView={totalsView}
-                orgSlug={organization.slug}
-                location={location}
-                transactionThreshold={transactionThreshold}
-                transactionThresholdMetric={transactionThresholdMetric}
-                referrer="api.performance.transaction-summary"
-              >
-                {({isLoading, error, tableData}) => {
-                  const totals: TotalValues | null = tableData?.data?.[0] ?? null;
-                  return (
-                    <SummaryContent
-                      location={location}
-                      organization={organization}
-                      eventView={eventView}
-                      transactionName={transactionName}
-                      isLoading={isLoading}
-                      error={error}
-                      totalValues={totals}
-                      onChangeFilter={onChangeFilter}
-                      spanOperationBreakdownFilter={spanOperationBreakdownFilter}
-                    />
-                  );
-                }}
-              </DiscoverQuery>
-            </Layout.Body>
-          </LightWeightNoProjectMessage>
-        </StyledPageContent>
-      </GlobalSelectionHeader>
-    </SentryDocumentTitle>
+      {({isLoading, error, tableData}) => {
+        const totals: TotalValues | null = tableData?.data?.[0] ?? null;
+        return (
+          <SummaryContent
+            location={location}
+            organization={organization}
+            eventView={eventView}
+            transactionName={transactionName}
+            isLoading={isLoading}
+            error={error}
+            totalValues={totals}
+            onChangeFilter={onChangeFilter}
+            spanOperationBreakdownFilter={spanOperationBreakdownFilter}
+          />
+        );
+      }}
+    </DiscoverQuery>
   );
 }
 
@@ -200,10 +148,6 @@ function getDocumentTitle(transactionName: string): string {
   return [t('Summary'), t('Performance')].join(' - ');
 }
 
-const StyledPageContent = styled(PageContent)`
-  padding: 0;
-`;
-
 function generateEventView(location: Location, transactionName: string): EventView {
   // Use the user supplied query but overwrite any transaction or event type
   // conditions they applied.
@@ -288,16 +232,6 @@ function getTotalsEventView(
   ]);
 }
 
-const StyledSdkUpdatesAlert = styled(GlobalSdkUpdateAlert)`
-  @media (min-width: ${p => p.theme.breakpoints[1]}) {
-    margin-bottom: 0;
-  }
-`;
-
-StyledSdkUpdatesAlert.defaultProps = {
-  Wrapper: p => <Layout.Main fullWidth {...p} />,
-};
-
 export default withApi(
   withGlobalSelection(withProjects(withOrganization(TransactionOverview)))
 );

+ 11 - 92
static/app/views/performance/transactionSummary/transactionTags/index.tsx

@@ -1,26 +1,14 @@
-import {ReactNode, useState} from 'react';
-import {browserHistory} from 'react-router';
-import styled from '@emotion/styled';
 import {Location} from 'history';
 
-import Feature from 'app/components/acl/feature';
-import Alert from 'app/components/alert';
-import * as Layout from 'app/components/layouts/thirds';
-import LightWeightNoProjectMessage from 'app/components/lightWeightNoProjectMessage';
-import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader';
-import SentryDocumentTitle from 'app/components/sentryDocumentTitle';
 import {t} from 'app/locale';
-import {PageContent} from 'app/styles/organization';
 import {Organization, Project} from 'app/types';
-import {defined} from 'app/utils';
 import EventView from 'app/utils/discover/eventView';
 import {decodeScalar} from 'app/utils/queryString';
 import {MutableSearch} from 'app/utils/tokenizeSearch';
 import withOrganization from 'app/utils/withOrganization';
 import withProjects from 'app/utils/withProjects';
 
-import {getTransactionName} from '../../utils';
-import TransactionHeader from '../header';
+import PageLayout from '../pageLayout';
 import Tab from '../tabs';
 
 import TagsPageContent from './content';
@@ -33,79 +21,18 @@ type Props = {
 
 function TransactionTags(props: Props) {
   const {location, organization, projects} = props;
-  const projectId = decodeScalar(location.query.project);
-  const transactionName = getTransactionName(location);
-
-  if (!defined(projectId) || !defined(transactionName)) {
-    // If there is no transaction name, redirect to the Performance landing page
-    browserHistory.replace({
-      pathname: `/organizations/${organization.slug}/performance/`,
-      query: {
-        ...location.query,
-      },
-    });
-    return null;
-  }
-
-  const project = projects.find(p => p.id === projectId);
-
-  const [incompatibleAlertNotice, setIncompatibleAlertNotice] = useState<ReactNode>(null);
-  const handleIncompatibleQuery = (incompatibleAlertNoticeFn, _errors) => {
-    const notice = incompatibleAlertNoticeFn(() => setIncompatibleAlertNotice(null));
-    setIncompatibleAlertNotice(notice);
-  };
-
-  const eventView = generateEventView(location, transactionName);
 
   return (
-    <SentryDocumentTitle
-      title={getDocumentTitle(transactionName)}
-      orgSlug={organization.slug}
-      projectSlug={project?.slug}
-    >
-      <Feature
-        features={['performance-tag-page']}
-        organization={organization}
-        renderDisabled={NoAccess}
-      >
-        <GlobalSelectionHeader
-          lockedMessageSubject={t('transaction')}
-          shouldForceProject={defined(project)}
-          forceProject={project}
-          specificProjectSlugs={defined(project) ? [project.slug] : []}
-          disableMultipleProjectSelection
-          showProjectSettingsLink
-        >
-          <StyledPageContent>
-            <LightWeightNoProjectMessage organization={organization}>
-              <TransactionHeader
-                eventView={eventView}
-                location={location}
-                organization={organization}
-                projects={projects}
-                projectId={projectId}
-                transactionName={transactionName}
-                currentTab={Tab.Tags}
-                hasWebVitals="maybe"
-                handleIncompatibleQuery={handleIncompatibleQuery}
-              />
-              <Layout.Body>
-                {incompatibleAlertNotice && (
-                  <Layout.Main fullWidth>{incompatibleAlertNotice}</Layout.Main>
-                )}
-                <TagsPageContent
-                  location={location}
-                  eventView={eventView}
-                  transactionName={transactionName}
-                  organization={organization}
-                  projects={projects}
-                />
-              </Layout.Body>
-            </LightWeightNoProjectMessage>
-          </StyledPageContent>
-        </GlobalSelectionHeader>
-      </Feature>
-    </SentryDocumentTitle>
+    <PageLayout
+      location={location}
+      organization={organization}
+      projects={projects}
+      tab={Tab.Tags}
+      getDocumentTitle={getDocumentTitle}
+      generateEventView={generateEventView}
+      childComponent={TagsPageContent}
+      features={['performance-tag-page']}
+    />
   );
 }
 
@@ -120,14 +47,6 @@ function getDocumentTitle(transactionName: string): string {
   return [t('Summary'), t('Tags')].join(' \u2014 ');
 }
 
-function NoAccess() {
-  return <Alert type="warning">{t("You don't have access to this feature")}</Alert>;
-}
-
-const StyledPageContent = styled(PageContent)`
-  padding: 0;
-`;
-
 function generateEventView(location: Location, transactionName: string): EventView {
   const query = decodeScalar(location.query.query, '');
   const conditions = new MutableSearch(query);

+ 10 - 90
static/app/views/performance/transactionSummary/transactionVitals/index.tsx

@@ -1,18 +1,7 @@
-import {ReactNode, useState} from 'react';
-import {browserHistory} from 'react-router';
-import styled from '@emotion/styled';
 import {Location} from 'history';
 
-import Feature from 'app/components/acl/feature';
-import Alert from 'app/components/alert';
-import * as Layout from 'app/components/layouts/thirds';
-import LightWeightNoProjectMessage from 'app/components/lightWeightNoProjectMessage';
-import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader';
-import SentryDocumentTitle from 'app/components/sentryDocumentTitle';
 import {t} from 'app/locale';
-import {PageContent} from 'app/styles/organization';
 import {Organization, Project} from 'app/types';
-import {defined} from 'app/utils';
 import EventView from 'app/utils/discover/eventView';
 import {isAggregateField, WebVital} from 'app/utils/discover/fields';
 import {WEB_VITAL_DETAILS} from 'app/utils/performance/vitals/constants';
@@ -21,8 +10,7 @@ import {MutableSearch} from 'app/utils/tokenizeSearch';
 import withOrganization from 'app/utils/withOrganization';
 import withProjects from 'app/utils/withProjects';
 
-import {getTransactionName} from '../../utils';
-import TransactionHeader from '../header';
+import PageLayout from '../pageLayout';
 import Tab from '../tabs';
 
 import {PERCENTILE, VITAL_GROUPS} from './constants';
@@ -36,77 +24,17 @@ type Props = {
 
 function TransactionVitals(props: Props) {
   const {location, organization, projects} = props;
-  const projectId = decodeScalar(location.query.project);
-  const transactionName = getTransactionName(location);
-
-  if (!defined(projectId) || !defined(transactionName)) {
-    // If there is no transaction name, redirect to the Performance landing page
-    browserHistory.replace({
-      pathname: `/organizations/${organization.slug}/performance/`,
-      query: {
-        ...location.query,
-      },
-    });
-    return null;
-  }
-
-  const project = projects.find(p => p.id === projectId);
-
-  const [incompatibleAlertNotice, setIncompatibleAlertNotice] = useState<ReactNode>(null);
-  const handleIncompatibleQuery = (incompatibleAlertNoticeFn, _errors) => {
-    const notice = incompatibleAlertNoticeFn(() => setIncompatibleAlertNotice(null));
-    setIncompatibleAlertNotice(notice);
-  };
-
-  const eventView = generateEventView(location, transactionName);
 
   return (
-    <SentryDocumentTitle
-      title={getDocumentTitle(transactionName)}
-      orgSlug={organization.slug}
-      projectSlug={project?.slug}
-    >
-      <Feature
-        features={['performance-view']}
-        organization={organization}
-        renderDisabled={NoAccess}
-      >
-        <GlobalSelectionHeader
-          lockedMessageSubject={t('transaction')}
-          shouldForceProject={defined(project)}
-          forceProject={project}
-          specificProjectSlugs={defined(project) ? [project.slug] : []}
-          disableMultipleProjectSelection
-          showProjectSettingsLink
-        >
-          <StyledPageContent>
-            <LightWeightNoProjectMessage organization={organization}>
-              <TransactionHeader
-                eventView={eventView}
-                location={location}
-                organization={organization}
-                projects={projects}
-                projectId={projectId}
-                transactionName={transactionName}
-                currentTab={Tab.WebVitals}
-                hasWebVitals="yes"
-                handleIncompatibleQuery={handleIncompatibleQuery}
-              />
-              <Layout.Body>
-                {incompatibleAlertNotice && (
-                  <Layout.Main fullWidth>{incompatibleAlertNotice}</Layout.Main>
-                )}
-                <VitalsContent
-                  location={location}
-                  organization={organization}
-                  eventView={eventView}
-                />
-              </Layout.Body>
-            </LightWeightNoProjectMessage>
-          </StyledPageContent>
-        </GlobalSelectionHeader>
-      </Feature>
-    </SentryDocumentTitle>
+    <PageLayout
+      location={location}
+      organization={organization}
+      projects={projects}
+      tab={Tab.WebVitals}
+      getDocumentTitle={getDocumentTitle}
+      generateEventView={generateEventView}
+      childComponent={VitalsContent}
+    />
   );
 }
 
@@ -121,14 +49,6 @@ function getDocumentTitle(transactionName): string {
   return [t('Summary'), t('Vitals')].join(' \u2014 ');
 }
 
-function NoAccess() {
-  return <Alert type="warning">{t("You don't have access to this feature")}</Alert>;
-}
-
-const StyledPageContent = styled(PageContent)`
-  padding: 0;
-`;
-
 function generateEventView(location: Location, transactionName: string): EventView {
   const query = decodeScalar(location.query.query, '');
   const conditions = new MutableSearch(query);

+ 8 - 0
tests/js/spec/views/performance/transactionTags/index.spec.jsx

@@ -107,6 +107,14 @@ describe('Performance > Transaction Tags', function () {
       url: '/organizations/org-slug/events-has-measurements/',
       body: {measurements: false},
     });
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/sdk-updates/',
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      url: '/prompts-activity/',
+      body: {},
+    });
   });
 
   afterEach(function () {

+ 8 - 0
tests/js/spec/views/performance/transactionVitals.spec.jsx

@@ -117,6 +117,14 @@ describe('Performance > Web Vitals', function () {
       url: `/organizations/org-slug/key-transactions-list/`,
       body: [],
     });
+    MockApiClient.addMockResponse({
+      url: '/prompts-activity/',
+      body: {},
+    });
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/sdk-updates/',
+      body: [],
+    });
   });
 
   it('render no access without feature', async function () {