Browse Source

ref(onboarding): Add heartbeat to the onboarding new orgs & adjust style for existing orgs - (#43677)

Priscila Oliveira 2 years ago
parent
commit
9851b63f36

+ 8 - 1
static/app/components/idBadge/projectBadge.tsx

@@ -5,6 +5,7 @@ import BadgeDisplayName from 'sentry/components/idBadge/badgeDisplayName';
 import BaseBadge from 'sentry/components/idBadge/baseBadge';
 import Link, {LinkProps} from 'sentry/components/links/link';
 import {Organization} from 'sentry/types';
+import getPlatformName from 'sentry/utils/getPlatformName';
 import withOrganization from 'sentry/utils/withOrganization';
 
 type BaseBadgeProps = React.ComponentProps<typeof BaseBadge>;
@@ -18,6 +19,7 @@ export interface ProjectBadgeProps
    * If true, this component will not be a link to project details page
    */
   disableLink?: boolean;
+  displayPlatformName?: boolean;
   /**
    * If true, will use default max-width, or specify one as a string
    */
@@ -35,6 +37,7 @@ const ProjectBadge = ({
   to,
   hideOverflow = true,
   disableLink = false,
+  displayPlatformName = false,
   className,
   ...props
 }: ProjectBadgeProps) => {
@@ -43,7 +46,11 @@ const ProjectBadge = ({
   const badge = (
     <BaseBadge
       displayName={
-        <BadgeDisplayName hideOverflow={hideOverflow}>{slug}</BadgeDisplayName>
+        <BadgeDisplayName hideOverflow={hideOverflow}>
+          {displayPlatformName && project.platform
+            ? getPlatformName(project.platform)
+            : slug}
+        </BadgeDisplayName>
       }
       project={project}
       {...props}

+ 55 - 0
static/app/views/onboarding/components/heartbeatFooter/changeRouteModal.tsx

@@ -0,0 +1,55 @@
+import {Fragment, useCallback} from 'react';
+import {RouteComponentProps} from 'react-router';
+import {Location} from 'history';
+
+import {ModalRenderProps} from 'sentry/actionCreators/modal';
+import {Button} from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import {} from 'sentry/components/text';
+import {t} from 'sentry/locale';
+
+type Props = {
+  nextLocation: Location;
+  router: RouteComponentProps<{}, {}>['router'];
+} & ModalRenderProps;
+
+export function ChangeRouteModal({
+  Header,
+  Body,
+  Footer,
+  router,
+  nextLocation,
+  closeModal,
+}: Props) {
+  const handleSetUpLater = useCallback(() => {
+    closeModal();
+    router.push({
+      ...nextLocation,
+      query: {
+        ...nextLocation.query,
+        setUpRemainingOnboardingTasksLater: true,
+      },
+    });
+  }, [router, nextLocation, closeModal]);
+
+  return (
+    <Fragment>
+      <Header closeButton>
+        <h4>{t('Are you sure?')}</h4>
+      </Header>
+      <Body>
+        {t(
+          'You are about to leave this page without completing the steps required to monitor errors and or performance for the selected projects.'
+        )}
+      </Body>
+      <Footer>
+        <ButtonBar gap={1}>
+          <Button onClick={handleSetUpLater}>{t('Yes, I’ll set up later')}</Button>
+          <Button priority="primary" onClick={closeModal}>
+            {t('No, I’ll set up now')}
+          </Button>
+        </ButtonBar>
+      </Footer>
+    </Fragment>
+  );
+}

+ 382 - 0
static/app/views/onboarding/components/heartbeatFooter/index.tsx

@@ -0,0 +1,382 @@
+import {Fragment, useEffect} from 'react';
+import {RouteComponentProps} from 'react-router';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+import {Location} from 'history';
+
+import {openModal} from 'sentry/actionCreators/modal';
+import {Button} from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import IdBadge from 'sentry/components/idBadge';
+import Placeholder from 'sentry/components/placeholder';
+import {IconCheckmark} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import PreferencesStore from 'sentry/stores/preferencesStore';
+import {useLegacyStore} from 'sentry/stores/useLegacyStore';
+import pulsingIndicatorStyles from 'sentry/styles/pulsingIndicator';
+import space from 'sentry/styles/space';
+import {Project} from 'sentry/types';
+import getPlatformName from 'sentry/utils/getPlatformName';
+import useOrganization from 'sentry/utils/useOrganization';
+import useProjects from 'sentry/utils/useProjects';
+
+import GenericFooter from '../genericFooter';
+
+import {useHeartbeat} from './useHeartbeat';
+
+enum BeatStatus {
+  AWAITING = 'awaiting',
+  PENDING = 'pending',
+  COMPLETE = 'complete',
+}
+
+async function openChangeRouteModal(
+  router: RouteComponentProps<{}, {}>['router'],
+  nextLocation: Location
+) {
+  const mod = await import(
+    'sentry/views/onboarding/components/heartbeatFooter/changeRouteModal'
+  );
+  const {ChangeRouteModal} = mod;
+
+  openModal(deps => (
+    <ChangeRouteModal {...deps} router={router} nextLocation={nextLocation} />
+  ));
+}
+
+type Props = Pick<RouteComponentProps<{}, {}>, 'router' | 'route' | 'location'> & {
+  projectSlug: Project['slug'];
+  newOrg?: boolean;
+  nextProjectSlug?: Project['slug'];
+  onSetupNextProject?: () => void;
+};
+
+export function HeartbeatFooter({
+  projectSlug,
+  router,
+  route,
+  location,
+  newOrg,
+  nextProjectSlug,
+  onSetupNextProject,
+}: Props) {
+  const organization = useOrganization();
+  const preferences = useLegacyStore(PreferencesStore);
+
+  const {initiallyLoaded, fetchError, fetching, projects} = useProjects({
+    orgId: organization.id,
+    slugs: nextProjectSlug ? [projectSlug, nextProjectSlug] : [projectSlug],
+  });
+
+  const projectsLoading = !initiallyLoaded && fetching;
+
+  const project =
+    !projectsLoading && !fetchError && projects.length
+      ? projects.find(proj => proj.slug === projectSlug)
+      : undefined;
+
+  const nextProject =
+    !projectsLoading && !fetchError && projects.length === 2
+      ? projects.find(proj => proj.slug === nextProjectSlug)
+      : undefined;
+
+  const {
+    sessionLoading,
+    eventLoading,
+    firstErrorReceived,
+    firstTransactionReceived,
+    hasSession,
+  } = useHeartbeat({project});
+
+  const serverConnected = hasSession || firstTransactionReceived;
+  const loading = projectsLoading || sessionLoading || eventLoading;
+
+  useEffect(() => {
+    const onUnload = (nextLocation?: Location) => {
+      const {orgId, platform, projectId} = router.params;
+
+      const isSetupDocsForNewOrg =
+        location.pathname === `/onboarding/${organization.slug}/setup-docs/` &&
+        nextLocation?.pathname !== `/onboarding/${organization.slug}/setup-docs/`;
+
+      const isSetupDocsForNewOrgBackButton = `/onboarding/${organization.slug}/select-platform/`;
+
+      const isGettingStartedForExistingOrg =
+        location.pathname === `/${orgId}/${projectId}/getting-started/${platform}/` ||
+        location.pathname === `/organizations/${orgId}/${projectId}/getting-started/`;
+
+      if (isSetupDocsForNewOrg || isGettingStartedForExistingOrg) {
+        // TODO(Priscila): I have to adjust this to check for all selected projects in the onboarding of new orgs
+        if (serverConnected && firstErrorReceived) {
+          return true;
+        }
+
+        // Next Location is always available when user clicks on a item with a new route
+        if (nextLocation) {
+          // Back button in the onboarding of new orgs
+          if (nextLocation.pathname === isSetupDocsForNewOrgBackButton) {
+            return true;
+          }
+
+          if (nextLocation.query.setUpRemainingOnboardingTasksLater) {
+            return true;
+          }
+
+          openChangeRouteModal(router, nextLocation);
+          return false;
+        }
+
+        return true;
+      }
+
+      return true;
+    };
+
+    router.setRouteLeaveHook(route, onUnload);
+  }, [serverConnected, firstErrorReceived, route, router, organization.slug, location]);
+
+  return (
+    <Wrapper newOrg={!!newOrg} sidebarCollapsed={!!preferences.collapsed}>
+      <PlatformIconAndName>
+        {projectsLoading ? (
+          <LoadingPlaceholder height="28px" width="276px" />
+        ) : (
+          <IdBadge
+            project={project}
+            displayPlatformName
+            avatarSize={28}
+            hideOverflow
+            disableLink
+          />
+        )}
+      </PlatformIconAndName>
+      <Beats>
+        {loading ? (
+          <Fragment>
+            <LoadingPlaceholder height="28px" />
+            <LoadingPlaceholder height="28px" />
+          </Fragment>
+        ) : firstErrorReceived ? (
+          <Fragment>
+            <Beat status={BeatStatus.COMPLETE}>
+              <IconCheckmark size="sm" isCircled />
+              {t('DSN response received')}
+            </Beat>
+            <Beat status={BeatStatus.COMPLETE}>
+              <IconCheckmark size="sm" isCircled />
+              {t('First error received')}
+            </Beat>
+          </Fragment>
+        ) : serverConnected ? (
+          <Fragment>
+            <Beat status={BeatStatus.COMPLETE}>
+              <IconCheckmark size="sm" isCircled />
+              {t('DSN response received')}
+            </Beat>
+            <Beat status={BeatStatus.AWAITING}>
+              <PulsingIndicator>2</PulsingIndicator>
+              {t('Awaiting first error')}
+            </Beat>
+          </Fragment>
+        ) : (
+          <Fragment>
+            <Beat status={BeatStatus.AWAITING}>
+              <PulsingIndicator>1</PulsingIndicator>
+              {t('Awaiting DSN response')}
+            </Beat>
+            <Beat status={BeatStatus.PENDING}>
+              <PulsingIndicator>2</PulsingIndicator>
+              {t('Awaiting first error')}
+            </Beat>
+          </Fragment>
+        )}
+      </Beats>
+      <Actions>
+        <ButtonBar gap={1}>
+          {newOrg ? (
+            <Fragment>
+              {nextProject && (
+                <Button busy={projectsLoading} onClick={onSetupNextProject}>
+                  {nextProject.platform
+                    ? t('Setup %s', getPlatformName(nextProject.platform))
+                    : t('Next Platform')}
+                </Button>
+              )}
+              {firstErrorReceived ? (
+                <Button
+                  priority="primary"
+                  busy={projectsLoading}
+                  to={`/organizations/${organization.slug}/issues/${
+                    firstErrorReceived &&
+                    firstErrorReceived !== true &&
+                    'id' in firstErrorReceived
+                      ? `${firstErrorReceived.id}/`
+                      : ''
+                  }?referrer=onboarding-first-event-footer`}
+                >
+                  {t('Go to my error')}
+                </Button>
+              ) : (
+                <Button
+                  priority="primary"
+                  busy={projectsLoading}
+                  to={`/organizations/${organization.slug}/issues/`} // TODO(Priscila): See what Jesse meant with 'explore sentry'. What should be the expected action?
+                >
+                  {t('Explore Sentry')}
+                </Button>
+              )}
+            </Fragment>
+          ) : (
+            <Fragment>
+              <Button
+                busy={projectsLoading}
+                to={{
+                  pathname: `/organizations/${organization.slug}/performance/`,
+                  query: {project: project?.id},
+                }}
+              >
+                {t('Go to Performance')}
+              </Button>
+              {firstErrorReceived ? (
+                <Button
+                  priority="primary"
+                  busy={projectsLoading}
+                  to={`/organizations/${organization.slug}/issues/${
+                    firstErrorReceived &&
+                    firstErrorReceived !== true &&
+                    'id' in firstErrorReceived
+                      ? `${firstErrorReceived.id}/`
+                      : ''
+                  }`}
+                >
+                  {t('Go to my error')}
+                </Button>
+              ) : (
+                <Button
+                  priority="primary"
+                  busy={projectsLoading}
+                  to={{
+                    pathname: `/organizations/${organization.slug}/issues/`,
+                    query: {project: project?.id},
+                    hash: '#welcome',
+                  }}
+                >
+                  {t('Go to Issues')}
+                </Button>
+              )}
+            </Fragment>
+          )}
+        </ButtonBar>
+      </Actions>
+    </Wrapper>
+  );
+}
+
+const Wrapper = styled(GenericFooter)<{newOrg: boolean; sidebarCollapsed: boolean}>`
+  display: none;
+  display: flex;
+  flex-direction: row;
+  justify-content: flex-end;
+  padding: ${space(2)} ${space(4)};
+
+  @media (min-width: ${p => p.theme.breakpoints.small}) {
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    align-items: center;
+    gap: ${space(3)};
+  }
+  ${p =>
+    !p.newOrg &&
+    css`
+      @media (min-width: ${p.theme.breakpoints.medium}) {
+        width: calc(
+          100% -
+            ${p.theme.sidebar[p.sidebarCollapsed ? 'collapsedWidth' : 'expandedWidth']}
+        );
+        right: 0;
+        left: auto;
+      }
+    `}
+`;
+
+const PlatformIconAndName = styled('div')`
+  display: none;
+
+  @media (min-width: ${p => p.theme.breakpoints.small}) {
+    max-width: 100%;
+    overflow: hidden;
+    width: 100%;
+    display: block;
+  }
+`;
+
+const Beats = styled('div')`
+  display: none;
+
+  @media (min-width: ${p => p.theme.breakpoints.small}) {
+    gap: ${space(2)};
+    display: grid;
+    grid-template-columns: repeat(2, max-content);
+    justify-content: center;
+    align-items: center;
+  }
+`;
+
+export const LoadingPlaceholder = styled(Placeholder)`
+  width: 100%;
+  max-width: ${p => p.width};
+`;
+
+const PulsingIndicator = styled('div')`
+  ${pulsingIndicatorStyles};
+  font-size: ${p => p.theme.fontSizeExtraSmall};
+  color: ${p => p.theme.white};
+  height: 16px;
+  width: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  :before {
+    top: auto;
+    left: auto;
+  }
+`;
+
+const Beat = styled('div')<{status: BeatStatus}>`
+  width: 160px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: ${space(0.5)};
+  font-size: ${p => p.theme.fontSizeSmall};
+  color: ${p => p.theme.pink300};
+
+  ${p =>
+    p.status === BeatStatus.PENDING &&
+    css`
+      color: ${p.theme.disabled};
+      ${PulsingIndicator} {
+        background: ${p.theme.disabled};
+        :before {
+          content: none;
+        }
+      }
+    `}
+
+  ${p =>
+    p.status === BeatStatus.COMPLETE &&
+    css`
+      color: ${p.theme.successText};
+      ${PulsingIndicator} {
+        background: ${p.theme.success};
+        :before {
+          content: none;
+        }
+      }
+    `}
+`;
+
+const Actions = styled('div')`
+  display: flex;
+  justify-content: flex-end;
+`;

+ 88 - 0
static/app/views/onboarding/components/heartbeatFooter/useHeartbeat.tsx

@@ -0,0 +1,88 @@
+import {useState} from 'react';
+
+import {
+  Group,
+  Project,
+  SessionApiResponse,
+  SessionFieldWithOperation,
+} from 'sentry/types';
+import {useQuery} from 'sentry/utils/queryClient';
+import {getCount} from 'sentry/utils/sessions';
+import useOrganization from 'sentry/utils/useOrganization';
+
+const DEFAULT_POLL_INTERVAL = 5000;
+
+type Props = {
+  project?: Project;
+};
+
+export function useHeartbeat({project}: Props) {
+  const organization = useOrganization();
+  const [firstError, setFirstError] = useState<string | null>(null);
+  const [firstTransactionReceived, setFirstTransactionReceived] = useState(false);
+  const [hasSession, setHasSession] = useState(false);
+
+  const {isLoading: eventLoading} = useQuery<Project>(
+    [`/projects/${organization.slug}/${project?.slug}/`],
+    {
+      staleTime: 0,
+      refetchInterval: DEFAULT_POLL_INTERVAL,
+      enabled: !!project && !firstError, // Fetch only if the project is available and we have not yet received an error,
+      onSuccess: data => {
+        setFirstError(data.firstEvent);
+        // When an error is received, a transaction is also received
+        setFirstTransactionReceived(!!data.firstTransactionEvent);
+      },
+    }
+  );
+
+  const {isLoading: sessionLoading} = useQuery<SessionApiResponse>(
+    [
+      `/organizations/${organization.slug}/sessions/`,
+      {
+        query: {
+          project: project?.id,
+          statsPeriod: '24h',
+          field: [SessionFieldWithOperation.SESSIONS],
+        },
+      },
+    ],
+    {
+      staleTime: 0,
+      refetchInterval: DEFAULT_POLL_INTERVAL,
+      enabled: !!project && !(hasSession || firstTransactionReceived), // Fetch only if the project is available and we if a connection to Sentry was not yet established,
+      onSuccess: data => {
+        const hasHealthData =
+          getCount(data.groups, SessionFieldWithOperation.SESSIONS) > 0;
+
+        setHasSession(hasHealthData);
+      },
+    }
+  );
+
+  // Locate the projects first issue group. The project.firstEvent field will
+  // *not* include sample events, while just looking at the issues list will.
+  // We will wait until the project.firstEvent is set and then locate the
+  // event given that event datetime
+  const {data: issuesData, isLoading: issuesLoading} = useQuery<Group[]>(
+    [`/projects/${organization.slug}/${project?.slug}/issues/`],
+    {
+      staleTime: Infinity,
+      enabled: !!firstError, // Only fetch if an error event is received,
+    }
+  );
+
+  const firstErrorReceived =
+    !!firstError && issuesData
+      ? issuesData.find((issue: Group) => issue.firstSeen === firstError) || true
+      : false;
+
+  return {
+    hasSession,
+    firstErrorReceived,
+    firstTransactionReceived,
+    eventLoading,
+    sessionLoading,
+    issuesLoading,
+  };
+}

+ 86 - 1
static/app/views/onboarding/components/projectSidebarSection.tsx

@@ -1,8 +1,10 @@
 import {Fragment} from 'react';
+import {useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
 import {motion, Variants} from 'framer-motion';
 import {PlatformIcon} from 'platformicons';
 
+import Placeholder from 'sentry/components/placeholder';
 import {PlatformKey} from 'sentry/data/platformCategories';
 import platforms from 'sentry/data/platforms';
 import {IconCheckmark} from 'sentry/icons';
@@ -12,20 +14,81 @@ import space from 'sentry/styles/space';
 import {Project} from 'sentry/types';
 import testableTransition from 'sentry/utils/testableTransition';
 
+import {useHeartbeat} from './heartbeatFooter/useHeartbeat';
+
 type Props = {
   activeProject: Project | null;
   checkProjectHasFirstEvent: (project: Project) => boolean;
+  hasHeartbeatFooter: boolean;
   projects: Project[];
   selectProject: (newProjectId: string) => void;
   // A map from selected platform keys to the projects created by onboarding.
   selectedPlatformToProjectIdMap: {[key in PlatformKey]?: string};
 };
+
+function NewProjectSideBarSection({
+  project,
+  isActive,
+  platformOnCreate,
+  onClick,
+}: {
+  isActive: boolean;
+  onClick: (projectId: Project['id']) => void;
+  platformOnCreate: string;
+  project?: Project;
+}) {
+  const theme = useTheme();
+  const {
+    firstErrorReceived,
+    hasSession,
+    firstTransactionReceived,
+    eventLoading,
+    sessionLoading,
+  } = useHeartbeat({project});
+
+  const loading = eventLoading || sessionLoading;
+  const serverConnected = hasSession || firstTransactionReceived;
+
+  const platform = project ? project.platform || 'other' : platformOnCreate;
+  const platformName = platforms.find(p => p.id === platform)?.name ?? '';
+
+  return (
+    <ProjectWrapper
+      isActive={loading ? false : isActive}
+      onClick={() => project && onClick(project.id)}
+      disabled={!project}
+    >
+      <StyledPlatformIcon platform={platform} size={36} />
+      <MiddleWrapper>
+        <NameWrapper>{platformName}</NameWrapper>
+        {loading ? (
+          <Placeholder height="20px" />
+        ) : !project ? (
+          <Beat color={theme.pink400}>{t('Project deleted')}</Beat>
+        ) : firstErrorReceived ? (
+          <Beat color={theme.successText}>{t('DSN and error received')}</Beat>
+        ) : serverConnected ? (
+          <Beat color={theme.pink400}>{t('Awaiting first error')}</Beat>
+        ) : (
+          <Beat color={theme.pink400}>{t('Awaiting DSN response')}</Beat>
+        )}
+      </MiddleWrapper>
+      {firstErrorReceived ? (
+        <StyledIconCheckmark isCircled color="green400" />
+      ) : (
+        isActive && !loading && <WaitingIndicator />
+      )}
+    </ProjectWrapper>
+  );
+}
+
 function ProjectSidebarSection({
   projects,
   activeProject,
   selectProject,
   checkProjectHasFirstEvent,
   selectedPlatformToProjectIdMap,
+  hasHeartbeatFooter,
 }: Props) {
   const oneProject = (platformOnCreate: string, projectSlug: string) => {
     const project = projects.find(p => p.slug === projectSlug);
@@ -63,7 +126,22 @@ function ProjectSidebarSection({
     <Fragment>
       <Title>{t('Projects to Setup')}</Title>
       {Object.entries(selectedPlatformToProjectIdMap).map(
-        ([platformOnCreate, projectSlug]) => oneProject(platformOnCreate, projectSlug)
+        ([platformOnCreate, projectSlug]) => {
+          if (hasHeartbeatFooter) {
+            const project = projects.find(p => p.slug === projectSlug);
+            const isActive = !!project && activeProject?.id === project.id;
+            return (
+              <NewProjectSideBarSection
+                key={projectSlug}
+                isActive={isActive}
+                project={project}
+                onClick={selectProject}
+                platformOnCreate={platformOnCreate}
+              />
+            );
+          }
+          return oneProject(platformOnCreate, projectSlug);
+        }
       )}
     </Fragment>
   );
@@ -103,6 +181,9 @@ const ProjectWrapper = styled('div')<{disabled: boolean; isActive: boolean}>`
     ${SubHeader} {
       color: ${p.theme.gray400};
     }
+    ${Beat} {
+      color: ${p.theme.gray400};
+    }
     ${NameWrapper} {
       text-decoration-line: line-through;
     }
@@ -141,3 +222,7 @@ const NameWrapper = styled('div')`
   white-space: nowrap;
   text-overflow: ellipsis;
 `;
+
+const Beat = styled('div')<{color: string}>`
+  color: ${p => p.color};
+`;

+ 14 - 3
static/app/views/onboarding/onboarding.tsx

@@ -74,6 +74,7 @@ function Onboarding(props: Props) {
       window.clearTimeout(cornerVariantTimeoutRed.current);
     };
   }, []);
+
   const onboardingSteps = getOrganizationOnboardingSteps();
   const stepObj = onboardingSteps.find(({id}) => stepId === id);
   const stepIndex = onboardingSteps.findIndex(({id}) => stepId === id);
@@ -171,6 +172,7 @@ function Onboarding(props: Props) {
       />
     );
   }
+
   return (
     <OnboardingWrapper data-test-id="targeted-onboarding">
       <SentryDocumentTitle title={stepObj.title} />
@@ -190,7 +192,12 @@ function Onboarding(props: Props) {
           />
         </UpsellWrapper>
       </Header>
-      <Container hasFooter={containerHasFooter}>
+      <Container
+        hasFooter={containerHasFooter}
+        hasHeartbeatFooter={
+          !!organization?.features.includes('onboarding-heartbeat-footer')
+        }
+      >
         <Back animate={stepIndex > 0 ? 'visible' : 'hidden'} onClick={handleGoBack} />
         <AnimatePresence exitBeforeEnter onExitComplete={updateAnimationState}>
           <OnboardingStep key={stepObj.id} data-test-id={`onboarding-step-${stepObj.id}`}>
@@ -203,6 +210,9 @@ function Onboarding(props: Props) {
                 orgId={organization.slug}
                 organization={props.organization}
                 search={props.location.search}
+                route={props.route}
+                router={props.router}
+                location={props.location}
                 {...{
                   genSkipOnboardingLink,
                 }}
@@ -216,13 +226,14 @@ function Onboarding(props: Props) {
   );
 }
 
-const Container = styled('div')<{hasFooter: boolean}>`
+const Container = styled('div')<{hasFooter: boolean; hasHeartbeatFooter: boolean}>`
   flex-grow: 1;
   display: flex;
   flex-direction: column;
   position: relative;
   background: ${p => p.theme.background};
-  padding: 120px ${space(3)};
+  padding: ${p =>
+    p.hasHeartbeatFooter ? `120px ${space(3)} 0 ${space(3)}` : `120px ${space(3)}`};
   width: 100%;
   margin: 0 auto;
   padding-bottom: ${p => p.hasFooter && '72px'};

+ 85 - 39
static/app/views/onboarding/setupDocs.tsx

@@ -23,6 +23,7 @@ import withProjects from 'sentry/utils/withProjects';
 
 import FirstEventFooter from './components/firstEventFooter';
 import FullIntroduction from './components/fullIntroduction';
+import {HeartbeatFooter} from './components/heartbeatFooter';
 import ProjectSidebarSection from './components/projectSidebarSection';
 import IntegrationSetup from './integrationSetup';
 import {StepProps} from './types';
@@ -112,6 +113,9 @@ function SetupDocs({
   projects: rawProjects,
   search,
   loadingProjects,
+  route,
+  router,
+  location,
 }: Props) {
   const api = useApi();
   const [clientState, setClientState] = usePersistedOnboardingState();
@@ -248,7 +252,11 @@ function SetupDocs({
   return (
     <Fragment>
       <Wrapper>
-        <SidebarWrapper>
+        <SidebarWrapper
+          hasHeartbeatFooter={
+            !!organization?.features.includes('onboarding-heartbeat-footer')
+          }
+        >
           <ProjectSidebarSection
             projects={projects}
             selectedPlatformToProjectIdMap={Object.fromEntries(
@@ -259,6 +267,9 @@ function SetupDocs({
             )}
             activeProject={project}
             {...{checkProjectHasFirstEvent, selectProject}}
+            hasHeartbeatFooter={
+              !!organization?.features.includes('onboarding-heartbeat-footer')
+            }
           />
         </SidebarWrapper>
         <MainContent>
@@ -282,43 +293,78 @@ function SetupDocs({
         </MainContent>
       </Wrapper>
 
-      {project && (
-        <FirstEventFooter
-          project={project}
-          organization={organization}
-          isLast={!nextProject}
-          hasFirstEvent={checkProjectHasFirstEvent(project)}
-          onClickSetupLater={() => {
-            const orgIssuesURL = `/organizations/${organization.slug}/issues/?project=${project.id}&referrer=onboarding-setup-docs`;
-            trackAdvancedAnalyticsEvent(
-              'growth.onboarding_clicked_setup_platform_later',
-              {
-                organization,
-                platform: currentPlatform,
-                project_index: projectIndex,
+      {project &&
+        (organization.features?.includes('onboarding-heartbeat-footer') ? (
+          <HeartbeatFooter
+            projectSlug={project.slug}
+            nextProjectSlug={nextProject?.slug}
+            route={route}
+            router={router}
+            location={location}
+            onSetupNextProject={() => {
+              const orgIssuesURL = `/organizations/${organization.slug}/issues/?project=${project.id}&referrer=onboarding-setup-docs`;
+              trackAdvancedAnalyticsEvent(
+                'growth.onboarding_clicked_setup_platform_later',
+                {
+                  organization,
+                  platform: currentPlatform,
+                  project_index: projectIndex,
+                }
+              );
+              if (!project.platform || !clientState) {
+                browserHistory.push(orgIssuesURL);
+                return;
               }
-            );
-            if (!project.platform || !clientState) {
-              browserHistory.push(normalizeUrl(orgIssuesURL));
-              return;
-            }
-            // if we have a next project, switch to that
-            if (nextProject) {
-              setNewProject(nextProject.id);
-            } else {
-              setClientState({
-                ...clientState,
-                state: 'finished',
-              });
-              browserHistory.push(orgIssuesURL);
-            }
-          }}
-          handleFirstIssueReceived={() => {
-            const newHasFirstEventMap = {...hasFirstEventMap, [project.id]: true};
-            setHasFirstEventMap(newHasFirstEventMap);
-          }}
-        />
-      )}
+              // if we have a next project, switch to that
+              if (nextProject) {
+                setNewProject(nextProject.id);
+              } else {
+                setClientState({
+                  ...clientState,
+                  state: 'finished',
+                });
+                browserHistory.push(orgIssuesURL);
+              }
+            }}
+            newOrg
+          />
+        ) : (
+          <FirstEventFooter
+            project={project}
+            organization={organization}
+            isLast={!nextProject}
+            hasFirstEvent={checkProjectHasFirstEvent(project)}
+            onClickSetupLater={() => {
+              const orgIssuesURL = `/organizations/${organization.slug}/issues/?project=${project.id}&referrer=onboarding-setup-docs`;
+              trackAdvancedAnalyticsEvent(
+                'growth.onboarding_clicked_setup_platform_later',
+                {
+                  organization,
+                  platform: currentPlatform,
+                  project_index: projectIndex,
+                }
+              );
+              if (!project.platform || !clientState) {
+                browserHistory.push(orgIssuesURL);
+                return;
+              }
+              // if we have a next project, switch to that
+              if (nextProject) {
+                setNewProject(nextProject.id);
+              } else {
+                setClientState({
+                  ...clientState,
+                  state: 'finished',
+                });
+                browserHistory.push(orgIssuesURL);
+              }
+            }}
+            handleFirstIssueReceived={() => {
+              const newHasFirstEventMap = {...hasFirstEventMap, [project.id]: true};
+              setHasFirstEventMap(newHasFirstEventMap);
+            }}
+          />
+        ))}
     </Fragment>
   );
 }
@@ -415,12 +461,12 @@ const MainContent = styled('div')`
 // the number icon will be space(2) + 30px to the left of the margin of center column
 // so we need to offset the right margin by that much
 // also hide the sidebar if the screen is too small
-const SidebarWrapper = styled('div')`
+const SidebarWrapper = styled('div')<{hasHeartbeatFooter: boolean}>`
   margin: ${space(1)} calc(${space(2)} + 30px + ${space(4)}) 0 ${space(2)};
   @media (max-width: 1150px) {
     display: none;
   }
-  flex-basis: 240px;
+  flex-basis: ${p => (p.hasHeartbeatFooter ? '256px' : '240px')};
   flex-grow: 0;
   flex-shrink: 0;
   min-width: 240px;

+ 6 - 1
static/app/views/onboarding/types.ts

@@ -1,3 +1,5 @@
+import {RouteComponentProps} from 'react-router';
+
 import {PlatformKey} from 'sentry/data/platformCategories';
 import {Organization} from 'sentry/types';
 
@@ -6,7 +8,10 @@ export type StepData = {
 };
 
 // Not sure if we need platform info to be passed down
-export type StepProps = {
+export type StepProps = Pick<
+  RouteComponentProps<{}, {}>,
+  'router' | 'route' | 'location'
+> & {
   active: boolean;
   genSkipOnboardingLink: () => React.ReactNode;
   onComplete: () => void;

+ 12 - 0
static/app/views/projectInstall/platform.spec.jsx

@@ -27,6 +27,12 @@ describe('ProjectInstallPlatform', function () {
         body: {},
       });
 
+      MockApiClient.addMockResponse({
+        method: 'GET',
+        url: '/organizations/org-slug/projects/',
+        body: [baseProps.project],
+      });
+
       render(<ProjectInstallPlatform {...props} />, {
         context: TestStubs.routerContext([{organization: {id: '1337'}}]),
       });
@@ -69,6 +75,12 @@ describe('ProjectInstallPlatform', function () {
         body: {html: '<h1>Documentation here</h1>'},
       });
 
+      MockApiClient.addMockResponse({
+        method: 'GET',
+        url: '/organizations/org-slug/projects/',
+        body: [baseProps.project],
+      });
+
       render(<ProjectInstallPlatform {...props} />, {
         context: TestStubs.routerContext([{organization: {id: '1337'}}]),
       });

+ 46 - 49
static/app/views/projectInstall/platform.tsx

@@ -25,7 +25,7 @@ import Projects from 'sentry/utils/projects';
 import withApi from 'sentry/utils/withApi';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 import withOrganization from 'sentry/utils/withOrganization';
-import {HeartbeatFooter} from 'sentry/views/projectInstall/heartbeatFooter';
+import {HeartbeatFooter} from 'sentry/views/onboarding/components/heartbeatFooter';
 
 type Props = {
   api: Client;
@@ -176,60 +176,57 @@ class ProjectInstallPlatform extends Component<Props, State> {
           )}
 
           {this.isGettingStarted &&
-            !organization.features?.includes('onboarding-heartbeat-footer') && (
-              <Projects
-                key={`${organization.slug}-${projectId}`}
-                orgId={organization.slug}
-                slugs={[projectId]}
-                passthroughPlaceholderProject={false}
-              >
-                {({projects, initiallyLoaded, fetching, fetchError}) => {
-                  const projectsLoading = !initiallyLoaded && fetching;
-                  const projectFilter =
-                    !projectsLoading && !fetchError && projects.length
-                      ? {
-                          project: (projects[0] as Project).id,
-                        }
-                      : {};
-
-                  return (
-                    <StyledButtonBar gap={1}>
-                      <Button
-                        priority="primary"
-                        busy={projectsLoading}
-                        to={{
-                          pathname: issueStreamLink,
-                          query: projectFilter,
-                          hash: '#welcome',
-                        }}
-                      >
-                        {t('Take me to Issues')}
-                      </Button>
-                      <Button
-                        busy={projectsLoading}
-                        to={{
-                          pathname: performanceOverviewLink,
-                          query: projectFilter,
-                        }}
-                      >
-                        {t('Take me to Performance')}
-                      </Button>
-                    </StyledButtonBar>
-                  );
-                }}
-              </Projects>
-            )}
-        </div>
-        {this.isGettingStarted &&
-          organization.features?.includes('onboarding-heartbeat-footer') && (
+          !!organization?.features.includes('onboarding-heartbeat-footer') ? (
             <HeartbeatFooter
               projectSlug={projectId}
-              issueStreamLink={issueStreamLink}
-              performanceOverviewLink={performanceOverviewLink}
               route={this.props.route}
               router={this.props.router}
+              location={this.props.location}
             />
+          ) : (
+            <Projects
+              key={`${organization.slug}-${projectId}`}
+              orgId={organization.slug}
+              slugs={[projectId]}
+              passthroughPlaceholderProject={false}
+            >
+              {({projects, initiallyLoaded, fetching, fetchError}) => {
+                const projectsLoading = !initiallyLoaded && fetching;
+                const projectFilter =
+                  !projectsLoading && !fetchError && projects.length
+                    ? {
+                        project: (projects[0] as Project).id,
+                      }
+                    : {};
+
+                return (
+                  <StyledButtonBar gap={1}>
+                    <Button
+                      priority="primary"
+                      busy={projectsLoading}
+                      to={{
+                        pathname: issueStreamLink,
+                        query: projectFilter,
+                        hash: '#welcome',
+                      }}
+                    >
+                      {t('Take me to Issues')}
+                    </Button>
+                    <Button
+                      busy={projectsLoading}
+                      to={{
+                        pathname: performanceOverviewLink,
+                        query: projectFilter,
+                      }}
+                    >
+                      {t('Take me to Performance')}
+                    </Button>
+                  </StyledButtonBar>
+                );
+              }}
+            </Projects>
           )}
+        </div>
       </Fragment>
     );
   }