Browse Source

fix(onboarding): Fix onboarding redirects - (#45639)

Priscila Oliveira 2 years ago
parent
commit
f98e88d9ea

+ 49 - 32
static/app/views/onboarding/components/footer.spec.tsx → static/app/components/onboarding/footer.spec.tsx

@@ -7,9 +7,11 @@ import {
   waitFor,
 } from 'sentry-test/reactTestingLibrary';
 
+import {Footer} from 'sentry/components/onboarding/footer';
+import {OnboardingContextProvider} from 'sentry/components/onboarding/onboardingContext';
 import {PersistedStoreProvider} from 'sentry/stores/persistedStore';
+import {OnboardingStatus} from 'sentry/types';
 import * as useSessionStorage from 'sentry/utils/useSessionStorage';
-import {Footer, OnboardingStatus} from 'sentry/views/onboarding/components/footer';
 
 describe('Onboarding Footer', function () {
   it('waiting for error ui', async function () {
@@ -21,15 +23,18 @@ describe('Onboarding Footer', function () {
     });
 
     render(
-      <PersistedStoreProvider>
-        <Footer
-          projectSlug={project.slug}
-          location={router.location}
-          route={route}
-          router={router}
-          newOrg
-        />
-      </PersistedStoreProvider>,
+      <OnboardingContextProvider>
+        <PersistedStoreProvider>
+          <Footer
+            projectId={project.id}
+            projectSlug={project.slug}
+            location={router.location}
+            route={route}
+            router={router}
+            newOrg
+          />
+        </PersistedStoreProvider>
+      </OnboardingContextProvider>,
       {
         organization,
       }
@@ -63,23 +68,29 @@ describe('Onboarding Footer', function () {
     // Mock useSessionStorage hook to return the mocked session data
     jest.spyOn(useSessionStorage, 'useSessionStorage').mockImplementation(() => [
       {
-        status: OnboardingStatus.PROCESSING,
-        firstIssueId: undefined,
+        [project.id]: {
+          status: OnboardingStatus.PROCESSING,
+          firstIssueId: '1',
+          slug: project.slug,
+        },
       },
       jest.fn(),
       jest.fn(),
     ]);
 
     render(
-      <PersistedStoreProvider>
-        <Footer
-          projectSlug={project.slug}
-          location={router.location}
-          route={route}
-          router={router}
-          newOrg
-        />
-      </PersistedStoreProvider>,
+      <OnboardingContextProvider>
+        <PersistedStoreProvider>
+          <Footer
+            projectId={project.id}
+            projectSlug={project.slug}
+            location={router.location}
+            route={route}
+            router={router}
+            newOrg
+          />
+        </PersistedStoreProvider>
+      </OnboardingContextProvider>,
       {
         organization,
       }
@@ -107,23 +118,29 @@ describe('Onboarding Footer', function () {
     // Mock useSessionStorage hook to return the mocked session data
     jest.spyOn(useSessionStorage, 'useSessionStorage').mockImplementation(() => [
       {
-        status: OnboardingStatus.PROCESSED,
-        firstIssueId: '1',
+        [project.id]: {
+          status: OnboardingStatus.PROCESSED,
+          firstIssueId: '1',
+          slug: project.slug,
+        },
       },
       jest.fn(),
       jest.fn(),
     ]);
 
     render(
-      <PersistedStoreProvider>
-        <Footer
-          projectSlug={project.slug}
-          location={router.location}
-          route={route}
-          router={router}
-          newOrg
-        />
-      </PersistedStoreProvider>,
+      <OnboardingContextProvider>
+        <PersistedStoreProvider>
+          <Footer
+            projectId={project.id}
+            projectSlug={project.slug}
+            location={router.location}
+            route={route}
+            router={router}
+            newOrg
+          />
+        </PersistedStoreProvider>
+      </OnboardingContextProvider>,
       {
         organization,
       }

+ 145 - 84
static/app/views/onboarding/components/footer.tsx → static/app/components/onboarding/footer.tsx

@@ -1,4 +1,4 @@
-import {useCallback, useEffect, useState} from 'react';
+import {useCallback, useContext, useEffect, useState} from 'react';
 import {RouteComponentProps} from 'react-router';
 import isPropValid from '@emotion/is-prop-valid';
 import {css} from '@emotion/react';
@@ -8,27 +8,19 @@ import {Location} from 'history';
 import {addSuccessMessage} from 'sentry/actionCreators/indicator';
 import {openModal} from 'sentry/actionCreators/modal';
 import {Button} from 'sentry/components/button';
+import {OnboardingContext} from 'sentry/components/onboarding/onboardingContext';
 import {IconCheckmark, IconCircle, IconRefresh} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import PreferencesStore from 'sentry/stores/preferencesStore';
 import {useLegacyStore} from 'sentry/stores/useLegacyStore';
 import {space} from 'sentry/styles/space';
-import {Group, Project} from 'sentry/types';
+import {Group, OnboardingStatus, Project} from 'sentry/types';
 import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
 import {useQuery} from 'sentry/utils/queryClient';
 import useOrganization from 'sentry/utils/useOrganization';
 import useProjects from 'sentry/utils/useProjects';
-import {useSessionStorage} from 'sentry/utils/useSessionStorage';
-
-import {usePersistedOnboardingState} from '../utils';
-
-import GenericFooter from './genericFooter';
-
-export enum OnboardingStatus {
-  WAITING = 'waiting',
-  PROCESSING = 'processing',
-  PROCESSED = 'processed',
-}
+import GenericFooter from 'sentry/views/onboarding/components/genericFooter';
+import {usePersistedOnboardingState} from 'sentry/views/onboarding/utils';
 
 export type OnboardingState = {
   status: OnboardingStatus;
@@ -76,22 +68,14 @@ export function Footer({projectSlug, projectId, router, newOrg}: Props) {
   const [firstIssue, setFirstIssue] = useState<Group | undefined>(undefined);
   const [clientState, setClientState] = usePersistedOnboardingState();
   const {projects} = useProjects();
-
-  const onboarding_sessionStorage_key = `onboarding-${projectId}`;
-
-  const [sessionStorage, setSessionStorage] = useSessionStorage<OnboardingState>(
-    onboarding_sessionStorage_key,
-    {
-      status: OnboardingStatus.WAITING,
-      firstIssueId: undefined,
-    }
-  );
+  const onboardingContext = useContext(OnboardingContext);
+  const projectData = projectId ? onboardingContext.data[projectId] : undefined;
 
   useQuery<Project>([`/projects/${organization.slug}/${projectSlug}/`], {
     staleTime: 0,
     refetchInterval: DEFAULT_POLL_INTERVAL,
     enabled:
-      !!projectSlug && !firstError && sessionStorage.status === OnboardingStatus.WAITING, // Fetch only if the project is available and we have not yet received an error,
+      !!projectSlug && !firstError && projectData?.status === OnboardingStatus.WAITING, // Fetch only if the project is available and we have not yet received an error,
     onSuccess: data => {
       setFirstError(data.firstEvent);
     },
@@ -104,17 +88,102 @@ export function Footer({projectSlug, projectId, router, newOrg}: Props) {
   useQuery<Group[]>([`/projects/${organization.slug}/${projectSlug}/issues/`], {
     staleTime: 0,
     enabled:
-      !!firstError &&
-      !firstIssue &&
-      sessionStorage.status === OnboardingStatus.PROCESSING, // Only fetch if an error event is received and we have not yet located the first issue,
+      !!firstError && !firstIssue && projectData?.status === OnboardingStatus.PROCESSING, // Only fetch if an error event is received and we have not yet located the first issue,
     onSuccess: data => {
       setFirstIssue(data.find((issue: Group) => issue.firstSeen === firstError));
     },
   });
 
+  useEffect(() => {
+    if (!projectId || !!projectData) {
+      return;
+    }
+
+    onboardingContext.setProjectData({
+      projectId,
+      projectSlug,
+      status: OnboardingStatus.WAITING,
+    });
+  }, [projectData, onboardingContext, projectSlug, projectId]);
+
+  useEffect(() => {
+    if (!projectId) {
+      return;
+    }
+
+    if (!firstError) {
+      return;
+    }
+
+    if (projectData?.status !== OnboardingStatus.WAITING) {
+      return;
+    }
+
+    trackAdvancedAnalyticsEvent('onboarding.first_error_received', {
+      organization,
+      new_organization: !!newOrg,
+    });
+
+    onboardingContext.setProjectData({
+      projectId,
+      projectSlug,
+      status: OnboardingStatus.PROCESSING,
+    });
+
+    addSuccessMessage(t('First error received'));
+  }, [
+    firstError,
+    newOrg,
+    organization,
+    projectId,
+    projectData,
+    onboardingContext,
+    projectSlug,
+  ]);
+
+  useEffect(() => {
+    if (!projectId) {
+      return;
+    }
+
+    if (!firstIssue) {
+      return;
+    }
+
+    if (projectData?.status !== OnboardingStatus.PROCESSING) {
+      return;
+    }
+
+    trackAdvancedAnalyticsEvent('onboarding.first_error_processed', {
+      organization,
+      new_organization: !!newOrg,
+    });
+
+    onboardingContext.setProjectData({
+      projectId,
+      projectSlug,
+      status: OnboardingStatus.PROCESSED,
+      firstIssueId: firstIssue.id,
+    });
+
+    addSuccessMessage(t('First error processed'));
+  }, [
+    firstIssue,
+    newOrg,
+    organization,
+    projectData,
+    projectId,
+    onboardingContext,
+    projectSlug,
+  ]);
+
   // The explore button is only showed if Sentry has not yet received any errors OR the issue is still being processed
   const handleExploreSentry = useCallback(() => {
-    if (sessionStorage.status === OnboardingStatus.WAITING) {
+    if (!projectId) {
+      return;
+    }
+
+    if (onboardingContext.data[projectId].status === OnboardingStatus.WAITING) {
       return;
     }
 
@@ -122,19 +191,25 @@ export function Footer({projectSlug, projectId, router, newOrg}: Props) {
       organization,
     });
 
-    openChangeRouteModal({
-      router,
-      nextLocation: {
-        ...router.location,
-        pathname: `/organizations/${organization.slug}/issues/?referrer=onboarding-first-event-footer`,
-      },
-      setClientState,
-      clientState,
+    if (clientState) {
+      setClientState({
+        ...clientState,
+        state: 'finished',
+      });
+    }
+
+    router.push({
+      ...router.location,
+      pathname: `/organizations/${organization.slug}/issues/?referrer=onboarding-first-event-footer`,
     });
-  }, [router, organization, sessionStorage.status, setClientState, clientState]);
+  }, [organization, projectId, onboardingContext, clientState, router, setClientState]);
 
   const handleSkipOnboarding = useCallback(() => {
-    if (sessionStorage.status !== OnboardingStatus.WAITING) {
+    if (!projectId) {
+      return;
+    }
+
+    if (onboardingContext.data[projectId].status !== OnboardingStatus.WAITING) {
       return;
     }
 
@@ -145,14 +220,16 @@ export function Footer({projectSlug, projectId, router, newOrg}: Props) {
 
     const selectedProjectId = projects.find(project => project.slug === projectSlug)?.id;
 
+    let pathname = `/organizations/${organization.slug}/issues/?`;
+    if (selectedProjectId) {
+      pathname += `project=${selectedProjectId}&`;
+    }
+
     openChangeRouteModal({
       router,
       nextLocation: {
         ...router.location,
-        pathname:
-          `/organizations/${organization.slug}/issues/?` +
-          (selectedProjectId ? `project=${selectedProjectId}&` : '') +
-          `referrer=onboarding-first-event-footer-skip`,
+        pathname: (pathname += `referrer=onboarding-first-event-footer-skip`),
       },
       setClientState,
       clientState,
@@ -160,77 +237,61 @@ export function Footer({projectSlug, projectId, router, newOrg}: Props) {
   }, [
     router,
     organization,
-    sessionStorage.status,
     setClientState,
     clientState,
     projects,
     projectSlug,
+    onboardingContext,
+    projectId,
   ]);
 
-  useEffect(() => {
-    if (!firstError) {
-      return;
-    }
-
-    if (sessionStorage.status !== OnboardingStatus.WAITING) {
+  const handleViewError = useCallback(() => {
+    if (!projectId) {
       return;
     }
 
-    trackAdvancedAnalyticsEvent('onboarding.first_error_received', {
+    trackAdvancedAnalyticsEvent('onboarding.view_error_button_clicked', {
       organization,
       new_organization: !!newOrg,
     });
 
-    setSessionStorage({status: OnboardingStatus.PROCESSING});
-    addSuccessMessage(t('First error received'));
-  }, [firstError, newOrg, organization, setSessionStorage, sessionStorage]);
-
-  useEffect(() => {
-    if (!firstIssue) {
-      return;
+    if (clientState) {
+      setClientState({
+        ...clientState,
+        state: 'finished',
+      });
     }
 
-    if (sessionStorage.status !== OnboardingStatus.PROCESSING) {
-      return;
-    }
-
-    trackAdvancedAnalyticsEvent('onboarding.first_error_processed', {
-      organization,
-      new_organization: !!newOrg,
-    });
-
-    setSessionStorage({status: OnboardingStatus.PROCESSED, firstIssueId: firstIssue.id});
-    addSuccessMessage(t('First error processed'));
-  }, [firstIssue, newOrg, organization, setSessionStorage, sessionStorage]);
-
-  const handleViewError = useCallback(() => {
-    trackAdvancedAnalyticsEvent('onboarding.view_error_button_clicked', {
-      organization,
-      new_organization: !!newOrg,
-    });
-
     router.push({
       ...router.location,
-      pathname: `/organizations/${organization.slug}/issues/${sessionStorage.firstIssueId}/?referrer=onboarding-first-event-footer`,
+      pathname: `/organizations/${organization.slug}/issues/${onboardingContext.data[projectId].firstIssueId}/?referrer=onboarding-first-event-footer`,
     });
-  }, [organization, newOrg, router, sessionStorage]);
+  }, [
+    organization,
+    newOrg,
+    router,
+    clientState,
+    setClientState,
+    onboardingContext,
+    projectId,
+  ]);
 
   return (
     <Wrapper newOrg={!!newOrg} sidebarCollapsed={!!preferences.collapsed}>
       <Column>
-        {sessionStorage.status === OnboardingStatus.WAITING && newOrg && (
+        {projectData?.status === OnboardingStatus.WAITING && newOrg && (
           <Button onClick={handleSkipOnboarding} priority="link">
             {t('Skip Onboarding')}
           </Button>
         )}
       </Column>
       <StatusesColumn>
-        {sessionStorage.status === OnboardingStatus.WAITING ? (
+        {projectData?.status === OnboardingStatus.WAITING ? (
           <WaitingForErrorStatus>
             <IconCircle size="sm" />
             {t('Waiting for error')}
           </WaitingForErrorStatus>
-        ) : sessionStorage.status === OnboardingStatus.PROCESSED ? (
+        ) : projectData?.status === OnboardingStatus.PROCESSED ? (
           <ErrorProcessedStatus>
             <IconCheckmark isCircled size="sm" color="green300" />
             {t('Error Processed!')}
@@ -243,17 +304,17 @@ export function Footer({projectSlug, projectId, router, newOrg}: Props) {
         )}
       </StatusesColumn>
       <ActionsColumn>
-        {sessionStorage.status === OnboardingStatus.PROCESSED ? (
+        {projectData?.status === OnboardingStatus.PROCESSED ? (
           <Button priority="primary" onClick={handleViewError}>
             {t('View Error')}
           </Button>
         ) : (
           <Button
             priority="primary"
-            disabled={sessionStorage.status === OnboardingStatus.WAITING}
+            disabled={projectData?.status === OnboardingStatus.WAITING}
             onClick={handleExploreSentry}
             title={
-              sessionStorage.status === OnboardingStatus.WAITING
+              projectData?.status === OnboardingStatus.WAITING
                 ? t('Waiting for error')
                 : undefined
             }

+ 66 - 0
static/app/components/onboarding/onboardingContext.tsx

@@ -0,0 +1,66 @@
+import {createContext, useCallback} from 'react';
+
+import {OnboardingStatus} from 'sentry/types';
+import {useSessionStorage} from 'sentry/utils/useSessionStorage';
+
+type Data = Record<
+  string,
+  {
+    slug: string;
+    status: OnboardingStatus;
+    firstIssueId?: string;
+  }
+>;
+
+export type OnboardingContextProps = {
+  data: Data;
+  setProjectData: (props: {
+    projectId: string;
+    projectSlug: string;
+    status: OnboardingStatus;
+    firstIssueId?: string;
+  }) => void;
+};
+
+export const OnboardingContext = createContext<OnboardingContextProps>({
+  setProjectData: () => {},
+  data: {},
+});
+
+type ProviderProps = {
+  children: React.ReactNode;
+};
+
+export const OnboardingContextProvider = ({children}: ProviderProps) => {
+  const [sessionStorage, setSessionStorage] = useSessionStorage<Data>('onboarding', {});
+
+  const setProjectData = useCallback(
+    ({
+      projectId,
+      projectSlug,
+      status,
+      firstIssueId,
+    }: {
+      projectId: string;
+      projectSlug: string;
+      status: OnboardingStatus;
+      firstIssueId?: string;
+    }) => {
+      setSessionStorage({
+        ...sessionStorage,
+        [projectId]: {
+          status,
+          firstIssueId,
+          slug: projectSlug,
+        },
+      });
+    },
+    [setSessionStorage, sessionStorage]
+  );
+
+  return (
+    <OnboardingContext.Provider value={{data: sessionStorage, setProjectData}}>
+      {children}
+    </OnboardingContext.Provider>
+  );
+};

+ 47 - 23
static/app/components/platformPicker.tsx

@@ -9,6 +9,7 @@ import ExternalLink from 'sentry/components/links/externalLink';
 import ListLink from 'sentry/components/links/listLink';
 import NavTabs from 'sentry/components/navTabs';
 import SearchBar from 'sentry/components/searchBar';
+import {Tooltip} from 'sentry/components/tooltip';
 import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
 import categoryList, {filterAliases, PlatformKey} from 'sentry/data/platformCategories';
 import platforms from 'sentry/data/platforms';
@@ -32,6 +33,7 @@ type Category = (typeof PLATFORM_CATEGORIES)[number]['id'];
 interface PlatformPickerProps {
   setPlatform: (key: PlatformKey | null) => void;
   defaultCategory?: Category;
+  disabledPlatforms?: {[key in PlatformKey]?: string};
   listClassName?: string;
   listProps?: React.HTMLAttributes<HTMLDivElement>;
   noAutoFilter?: boolean;
@@ -105,7 +107,7 @@ class PlatformPicker extends Component<PlatformPickerProps, State> {
 
   render() {
     const platformList = this.platformList;
-    const {setPlatform, listProps, listClassName} = this.props;
+    const {setPlatform, listProps, listClassName, disabledPlatforms} = this.props;
     const {filter, category} = this.state;
 
     return (
@@ -139,26 +141,47 @@ class PlatformPicker extends Component<PlatformPickerProps, State> {
           />
         </NavContainer>
         <PlatformList className={listClassName} {...listProps}>
-          {platformList.map(platform => (
-            <PlatformCard
-              data-test-id={`platform-${platform.id}`}
-              key={platform.id}
-              platform={platform}
-              selected={this.props.platform === platform.id}
-              onClear={(e: React.MouseEvent) => {
-                setPlatform(null);
-                e.stopPropagation();
-              }}
-              onClick={() => {
-                trackAdvancedAnalyticsEvent('growth.select_platform', {
-                  platform_id: platform.id,
-                  source: this.props.source,
-                  organization: this.props.organization ?? null,
-                });
-                setPlatform(platform.id as PlatformKey);
-              }}
-            />
-          ))}
+          {platformList.map(platform => {
+            const disabled = !!disabledPlatforms?.[platform.id as PlatformKey];
+            const content = (
+              <PlatformCard
+                data-test-id={`platform-${platform.id}`}
+                key={platform.id}
+                platform={platform}
+                disabled={disabled}
+                selected={this.props.platform === platform.id}
+                onClear={(e: React.MouseEvent) => {
+                  setPlatform(null);
+                  e.stopPropagation();
+                }}
+                onClick={() => {
+                  if (disabled) {
+                    return;
+                  }
+
+                  trackAdvancedAnalyticsEvent('growth.select_platform', {
+                    platform_id: platform.id,
+                    source: this.props.source,
+                    organization: this.props.organization ?? null,
+                  });
+                  setPlatform(platform.id as PlatformKey);
+                }}
+              />
+            );
+
+            if (disabled) {
+              return (
+                <Tooltip
+                  title={disabledPlatforms?.[platform.id as PlatformKey]}
+                  key={platform.id}
+                >
+                  {content}
+                </Tooltip>
+              );
+            }
+
+            return content;
+          })}
         </PlatformList>
         {platformList.length === 0 && (
           <EmptyMessage
@@ -248,7 +271,6 @@ const PlatformCard = styled(({platform, selected, onClear, ...props}) => (
       withLanguageIcon
       format="lg"
     />
-
     <h3>{platform.name}</h3>
     {selected && <ClearButton onClick={onClear} aria-label={t('Clear')} />}
   </div>
@@ -259,13 +281,15 @@ const PlatformCard = styled(({platform, selected, onClear, ...props}) => (
   align-items: center;
   padding: 0 0 14px;
   border-radius: 4px;
-  cursor: pointer;
   background: ${p => p.selected && p.theme.alert.info.backgroundLight};
 
   &:hover {
     background: ${p => p.theme.alert.muted.backgroundLight};
   }
 
+  opacity: ${p => (p.disabled ? 0.2 : null)};
+  cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
+
   h3 {
     flex-grow: 1;
     display: flex;

+ 6 - 0
static/app/types/onboarding.tsx

@@ -126,3 +126,9 @@ interface OnboardingTaskWithExternal
 }
 
 export type OnboardingTask = OnboardingTaskWithAction | OnboardingTaskWithExternal;
+
+export enum OnboardingStatus {
+  WAITING = 'waiting',
+  PROCESSING = 'processing',
+  PROCESSED = 'processed',
+}

+ 4 - 0
static/app/views/onboarding/deprecatedPlatform.tsx

@@ -7,6 +7,7 @@ import MultiPlatformPicker from 'sentry/components/multiPlatformPicker';
 import {PlatformKey} from 'sentry/data/platformCategories';
 import {t, tct} from 'sentry/locale';
 import testableTransition from 'sentry/utils/testableTransition';
+import useOrganization from 'sentry/utils/useOrganization';
 import StepHeading from 'sentry/views/onboarding/components/stepHeading';
 
 import CreateProjectsFooter from './components/createProjectsFooter';
@@ -14,6 +15,7 @@ import {StepProps} from './types';
 import {usePersistedOnboardingState} from './utils';
 
 function OnboardingPlatform(props: StepProps) {
+  const organization = useOrganization();
   const [selectedPlatforms, setSelectedPlatforms] = useState<PlatformKey[]>([]);
   const addPlatform = (platform: PlatformKey) => {
     setSelectedPlatforms([...selectedPlatforms, platform]);
@@ -55,6 +57,7 @@ function OnboardingPlatform(props: StepProps) {
           noAutoFilter
           source="targeted-onboarding"
           {...props}
+          organization={organization}
           removePlatform={removePlatform}
           addPlatform={addPlatform}
           platforms={selectedPlatforms}
@@ -62,6 +65,7 @@ function OnboardingPlatform(props: StepProps) {
       </motion.div>
       <CreateProjectsFooter
         {...props}
+        organization={organization}
         clearPlatforms={clearPlatforms}
         platforms={selectedPlatforms}
       />

+ 13 - 454
static/app/views/onboarding/index.tsx

@@ -1,461 +1,20 @@
-import {useCallback, useEffect, useRef, useState} from 'react';
-import {browserHistory, RouteComponentProps} from 'react-router';
-import styled from '@emotion/styled';
-import * as Sentry from '@sentry/react';
-import {AnimatePresence, motion, MotionProps, useAnimation} from 'framer-motion';
+import {RouteComponentProps} from 'react-router';
 
-import {removeProject} from 'sentry/actionCreators/projects';
-import {Button, ButtonProps} from 'sentry/components/button';
-import Hook from 'sentry/components/hook';
-import Link from 'sentry/components/links/link';
-import LogoSentry from 'sentry/components/logoSentry';
-import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
-import {PlatformKey} from 'sentry/data/platformCategories';
-import {IconArrow} from 'sentry/icons';
-import {t} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
-import {Organization, Project} from 'sentry/types';
-import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
-import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse';
-import Redirect from 'sentry/utils/redirect';
-import testableTransition from 'sentry/utils/testableTransition';
-import useApi from 'sentry/utils/useApi';
-import {useSessionStorage} from 'sentry/utils/useSessionStorage';
-import {normalizeUrl} from 'sentry/utils/withDomainRequired';
-import withOrganization from 'sentry/utils/withOrganization';
-import withProjects from 'sentry/utils/withProjects';
-import PageCorners from 'sentry/views/onboarding/components/pageCorners';
+import {OnboardingContextProvider} from 'sentry/components/onboarding/onboardingContext';
 
-import {OnboardingState, OnboardingStatus} from './components/footer';
-import Stepper from './components/stepper';
-import OnboardingPlatform from './deprecatedPlatform';
-import {PlatformSelection} from './platformSelection';
-import SetupDocs from './setupDocs';
-import {StepDescriptor} from './types';
-import {usePersistedOnboardingState} from './utils';
-import TargetedOnboardingWelcome from './welcome';
+import Onboarding from './onboarding';
 
-type RouteParams = {
-  step: string;
-};
-
-type Props = RouteComponentProps<RouteParams, {}> & {
-  organization: Organization;
-  projects: Project[];
-};
-
-function getOrganizationOnboardingSteps(singleSelectPlatform: boolean): StepDescriptor[] {
-  return [
-    {
-      id: 'welcome',
-      title: t('Welcome'),
-      Component: TargetedOnboardingWelcome,
-      cornerVariant: 'top-right',
-    },
-    {
-      ...(singleSelectPlatform
-        ? {
-            id: 'select-platform',
-            title: t('Select platform'),
-            Component: PlatformSelection,
-            hasFooter: true,
-            cornerVariant: 'top-left',
-          }
-        : {
-            id: 'select-platform',
-            title: t('Select platforms'),
-            Component: OnboardingPlatform,
-            hasFooter: true,
-            cornerVariant: 'top-left',
-          }),
-    },
-    {
-      id: 'setup-docs',
-      title: t('Install the Sentry SDK'),
-      Component: SetupDocs,
-      hasFooter: true,
-      cornerVariant: 'top-left',
-    },
-  ];
-}
-
-function Onboarding(props: Props) {
-  const api = useApi();
-  const [clientState, setClientState] = usePersistedOnboardingState();
-
-  const selectedPlatforms = clientState?.selectedPlatforms || [];
-  const platformToProjectIdMap = clientState?.platformToProjectIdMap || {};
-
-  const selectedProjectSlugs = selectedPlatforms
-    .map(platform => platformToProjectIdMap[platform])
-    .filter((slug): slug is string => slug !== undefined);
-
-  const selectedProjectSlug = selectedProjectSlugs[0];
-  const selectedProject = props.projects.find(p => p.slug === selectedProjectSlug);
-
-  const [sessionStorage] = useSessionStorage<Partial<OnboardingState>>(
-    `onboarding-${selectedProject?.id}`,
-    {}
-  );
-
-  const {
-    organization,
-    params: {step: stepId},
-  } = props;
-
-  const cornerVariantTimeoutRed = useRef<number | undefined>(undefined);
-
-  useEffect(() => {
-    return () => {
-      window.clearTimeout(cornerVariantTimeoutRed.current);
-    };
-  }, []);
-
-  const heartbeatFooter = !!props.organization?.features.includes(
-    'onboarding-heartbeat-footer'
-  );
-
-  const singleSelectPlatform = !!props.organization?.features.includes(
-    'onboarding-remove-multiselect-platform'
-  );
-
-  const projectDeletionOnBackClick = !!props.organization?.features.includes(
-    'onboarding-project-deletion-on-back-click'
-  );
-
-  const onboardingSteps = getOrganizationOnboardingSteps(singleSelectPlatform);
-  const stepObj = onboardingSteps.find(({id}) => stepId === id);
-  const stepIndex = onboardingSteps.findIndex(({id}) => stepId === id);
-
-  const cornerVariantControl = useAnimation();
-  const updateCornerVariant = () => {
-    // TODO: find better way to delay the corner animation
-    window.clearTimeout(cornerVariantTimeoutRed.current);
-
-    cornerVariantTimeoutRed.current = window.setTimeout(
-      () => cornerVariantControl.start(stepIndex === 0 ? 'top-right' : 'top-left'),
-      1000
-    );
-  };
-
-  useEffect(updateCornerVariant, [stepIndex, cornerVariantControl]);
-
-  // Called onExitComplete
-  const [containerHasFooter, setContainerHasFooter] = useState<boolean>(false);
-  const updateAnimationState = () => {
-    if (!stepObj) {
-      return;
-    }
-
-    setContainerHasFooter(stepObj.hasFooter ?? false);
-  };
-
-  const goToStep = (step: StepDescriptor) => {
-    if (!stepObj) {
-      return;
-    }
-    if (step.cornerVariant !== stepObj.cornerVariant) {
-      cornerVariantControl.start('none');
-    }
-    browserHistory.push(normalizeUrl(`/onboarding/${organization.slug}/${step.id}/`));
-  };
-
-  const goNextStep = (step: StepDescriptor) => {
-    const currentStepIndex = onboardingSteps.findIndex(s => s.id === step.id);
-    const nextStep = onboardingSteps[currentStepIndex + 1];
-    if (step.cornerVariant !== nextStep.cornerVariant) {
-      cornerVariantControl.start('none');
-    }
-    browserHistory.push(normalizeUrl(`/onboarding/${organization.slug}/${nextStep.id}/`));
-  };
-
-  const handleGoBack = async () => {
-    if (!stepObj) {
-      return;
-    }
-
-    const previousStep = onboardingSteps[stepIndex - 1];
-
-    if (!previousStep) {
-      return;
-    }
-
-    // The user is going back to select a new platform,
-    // so we silently delete the last created project
-    if (projectDeletionOnBackClick && stepIndex === onboardingSteps.length - 1) {
-      if (
-        sessionStorage.status === OnboardingStatus.WAITING ||
-        sessionStorage.status === undefined
-      ) {
-        try {
-          await removeProject(api, organization.slug, selectedProjectSlug);
-        } catch (error) {
-          handleXhrErrorResponse(t('Unable to delete project'))(error);
-          // we don't give the user any feedback regarding this error as this shall be silent
-        }
-      }
-
-      if (clientState) {
-        setClientState({
-          url: 'setup-docs/',
-          state: 'projects_selected',
-          selectedPlatforms: [selectedProjectSlugs[0] as PlatformKey],
-          platformToProjectIdMap:
-            sessionStorage.status === OnboardingStatus.WAITING ||
-            sessionStorage.status === undefined
-              ? Object.keys(platformToProjectIdMap).reduce((acc, value) => {
-                  if (value !== selectedProjectSlug && !acc[value]) {
-                    acc[value] = value;
-                  }
-                  return acc;
-                }, {})
-              : clientState.platformToProjectIdMap,
-        });
-      }
-    }
-
-    if (stepObj.cornerVariant !== previousStep.cornerVariant) {
-      cornerVariantControl.start('none');
-    }
-
-    trackAdvancedAnalyticsEvent('onboarding.back_button_clicked', {
-      organization,
-      from: onboardingSteps[stepIndex].id,
-      to: previousStep.id,
-    });
-
-    browserHistory.replace(
-      normalizeUrl(`/onboarding/${organization.slug}/${previousStep.id}/`)
-    );
-  };
-
-  const genSkipOnboardingLink = () => {
-    const source = `targeted-onboarding-${stepId}`;
-    return (
-      <SkipOnboardingLink
-        onClick={() => {
-          trackAdvancedAnalyticsEvent('growth.onboarding_clicked_skip', {
-            organization,
-            source,
-          });
-          if (clientState) {
-            setClientState({
-              ...clientState,
-              state: 'skipped',
-            });
-          }
-        }}
-        to={normalizeUrl(
-          `/organizations/${organization.slug}/issues/?referrer=onboarding-skip`
-        )}
-      >
-        {t('Skip Onboarding')}
-      </SkipOnboardingLink>
-    );
-  };
-
-  const jumpToSetupProject = useCallback(() => {
-    const nextStep = onboardingSteps.find(({id}) => id === 'setup-docs');
-    if (!nextStep) {
-      Sentry.captureMessage(
-        'Missing step in onboarding: `setup-docs` when trying to jump there'
-      );
-      return;
-    }
-    browserHistory.push(normalizeUrl(`/onboarding/${organization.slug}/${nextStep.id}/`));
-  }, [onboardingSteps, organization]);
-
-  if (!stepObj || stepIndex === -1) {
-    return (
-      <Redirect
-        to={normalizeUrl(`/onboarding/${organization.slug}/${onboardingSteps[0].id}/`)}
-      />
-    );
-  }
+type Props = RouteComponentProps<
+  {
+    step: string;
+  },
+  {}
+>;
 
+export default function OnboardingContainer(props: Props) {
   return (
-    <OnboardingWrapper data-test-id="targeted-onboarding">
-      <SentryDocumentTitle title={stepObj.title} />
-      <Header>
-        <LogoSvg />
-        {stepIndex !== -1 && (
-          <StyledStepper
-            numSteps={onboardingSteps.length}
-            currentStepIndex={stepIndex}
-            onClick={i => goToStep(onboardingSteps[i])}
-          />
-        )}
-        <UpsellWrapper>
-          <Hook
-            name="onboarding:targeted-onboarding-header"
-            source="targeted-onboarding"
-          />
-        </UpsellWrapper>
-      </Header>
-      <Container hasFooter={containerHasFooter} heartbeatFooter={heartbeatFooter}>
-        <Back animate={stepIndex > 0 ? 'visible' : 'hidden'} onClick={handleGoBack} />
-        <AnimatePresence exitBeforeEnter onExitComplete={updateAnimationState}>
-          <OnboardingStep key={stepObj.id} data-test-id={`onboarding-step-${stepObj.id}`}>
-            {stepObj.Component && (
-              <stepObj.Component
-                active
-                data-test-id={`onboarding-step-${stepObj.id}`}
-                stepIndex={stepIndex}
-                onComplete={() => stepObj && goNextStep(stepObj)}
-                orgId={organization.slug}
-                organization={props.organization}
-                search={props.location.search}
-                route={props.route}
-                router={props.router}
-                location={props.location}
-                jumpToSetupProject={jumpToSetupProject}
-                {...{
-                  genSkipOnboardingLink,
-                }}
-              />
-            )}
-          </OnboardingStep>
-        </AnimatePresence>
-        <AdaptivePageCorners animateVariant={cornerVariantControl} />
-      </Container>
-    </OnboardingWrapper>
+    <OnboardingContextProvider>
+      <Onboarding {...props} />
+    </OnboardingContextProvider>
   );
 }
-
-const Container = styled('div')<{hasFooter: boolean; heartbeatFooter: boolean}>`
-  flex-grow: 1;
-  display: flex;
-  flex-direction: column;
-  position: relative;
-  background: ${p => p.theme.background};
-  padding: ${p =>
-    p.heartbeatFooter ? `120px ${space(3)} 0 ${space(3)}` : `120px ${space(3)}`};
-  width: 100%;
-  margin: 0 auto;
-  padding-bottom: ${p => p.hasFooter && '72px'};
-  margin-bottom: ${p => p.hasFooter && '72px'};
-`;
-
-const Header = styled('header')`
-  background: ${p => p.theme.background};
-  padding-left: ${space(4)};
-  padding-right: ${space(4)};
-  position: sticky;
-  height: 80px;
-  align-items: center;
-  top: 0;
-  z-index: 100;
-  box-shadow: 0 5px 10px rgba(0, 0, 0, 0.05);
-  display: grid;
-  grid-template-columns: 1fr 1fr 1fr;
-  justify-items: stretch;
-`;
-
-const LogoSvg = styled(LogoSentry)`
-  width: 130px;
-  height: 30px;
-  color: ${p => p.theme.textColor};
-`;
-
-const OnboardingStep = styled(motion.div)`
-  flex-grow: 1;
-  display: flex;
-  flex-direction: column;
-`;
-
-OnboardingStep.defaultProps = {
-  initial: 'initial',
-  animate: 'animate',
-  exit: 'exit',
-  variants: {animate: {}},
-  transition: testableTransition({
-    staggerChildren: 0.2,
-  }),
-};
-
-const Sidebar = styled(motion.div)`
-  width: 850px;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-`;
-
-Sidebar.defaultProps = {
-  initial: 'initial',
-  animate: 'animate',
-  exit: 'exit',
-  variants: {animate: {}},
-  transition: testableTransition({
-    staggerChildren: 0.2,
-  }),
-};
-
-const AdaptivePageCorners = styled(PageCorners)`
-  --corner-scale: 1;
-  @media (max-width: ${p => p.theme.breakpoints.small}) {
-    --corner-scale: 0.5;
-  }
-`;
-
-const StyledStepper = styled(Stepper)`
-  justify-self: center;
-  @media (max-width: ${p => p.theme.breakpoints.medium}) {
-    display: none;
-  }
-`;
-
-interface BackButtonProps extends Omit<ButtonProps, 'icon' | 'priority'> {
-  animate: MotionProps['animate'];
-  className?: string;
-}
-
-const Back = styled(({className, animate, ...props}: BackButtonProps) => (
-  <motion.div
-    className={className}
-    animate={animate}
-    transition={testableTransition()}
-    variants={{
-      initial: {opacity: 0, visibility: 'hidden'},
-      visible: {
-        opacity: 1,
-        visibility: 'visible',
-        transition: testableTransition({delay: 1}),
-      },
-      hidden: {
-        opacity: 0,
-        transitionEnd: {
-          visibility: 'hidden',
-        },
-      },
-    }}
-  >
-    <Button {...props} icon={<IconArrow direction="left" size="sm" />} priority="link">
-      {t('Back')}
-    </Button>
-  </motion.div>
-))`
-  position: absolute;
-  top: 40px;
-  left: 20px;
-
-  button {
-    font-size: ${p => p.theme.fontSizeSmall};
-  }
-`;
-
-const SkipOnboardingLink = styled(Link)`
-  margin: auto ${space(4)};
-`;
-
-const UpsellWrapper = styled('div')`
-  grid-column: 3;
-  margin-left: auto;
-`;
-
-const OnboardingWrapper = styled('main')`
-  flex-grow: 1;
-  display: flex;
-  flex-direction: column;
-`;
-
-export default withOrganization(withProjects(Onboarding));

+ 1 - 1
static/app/views/onboarding/onboarding.spec.jsx

@@ -4,7 +4,7 @@ import {render, screen} from 'sentry-test/reactTestingLibrary';
 import OrganizationStore from 'sentry/stores/organizationStore';
 import {PersistedStoreProvider} from 'sentry/stores/persistedStore';
 import ProjectsStore from 'sentry/stores/projectsStore';
-import Onboarding from 'sentry/views/onboarding';
+import Onboarding from 'sentry/views/onboarding/onboarding';
 
 describe('Onboarding', function () {
   afterEach(function () {

+ 480 - 0
static/app/views/onboarding/onboarding.tsx

@@ -0,0 +1,480 @@
+import {useCallback, useContext, useEffect, useRef, useState} from 'react';
+import {RouteComponentProps} from 'react-router';
+import styled from '@emotion/styled';
+import * as Sentry from '@sentry/react';
+import {AnimatePresence, motion, MotionProps, useAnimation} from 'framer-motion';
+
+import {removeProject} from 'sentry/actionCreators/projects';
+import {Button, ButtonProps} from 'sentry/components/button';
+import Hook from 'sentry/components/hook';
+import Link from 'sentry/components/links/link';
+import LogoSentry from 'sentry/components/logoSentry';
+import {OnboardingContext} from 'sentry/components/onboarding/onboardingContext';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {PlatformKey} from 'sentry/data/platformCategories';
+import {IconArrow} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {OnboardingStatus} from 'sentry/types';
+import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
+import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse';
+import Redirect from 'sentry/utils/redirect';
+import testableTransition from 'sentry/utils/testableTransition';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import PageCorners from 'sentry/views/onboarding/components/pageCorners';
+
+import Stepper from './components/stepper';
+import OnboardingPlatform from './deprecatedPlatform';
+import {PlatformSelection} from './platformSelection';
+import SetupDocs from './setupDocs';
+import {StepDescriptor} from './types';
+import {usePersistedOnboardingState} from './utils';
+import TargetedOnboardingWelcome from './welcome';
+
+type RouteParams = {
+  step: string;
+};
+
+type Props = RouteComponentProps<RouteParams, {}>;
+
+function getOrganizationOnboardingSteps(singleSelectPlatform: boolean): StepDescriptor[] {
+  return [
+    {
+      id: 'welcome',
+      title: t('Welcome'),
+      Component: TargetedOnboardingWelcome,
+      cornerVariant: 'top-right',
+    },
+    {
+      ...(singleSelectPlatform
+        ? {
+            id: 'select-platform',
+            title: t('Select platform'),
+            Component: PlatformSelection,
+            hasFooter: true,
+            cornerVariant: 'top-left',
+          }
+        : {
+            id: 'select-platform',
+            title: t('Select platforms'),
+            Component: OnboardingPlatform,
+            hasFooter: true,
+            cornerVariant: 'top-left',
+          }),
+    },
+    {
+      id: 'setup-docs',
+      title: t('Install the Sentry SDK'),
+      Component: SetupDocs,
+      hasFooter: true,
+      cornerVariant: 'top-left',
+    },
+  ];
+}
+
+function Onboarding(props: Props) {
+  const api = useApi();
+  const organization = useOrganization();
+  const [clientState, setClientState] = usePersistedOnboardingState();
+  const onboardingContext = useContext(OnboardingContext);
+  const selectedPlatforms = clientState?.selectedPlatforms || [];
+  const selectedProjectSlug = selectedPlatforms[0];
+
+  const {
+    params: {step: stepId},
+  } = props;
+
+  const cornerVariantTimeoutRed = useRef<number | undefined>(undefined);
+
+  useEffect(() => {
+    return () => {
+      window.clearTimeout(cornerVariantTimeoutRed.current);
+    };
+  }, []);
+
+  const heartbeatFooter = !!organization?.features.includes(
+    'onboarding-heartbeat-footer'
+  );
+
+  const singleSelectPlatform = !!organization?.features.includes(
+    'onboarding-remove-multiselect-platform'
+  );
+
+  const projectDeletionOnBackClick = !!organization?.features.includes(
+    'onboarding-project-deletion-on-back-click'
+  );
+
+  const onboardingSteps = getOrganizationOnboardingSteps(singleSelectPlatform);
+  const stepObj = onboardingSteps.find(({id}) => stepId === id);
+  const stepIndex = onboardingSteps.findIndex(({id}) => stepId === id);
+
+  const cornerVariantControl = useAnimation();
+  const updateCornerVariant = () => {
+    // TODO: find better way to delay the corner animation
+    window.clearTimeout(cornerVariantTimeoutRed.current);
+
+    cornerVariantTimeoutRed.current = window.setTimeout(
+      () => cornerVariantControl.start(stepIndex === 0 ? 'top-right' : 'top-left'),
+      1000
+    );
+  };
+
+  useEffect(updateCornerVariant, [stepIndex, cornerVariantControl]);
+
+  // Called onExitComplete
+  const [containerHasFooter, setContainerHasFooter] = useState<boolean>(false);
+  const updateAnimationState = () => {
+    if (!stepObj) {
+      return;
+    }
+
+    setContainerHasFooter(stepObj.hasFooter ?? false);
+  };
+
+  const goToStep = (step: StepDescriptor) => {
+    if (!stepObj) {
+      return;
+    }
+    if (step.cornerVariant !== stepObj.cornerVariant) {
+      cornerVariantControl.start('none');
+    }
+    props.router.push(normalizeUrl(`/onboarding/${organization.slug}/${step.id}/`));
+  };
+
+  const goNextStep = (step: StepDescriptor) => {
+    const currentStepIndex = onboardingSteps.findIndex(s => s.id === step.id);
+    const nextStep = onboardingSteps[currentStepIndex + 1];
+    if (step.cornerVariant !== nextStep.cornerVariant) {
+      cornerVariantControl.start('none');
+    }
+    props.router.push(normalizeUrl(`/onboarding/${organization.slug}/${nextStep.id}/`));
+  };
+
+  const deleteProject = useCallback(
+    async (projectSlug: string) => {
+      try {
+        await removeProject(api, organization.slug, projectSlug);
+      } catch (error) {
+        handleXhrErrorResponse(t('Unable to delete project'))(error);
+        // we don't give the user any feedback regarding this error as this shall be silent
+      }
+    },
+    [api, organization.slug]
+  );
+
+  const handleGoBack = useCallback(() => {
+    if (!stepObj) {
+      return;
+    }
+
+    const previousStep = onboardingSteps[stepIndex - 1];
+
+    if (!previousStep) {
+      return;
+    }
+
+    if (stepObj.cornerVariant !== previousStep.cornerVariant) {
+      cornerVariantControl.start('none');
+    }
+
+    trackAdvancedAnalyticsEvent('onboarding.back_button_clicked', {
+      organization,
+      from: onboardingSteps[stepIndex].id,
+      to: previousStep.id,
+    });
+
+    // from selected platform to welcome
+    if (onboardingSteps[stepIndex].id === 'welcome') {
+      setClientState({
+        platformToProjectIdMap: clientState?.platformToProjectIdMap ?? {},
+        selectedPlatforms: [],
+        url: 'welcome/',
+        state: undefined,
+      });
+    }
+
+    // from setup docs to selected platform
+    if (onboardingSteps[stepIndex].id === 'setup-docs' && projectDeletionOnBackClick) {
+      // The user is going back to select a new platform,
+      // so we silently delete the last created project
+      // if the user didn't send an first error yet.
+
+      const projectShallBeRemoved = !Object.keys(onboardingContext.data).some(
+        key =>
+          onboardingContext.data[key].slug === selectedProjectSlug &&
+          (onboardingContext.data[key].status === OnboardingStatus.PROCESSING ||
+            onboardingContext.data[key].status === OnboardingStatus.PROCESSED)
+      );
+
+      let platformToProjectIdMap = clientState?.platformToProjectIdMap ?? {};
+
+      if (projectShallBeRemoved) {
+        deleteProject(selectedProjectSlug);
+
+        platformToProjectIdMap = Object.keys(
+          clientState?.platformToProjectIdMap ?? {}
+        ).reduce((acc, platform) => {
+          if (!acc[platform] && platform !== selectedProjectSlug) {
+            acc[platform] = platform;
+          }
+          return acc;
+        }, {});
+      }
+
+      setClientState({
+        url: 'select-platform/',
+        state: 'projects_selected',
+        selectedPlatforms: [selectedProjectSlug as PlatformKey],
+        platformToProjectIdMap,
+      });
+    }
+
+    props.router.replace(
+      normalizeUrl(`/onboarding/${organization.slug}/${previousStep.id}/`)
+    );
+  }, [
+    stepObj,
+    stepIndex,
+    onboardingSteps,
+    organization,
+    cornerVariantControl,
+    clientState,
+    setClientState,
+    selectedProjectSlug,
+    props.router,
+    deleteProject,
+    projectDeletionOnBackClick,
+    onboardingContext,
+  ]);
+
+  const genSkipOnboardingLink = () => {
+    const source = `targeted-onboarding-${stepId}`;
+    return (
+      <SkipOnboardingLink
+        onClick={() => {
+          trackAdvancedAnalyticsEvent('growth.onboarding_clicked_skip', {
+            organization,
+            source,
+          });
+          if (clientState) {
+            setClientState({
+              ...clientState,
+              state: 'skipped',
+            });
+          }
+        }}
+        to={normalizeUrl(
+          `/organizations/${organization.slug}/issues/?referrer=onboarding-skip`
+        )}
+      >
+        {t('Skip Onboarding')}
+      </SkipOnboardingLink>
+    );
+  };
+
+  const jumpToSetupProject = useCallback(() => {
+    const nextStep = onboardingSteps.find(({id}) => id === 'setup-docs');
+    if (!nextStep) {
+      Sentry.captureMessage(
+        'Missing step in onboarding: `setup-docs` when trying to jump there'
+      );
+      return;
+    }
+    props.router.push(normalizeUrl(`/onboarding/${organization.slug}/${nextStep.id}/`));
+  }, [onboardingSteps, organization, props.router]);
+
+  if (!stepObj || stepIndex === -1) {
+    return (
+      <Redirect
+        to={normalizeUrl(`/onboarding/${organization.slug}/${onboardingSteps[0].id}/`)}
+      />
+    );
+  }
+
+  return (
+    <OnboardingWrapper data-test-id="targeted-onboarding">
+      <SentryDocumentTitle title={stepObj.title} />
+      <Header>
+        <LogoSvg />
+        {stepIndex !== -1 && (
+          <StyledStepper
+            numSteps={onboardingSteps.length}
+            currentStepIndex={stepIndex}
+            onClick={i => goToStep(onboardingSteps[i])}
+          />
+        )}
+        <UpsellWrapper>
+          <Hook
+            name="onboarding:targeted-onboarding-header"
+            source="targeted-onboarding"
+          />
+        </UpsellWrapper>
+      </Header>
+      <Container hasFooter={containerHasFooter} heartbeatFooter={heartbeatFooter}>
+        <Back animate={stepIndex > 0 ? 'visible' : 'hidden'} onClick={handleGoBack} />
+        <AnimatePresence exitBeforeEnter onExitComplete={updateAnimationState}>
+          <OnboardingStep key={stepObj.id} data-test-id={`onboarding-step-${stepObj.id}`}>
+            {stepObj.Component && (
+              <stepObj.Component
+                active
+                data-test-id={`onboarding-step-${stepObj.id}`}
+                stepIndex={stepIndex}
+                onComplete={() => stepObj && goNextStep(stepObj)}
+                orgId={organization.slug}
+                search={props.location.search}
+                route={props.route}
+                router={props.router}
+                location={props.location}
+                jumpToSetupProject={jumpToSetupProject}
+                {...{
+                  genSkipOnboardingLink,
+                }}
+              />
+            )}
+          </OnboardingStep>
+        </AnimatePresence>
+        <AdaptivePageCorners animateVariant={cornerVariantControl} />
+      </Container>
+    </OnboardingWrapper>
+  );
+}
+
+const Container = styled('div')<{hasFooter: boolean; heartbeatFooter: boolean}>`
+  flex-grow: 1;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  background: ${p => p.theme.background};
+  padding: ${p =>
+    p.heartbeatFooter ? `120px ${space(3)} 0 ${space(3)}` : `120px ${space(3)}`};
+  width: 100%;
+  margin: 0 auto;
+  padding-bottom: ${p => p.hasFooter && '72px'};
+  margin-bottom: ${p => p.hasFooter && '72px'};
+`;
+
+const Header = styled('header')`
+  background: ${p => p.theme.background};
+  padding-left: ${space(4)};
+  padding-right: ${space(4)};
+  position: sticky;
+  height: 80px;
+  align-items: center;
+  top: 0;
+  z-index: 100;
+  box-shadow: 0 5px 10px rgba(0, 0, 0, 0.05);
+  display: grid;
+  grid-template-columns: 1fr 1fr 1fr;
+  justify-items: stretch;
+`;
+
+const LogoSvg = styled(LogoSentry)`
+  width: 130px;
+  height: 30px;
+  color: ${p => p.theme.textColor};
+`;
+
+const OnboardingStep = styled(motion.div)`
+  flex-grow: 1;
+  display: flex;
+  flex-direction: column;
+`;
+
+OnboardingStep.defaultProps = {
+  initial: 'initial',
+  animate: 'animate',
+  exit: 'exit',
+  variants: {animate: {}},
+  transition: testableTransition({
+    staggerChildren: 0.2,
+  }),
+};
+
+const Sidebar = styled(motion.div)`
+  width: 850px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+`;
+
+Sidebar.defaultProps = {
+  initial: 'initial',
+  animate: 'animate',
+  exit: 'exit',
+  variants: {animate: {}},
+  transition: testableTransition({
+    staggerChildren: 0.2,
+  }),
+};
+
+const AdaptivePageCorners = styled(PageCorners)`
+  --corner-scale: 1;
+  @media (max-width: ${p => p.theme.breakpoints.small}) {
+    --corner-scale: 0.5;
+  }
+`;
+
+const StyledStepper = styled(Stepper)`
+  justify-self: center;
+  @media (max-width: ${p => p.theme.breakpoints.medium}) {
+    display: none;
+  }
+`;
+
+interface BackButtonProps extends Omit<ButtonProps, 'icon' | 'priority'> {
+  animate: MotionProps['animate'];
+  className?: string;
+}
+
+const Back = styled(({className, animate, ...props}: BackButtonProps) => (
+  <motion.div
+    className={className}
+    animate={animate}
+    transition={testableTransition()}
+    variants={{
+      initial: {opacity: 0, visibility: 'hidden'},
+      visible: {
+        opacity: 1,
+        visibility: 'visible',
+        transition: testableTransition({delay: 1}),
+      },
+      hidden: {
+        opacity: 0,
+        transitionEnd: {
+          visibility: 'hidden',
+        },
+      },
+    }}
+  >
+    <Button {...props} icon={<IconArrow direction="left" size="sm" />} priority="link">
+      {t('Back')}
+    </Button>
+  </motion.div>
+))`
+  position: absolute;
+  top: 40px;
+  left: 20px;
+
+  button {
+    font-size: ${p => p.theme.fontSizeSmall};
+  }
+`;
+
+const SkipOnboardingLink = styled(Link)`
+  margin: auto ${space(4)};
+`;
+
+const UpsellWrapper = styled('div')`
+  grid-column: 3;
+  margin-left: auto;
+`;
+
+const OnboardingWrapper = styled('main')`
+  flex-grow: 1;
+  display: flex;
+  flex-direction: column;
+`;
+
+export default Onboarding;

+ 24 - 4
static/app/views/onboarding/platformSelection.tsx

@@ -6,6 +6,7 @@ import PlatformPicker from 'sentry/components/platformPicker';
 import {PlatformKey} from 'sentry/data/platformCategories';
 import {t} from 'sentry/locale';
 import testableTransition from 'sentry/utils/testableTransition';
+import useOrganization from 'sentry/utils/useOrganization';
 import StepHeading from 'sentry/views/onboarding/components/stepHeading';
 
 import CreateProjectsFooter from './components/createProjectsFooter';
@@ -13,17 +14,34 @@ import {StepProps} from './types';
 import {usePersistedOnboardingState} from './utils';
 
 export function PlatformSelection(props: StepProps) {
+  const organization = useOrganization();
   const [selectedPlatform, setSelectedPlatform] = useState<PlatformKey | undefined>(
     undefined
   );
 
-  const [clientState] = usePersistedOnboardingState();
+  const [clientState, _setClientState] = usePersistedOnboardingState();
+
+  const disabledPlatforms = Object.keys(clientState?.platformToProjectIdMap ?? {}).reduce(
+    (acc, key) => {
+      if (!acc[key]) {
+        acc[key] = t('Project already created');
+      }
+      return acc;
+    },
+    {}
+  );
 
   useEffect(() => {
-    if (clientState) {
+    if (!clientState) {
+      return;
+    }
+
+    const selectedprojectCreated = disabledPlatforms[clientState.selectedPlatforms[0]];
+
+    if (selectedPlatform === undefined && !selectedprojectCreated) {
       setSelectedPlatform(clientState.selectedPlatforms[0]);
     }
-  }, [clientState]);
+  }, [clientState, disabledPlatforms, selectedPlatform]);
 
   return (
     <Wrapper>
@@ -51,11 +69,13 @@ export function PlatformSelection(props: StepProps) {
           setPlatform={platformKey => {
             setSelectedPlatform(platformKey ?? undefined);
           }}
-          organization={props.organization}
+          disabledPlatforms={disabledPlatforms}
+          organization={organization}
         />
       </motion.div>
       <CreateProjectsFooter
         {...props}
+        organization={organization}
         clearPlatforms={() => setSelectedPlatform(undefined)}
         platforms={selectedPlatform ? [selectedPlatform] : []}
       />

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