Browse Source

feat(onboarding): targeted onboarding stage 2 (#32806)

This PR adds the instrumentation step for the new targeted onboarding flow
Stephen Cefali 3 years ago
parent
commit
729362d15e

+ 6 - 0
static/app/data/experimentConfig.tsx

@@ -19,6 +19,12 @@ export const experimentList = [
     parameter: 'exposed',
     assignments: [0, 1],
   },
+  {
+    key: 'TargetedOnboardingMultiSelectExperiment',
+    type: ExperimentType.Organization,
+    parameter: 'exposed',
+    assignments: [0, 1],
+  },
 ] as const;
 
 export const experimentConfig = experimentList.reduce(

+ 5 - 2
static/app/views/onboarding/onboardingController.tsx

@@ -19,8 +19,11 @@ function OnboardingController({experimentAssignment, ...rest}: Props) {
       organization: rest.organization,
     });
   }, []);
-  if (rest.params.step === 'welcome' && experimentAssignment) {
-    return <TargetedOnboarding />;
+  if (
+    (rest.params.step === 'welcome' && experimentAssignment) ||
+    rest.organization?.experiments.TargetedOnboardingMultiSelectExperiment
+  ) {
+    return <TargetedOnboarding {...rest} />;
   }
   return <Onboarding {...rest} />;
 }

+ 201 - 0
static/app/views/onboarding/targetedOnboarding/components/firstEventFooter.tsx

@@ -0,0 +1,201 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+import {motion, Variants} from 'framer-motion';
+
+import Button from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import Link from 'sentry/components/links/link';
+import {IconCheckmark} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import pulsingIndicatorStyles from 'sentry/styles/pulsingIndicator';
+import space from 'sentry/styles/space';
+import {Group, Organization, Project} from 'sentry/types';
+import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
+import EventWaiter from 'sentry/utils/eventWaiter';
+import testableTransition from 'sentry/utils/testableTransition';
+import CreateSampleEventButton from 'sentry/views/onboarding/createSampleEventButton';
+
+interface FirstEventFooterProps {
+  handleFirstIssueReceived: () => void;
+  hasFirstEvent: boolean;
+  isLast: boolean;
+  onClickSetupLater: () => void;
+  organization: Organization;
+  project: Project;
+}
+
+export default function FirstEventFooter({
+  organization,
+  project,
+  onClickSetupLater,
+  isLast,
+  hasFirstEvent,
+  handleFirstIssueReceived,
+}: FirstEventFooterProps) {
+  const source = 'targeted_onboarding_first_event_footer';
+
+  const getSecondaryCta = ({firstIssue}: {firstIssue: null | true | Group}) => {
+    // if hasn't sent first event, allow creation of sample error
+    if (!hasFirstEvent) {
+      return (
+        <CreateSampleEventButton
+          project={project}
+          source="targted-onboarding"
+          priority="default"
+        >
+          {t('View Sample Error')}
+        </CreateSampleEventButton>
+      );
+    }
+    // if last, no secondary cta
+    if (isLast) {
+      return null;
+    }
+    return (
+      <Button
+        to={`/organizations/${organization.slug}/issues/${
+          firstIssue !== true && firstIssue !== null ? `${firstIssue.id}/` : ''
+        }`}
+      >
+        {t('Take me to my error')}
+      </Button>
+    );
+  };
+
+  const getPrimaryCta = ({firstIssue}: {firstIssue: null | true | Group}) => {
+    // if hasn't sent first event, allow skiping
+    if (!hasFirstEvent) {
+      return (
+        <Button priority="primary" onClick={onClickSetupLater}>
+          {t('Setup Later')}
+        </Button>
+      );
+    }
+    if (isLast) {
+      return (
+        <Button
+          to={`/organizations/${organization.slug}/issues/${
+            firstIssue !== true && firstIssue !== null ? `${firstIssue.id}/` : ''
+          }`}
+          priority="primary"
+        >
+          {t('Take me to my error')}
+        </Button>
+      );
+    }
+    return (
+      <Button priority="primary" onClick={onClickSetupLater}>
+        {t('Next Platform')}
+      </Button>
+    );
+  };
+
+  return (
+    <Wrapper>
+      <SkipOnboardingLink
+        onClick={() =>
+          trackAdvancedAnalyticsEvent('growth.onboarding_clicked_skip', {
+            organization,
+            source,
+          })
+        }
+        to={`/organizations/${organization.slug}/issues/`}
+      >
+        {t('Skip Onboarding')}
+      </SkipOnboardingLink>
+      <EventWaiter
+        eventType="error"
+        onIssueReceived={handleFirstIssueReceived}
+        {...{project, organization}}
+      >
+        {({firstIssue}) => (
+          <Fragment>
+            <StatusWrapper>
+              {hasFirstEvent ? (
+                <IconCheckmark isCircled color="green400" />
+              ) : (
+                <WaitingIndicator />
+              )}
+              <AnimatedText errorReceived={hasFirstEvent}>
+                {hasFirstEvent ? t('Error Received') : t('Waiting for error')}
+              </AnimatedText>
+            </StatusWrapper>
+            <OnboardingButtonBar gap={2}>
+              {getSecondaryCta({firstIssue})}
+              {getPrimaryCta({firstIssue})}
+            </OnboardingButtonBar>
+          </Fragment>
+        )}
+      </EventWaiter>
+    </Wrapper>
+  );
+}
+
+const Wrapper = styled('div')`
+  width: 100%;
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  height: 72px;
+  z-index: 100;
+  display: flex;
+  background-color: ${p => p.theme.background};
+  justify-content: space-between;
+  box-shadow: 0px -4px 24px rgba(43, 34, 51, 0.08);
+`;
+
+const OnboardingButtonBar = styled(ButtonBar)`
+  margin: ${space(2)} ${space(4)};
+`;
+
+const AnimatedText = styled(motion.div)<{errorReceived: boolean}>`
+  margin-left: ${space(1)};
+  color: ${p =>
+    p.errorReceived ? p.theme.successText : p.theme.charts.getColorPalette(5)[4]};
+`;
+
+const indicatorAnimation: Variants = {
+  initial: {opacity: 0, y: -10},
+  animate: {opacity: 1, y: 0},
+  exit: {opacity: 0, y: 10},
+};
+
+AnimatedText.defaultProps = {
+  variants: indicatorAnimation,
+  transition: testableTransition(),
+};
+
+const WaitingIndicator = styled(motion.div)`
+  ${pulsingIndicatorStyles};
+  background-color: ${p => p.theme.charts.getColorPalette(5)[4]};
+`;
+
+WaitingIndicator.defaultProps = {
+  variants: indicatorAnimation,
+  transition: testableTransition(),
+};
+
+const StatusWrapper = styled(motion.div)`
+  display: flex;
+  align-items: center;
+  font-size: ${p => p.theme.fontSizeMedium};
+`;
+
+StatusWrapper.defaultProps = {
+  initial: 'initial',
+  animate: 'animate',
+  exit: 'exit',
+  variants: {
+    initial: {opacity: 0, y: -10},
+    animate: {
+      opacity: 1,
+      y: 0,
+      transition: testableTransition({when: 'beforeChildren', staggerChildren: 0.35}),
+    },
+    exit: {opacity: 0, y: 10},
+  },
+};
+
+const SkipOnboardingLink = styled(Link)`
+  margin: auto ${space(4)};
+`;

+ 115 - 0
static/app/views/onboarding/targetedOnboarding/components/sidebar.tsx

@@ -0,0 +1,115 @@
+import styled from '@emotion/styled';
+import {motion, Variants} from 'framer-motion';
+import {PlatformIcon} from 'platformicons';
+
+import platforms from 'sentry/data/platforms';
+import {IconCheckmark} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import pulsingIndicatorStyles from 'sentry/styles/pulsingIndicator';
+import space from 'sentry/styles/space';
+import {Project} from 'sentry/types';
+import testableTransition from 'sentry/utils/testableTransition';
+import withProjects from 'sentry/utils/withProjects';
+
+type Props = {
+  checkProjectHasFirstEvent: (project: Project) => boolean;
+  projects: Project[];
+  setNewProject: (newProjectId: string) => void;
+  activeProject?: Project;
+};
+function Sidebar({
+  projects,
+  activeProject,
+  setNewProject,
+  checkProjectHasFirstEvent,
+}: Props) {
+  const oneProject = (project: Project) => {
+    const name = platforms.find(p => p.id === project.platform)?.name ?? '';
+    const isActive = activeProject?.id === project.id;
+    const errorReceived = checkProjectHasFirstEvent(project);
+    return (
+      <ProjectWrapper
+        key={project.id}
+        isActive={isActive}
+        onClick={() => setNewProject(project.id)}
+      >
+        <IconWrapper>
+          <PlatformIcon platform={project.platform || 'other'} size={36} />
+        </IconWrapper>
+        <MiddleWrapper>
+          {name}
+          <SubHeader errorReceived={errorReceived}>
+            {errorReceived ? t('Error Received') : t('Waiting for error')}
+          </SubHeader>
+        </MiddleWrapper>
+        <IconWrapper>
+          {errorReceived ? (
+            <IconCheckmark isCircled color="green400" />
+          ) : (
+            isActive && <WaitingIndicator />
+          )}
+        </IconWrapper>
+      </ProjectWrapper>
+    );
+  };
+  return (
+    <Wrapper>
+      <Title>{t('Projects to Setup')}</Title>
+      {projects.map(oneProject)}
+    </Wrapper>
+  );
+}
+
+export default withProjects(Sidebar);
+
+const Title = styled('span')`
+  font-size: 12px;
+  font-weight: 600;
+  text-transform: uppercase;
+  margin-left: ${space(2)};
+`;
+
+const ProjectWrapper = styled('div')<{isActive: boolean}>`
+  display: grid;
+  grid-template-columns: 1fr fit-content(100%) 1fr;
+  background-color: ${p => p.isActive && p.theme.gray100};
+  padding: ${space(2)};
+  cursor: pointer;
+  border-radius: 4px;
+`;
+
+const SubHeader = styled('div')<{errorReceived: boolean}>`
+  color: ${p =>
+    p.errorReceived ? p.theme.successText : p.theme.charts.getColorPalette(5)[4]};
+`;
+
+const indicatorAnimation: Variants = {
+  initial: {opacity: 0, y: -10},
+  animate: {opacity: 1, y: 0},
+  exit: {opacity: 0, y: 10},
+};
+
+const WaitingIndicator = styled(motion.div)`
+  margin: 0 6px;
+  ${pulsingIndicatorStyles};
+  background-color: ${p => p.theme.charts.getColorPalette(5)[4]};
+`;
+
+WaitingIndicator.defaultProps = {
+  variants: indicatorAnimation,
+  transition: testableTransition(),
+};
+
+const IconWrapper = styled('div')`
+  margin: auto;
+`;
+
+const MiddleWrapper = styled('div')`
+  margin: 0 ${space(1)};
+`;
+
+// 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
+const Wrapper = styled('div')`
+  margin: ${space(1)} calc(${space(2)} + 30px + ${space(4)}) 0 ${space(2)};
+`;

+ 165 - 10
static/app/views/onboarding/targetedOnboarding/onboarding.tsx

@@ -1,27 +1,85 @@
 import * as React from 'react';
+import {browserHistory, RouteComponentProps} from 'react-router';
 import styled from '@emotion/styled';
-import {AnimatePresence, useAnimation} from 'framer-motion';
+import {AnimatePresence, motion, MotionProps, useAnimation} from 'framer-motion';
 
+import Button, {ButtonProps} from 'sentry/components/button';
 import Hook from 'sentry/components/hook';
 import LogoSentry from 'sentry/components/logoSentry';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {IconArrow} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
+import {Organization, Project} from 'sentry/types';
+import testableTransition from 'sentry/utils/testableTransition';
+import withOrganization from 'sentry/utils/withOrganization';
+import withProjects from 'sentry/utils/withProjects';
 import PageCorners from 'sentry/views/onboarding/components/pageCorners';
 
+import PlatformSelection from './platform';
+import SetupDocs from './setupDocs';
+import {StepDescriptor} from './types';
 import TargetedOnboardingWelcome from './welcome';
 
-export default function Onboarding() {
+type RouteParams = {
+  orgId: string;
+  step: string;
+};
+
+type Props = RouteComponentProps<RouteParams, {}> & {
+  organization: Organization;
+  projects: Project[];
+};
+
+const ONBOARDING_STEPS: StepDescriptor[] = [
+  {
+    id: 'welcome',
+    title: t('Welcome'),
+    Component: TargetedOnboardingWelcome,
+    centered: true,
+  },
+  {
+    id: 'select-platform',
+    title: t('Select a platform'),
+    Component: PlatformSelection,
+  },
+  {
+    id: 'setup-docs',
+    title: t('Install the Sentry SDK'),
+    Component: SetupDocs,
+    hasFooter: true,
+  },
+];
+
+function Onboarding(props: Props) {
+  const stepId = props.params.step;
+  const stepObj = ONBOARDING_STEPS.find(({id}) => stepId === id);
+  if (!stepObj) {
+    return <div>Can't find</div>;
+  }
+
   const cornerVariantControl = useAnimation();
   const updateCornerVariant = () => {
-    cornerVariantControl.start('top-right');
+    // TODO: find better way to delay thhe corner animation
+    setTimeout(() => cornerVariantControl.start('top-right'), 1000);
   };
 
-  // XXX(epurkhiser): We're using a react hook here becuase there's no other
-  // way to create framer-motion controls than by using the `useAnimation`
-  // hook.
-
   React.useEffect(updateCornerVariant, []);
+
+  const goNextStep = (step: StepDescriptor) => {
+    const stepIndex = ONBOARDING_STEPS.findIndex(s => s.id === step.id);
+    const nextStep = ONBOARDING_STEPS[stepIndex + 1];
+
+    browserHistory.push(`/onboarding/${props.params.orgId}/${nextStep.id}/`);
+  };
+
+  const activeStepIndex = ONBOARDING_STEPS.findIndex(({id}) => props.params.step === id);
+
+  const handleGoBack = () => {
+    const previousStep = ONBOARDING_STEPS[activeStepIndex - 1];
+    browserHistory.replace(`/onboarding/${props.params.orgId}/${previousStep.id}/`);
+  };
+
   return (
     <OnboardingWrapper data-test-id="targeted-onboarding">
       <SentryDocumentTitle title={t('Welcome')} />
@@ -29,9 +87,27 @@ export default function Onboarding() {
         <LogoSvg />
         <Hook name="onboarding:targeted-onboarding-header" />
       </Header>
-      <Container>
+      <Container hasFooter={!!stepObj.hasFooter}>
+        <Back
+          animate={activeStepIndex > 0 ? 'visible' : 'hidden'}
+          onClick={handleGoBack}
+        />
         <AnimatePresence exitBeforeEnter onExitComplete={updateCornerVariant}>
-          <TargetedOnboardingWelcome />
+          <OnboardingStep
+            centered={stepObj.centered}
+            key={stepObj.id}
+            data-test-id={`onboarding-step-${stepObj.id}`}
+          >
+            {stepObj.Component && (
+              <stepObj.Component
+                active
+                onComplete={() => goNextStep(stepObj)}
+                orgId={props.params.orgId}
+                organization={props.organization}
+                search={props.location.search}
+              />
+            )}
+          </OnboardingStep>
         </AnimatePresence>
         <AdaptivePageCorners animateVariant={cornerVariantControl} />
       </Container>
@@ -46,7 +122,7 @@ const OnboardingWrapper = styled('main')`
   flex-grow: 1;
 `;
 
-const Container = styled('div')`
+const Container = styled('div')<{hasFooter: boolean}>`
   display: flex;
   justify-content: center;
   position: relative;
@@ -55,6 +131,8 @@ const Container = styled('div')`
   width: 100%;
   margin: 0 auto;
   flex-grow: 1;
+  padding-bottom: ${p => p.hasFooter && '72px'};
+  margin-bottom: ${p => p.hasFooter && '72px'};
 `;
 
 const Header = styled('header')`
@@ -74,9 +152,86 @@ const LogoSvg = styled(LogoSentry)`
   color: ${p => p.theme.textColor};
 `;
 
+const OnboardingStep = styled(motion.div)<{centered?: boolean}>`
+  display: flex;
+  flex-direction: column;
+  ${p =>
+    p.centered &&
+    `justify-content: center;
+     align-items: center;`};
+`;
+
+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[0]}) {
     --corner-scale: 0.5;
   }
 `;
+
+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};
+  }
+`;
+
+export default withOrganization(withProjects(Onboarding));

+ 5 - 0
static/app/views/onboarding/targetedOnboarding/platform.tsx

@@ -0,0 +1,5 @@
+import {StepProps} from './types';
+
+export default function Platform(_props: StepProps) {
+  return <div>Platform Selection</div>;
+}

+ 267 - 0
static/app/views/onboarding/targetedOnboarding/setupDocs.tsx

@@ -0,0 +1,267 @@
+import {useEffect, useState} from 'react';
+import {browserHistory} from 'react-router';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+import {motion} from 'framer-motion';
+import * as qs from 'query-string';
+
+import {loadDocs} from 'sentry/actionCreators/projects';
+import Alert, {alertStyles} from 'sentry/components/alert';
+import ExternalLink from 'sentry/components/links/externalLink';
+import LoadingError from 'sentry/components/loadingError';
+import {PlatformKey} from 'sentry/data/platformCategories';
+import platforms from 'sentry/data/platforms';
+import {IconInfo} from 'sentry/icons';
+import {t, tct} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {Project} from 'sentry/types';
+import getDynamicText from 'sentry/utils/getDynamicText';
+import {Theme} from 'sentry/utils/theme';
+import useApi from 'sentry/utils/useApi';
+import withProjects from 'sentry/utils/withProjects';
+import FullIntroduction from 'sentry/views/onboarding/components/fullIntroduction';
+
+import FirstEventFooter from './components/firstEventFooter';
+import TargetedOnboardingSidebar from './components/sidebar';
+import {StepProps} from './types';
+
+/**
+ * The documentation will include the following string should it be missing the
+ * verification example, which currently a lot of docs are.
+ */
+const INCOMPLETE_DOC_FLAG = 'TODO-ADD-VERIFICATION-EXAMPLE';
+
+type PlatformDoc = {html: string; link: string};
+
+type Props = {
+  projects: Project[];
+  search: string;
+} & StepProps;
+
+function SetupDocs({organization, projects, search}: Props) {
+  const api = useApi();
+  const [hasError, setHasError] = useState(false);
+  const [platformDocs, setPlatformDocs] = useState<PlatformDoc | null>(null);
+  const [loadedPlatform, setLoadedPlatform] = useState<PlatformKey | null>(null);
+  // store what projects have sent first event in state based project.firstEvent
+  const [hasFirstEventMap, setHasFirstEventMap] = useState<Record<string, boolean>>(
+    projects.reduce((accum, project: Project) => {
+      accum[project.id] = !!project.firstEvent;
+      return accum;
+    }, {} as Record<string, boolean>)
+  );
+  const checkProjectHasFirstEvent = (project: Project) => {
+    return !!hasFirstEventMap[project.id];
+  };
+
+  // TODO: Check no projects
+  const {sub_step: rawSubStep, project_id: rawProjectId} = qs.parse(search);
+  const subStep = rawSubStep === 'integration' ? 'integration' : 'project';
+  const rawProjectIndex = projects.findIndex(p => p.id === rawProjectId);
+  const firstProjectNoError = projects.findIndex(p => !checkProjectHasFirstEvent(p));
+  const projectIndex = rawProjectIndex >= 0 ? rawProjectIndex : firstProjectNoError;
+  const project = projects[projectIndex];
+  const {platform} = project || {};
+  const currentPlatform = loadedPlatform ?? platform ?? 'other';
+
+  const fetchData = async () => {
+    // const {platform} = project || {};
+    // TODO: add better error handling logic
+    if (!project?.platform) {
+      return;
+    }
+    try {
+      const loadedDocs = await loadDocs(
+        api,
+        organization.slug,
+        project.slug,
+        project.platform
+      );
+      setPlatformDocs(loadedDocs);
+      setLoadedPlatform(project.platform);
+      setHasError(false);
+    } catch (error) {
+      setHasError(error);
+      throw error;
+    }
+  };
+
+  useEffect(() => {
+    fetchData();
+  }, [project]);
+
+  // TODO: add better error handling logic
+  if (!project && subStep === 'project') {
+    return null;
+  }
+
+  const setNewProject = (newProjectId: string) => {
+    const searchParams = new URLSearchParams({
+      project_id: newProjectId,
+    });
+    browserHistory.push(`${window.location.pathname}?${searchParams}`);
+  };
+
+  const missingExampleWarning = () => {
+    const missingExample =
+      platformDocs && platformDocs.html.includes(INCOMPLETE_DOC_FLAG);
+
+    if (!missingExample) {
+      return null;
+    }
+
+    return (
+      <Alert type="warning" icon={<IconInfo size="md" />}>
+        {tct(
+          `Looks like this getting started example is still undergoing some
+           work and doesn't include an example for triggering an event quite
+           yet. If you have trouble sending your first event be sure to consult
+           the [docsLink:full documentation] for [platform].`,
+          {
+            docsLink: <ExternalLink href={platformDocs?.link} />,
+            platform: platforms.find(p => p.id === loadedPlatform)?.name,
+          }
+        )}
+      </Alert>
+    );
+  };
+
+  const docs = platformDocs !== null && (
+    <DocsWrapper key={platformDocs.html}>
+      <Content dangerouslySetInnerHTML={{__html: platformDocs.html}} />
+      {missingExampleWarning()}
+      {project && (
+        <FirstEventFooter
+          project={project}
+          organization={organization}
+          isLast={projectIndex === projects.length - 1}
+          hasFirstEvent={checkProjectHasFirstEvent(project)}
+          onClickSetupLater={() => {
+            // TODO: analytics
+            const nextProject = projects.find(
+              (p, index) => !p.firstEvent && index > projectIndex
+            );
+            if (!nextProject) {
+              // TODO: integrations
+              browserHistory.push('/');
+              return;
+            }
+            setNewProject(nextProject.id);
+          }}
+          handleFirstIssueReceived={() => {
+            const newHasFirstEventMap = {...hasFirstEventMap, [project.id]: true};
+            setHasFirstEventMap(newHasFirstEventMap);
+          }}
+        />
+      )}
+    </DocsWrapper>
+  );
+
+  const loadingError = (
+    <LoadingError
+      message={t('Failed to load documentation for the %s platform.', platform)}
+      onRetry={fetchData}
+    />
+  );
+
+  const testOnlyAlert = (
+    <Alert type="warning">
+      Platform documentation is not rendered in for tests in CI
+    </Alert>
+  );
+
+  return (
+    <Wrapper>
+      <TargetedOnboardingSidebar
+        activeProject={project}
+        {...{checkProjectHasFirstEvent, setNewProject}}
+      />
+      <MainContent>
+        <FullIntroduction currentPlatform={currentPlatform} />
+        {getDynamicText({
+          value: !hasError ? docs : loadingError,
+          fixed: testOnlyAlert,
+        })}
+      </MainContent>
+    </Wrapper>
+  );
+}
+
+export default withProjects(SetupDocs);
+
+type AlertType = React.ComponentProps<typeof Alert>['type'];
+
+const getAlertSelector = (type: AlertType) =>
+  type === 'muted' ? null : `.alert[level="${type}"], .alert-${type}`;
+
+const mapAlertStyles = (p: {theme: Theme}, type: AlertType) =>
+  css`
+    ${getAlertSelector(type)} {
+      ${alertStyles({theme: p.theme, type})};
+      display: block;
+    }
+  `;
+
+const Content = styled(motion.div)`
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6,
+  p {
+    margin-bottom: 18px;
+  }
+
+  div[data-language] {
+    margin-bottom: ${space(2)};
+  }
+
+  code {
+    font-size: 87.5%;
+    color: ${p => p.theme.pink300};
+  }
+
+  pre code {
+    color: inherit;
+    font-size: inherit;
+    white-space: pre;
+  }
+
+  h2 {
+    font-size: 1.4em;
+  }
+
+  .alert h5 {
+    font-size: 1em;
+    margin-bottom: 0.625rem;
+  }
+
+  /**
+   * XXX(epurkhiser): This comes from the doc styles and avoids bottom margin issues in alerts
+   */
+  .content-flush-bottom *:last-child {
+    margin-bottom: 0;
+  }
+
+  ${p => Object.keys(p.theme.alert).map(type => mapAlertStyles(p, type as AlertType))}
+`;
+
+const DocsWrapper = styled(motion.div)``;
+
+DocsWrapper.defaultProps = {
+  initial: {opacity: 0, y: 40},
+  animate: {opacity: 1, y: 0},
+  exit: {opacity: 0},
+};
+
+const Wrapper = styled('div')`
+  display: grid;
+  grid-template-columns: fit-content(100%) fit-content(100%);
+  width: max-content;
+  margin: ${space(2)};
+`;
+
+const MainContent = styled('div')`
+  width: 850px;
+`;

+ 22 - 0
static/app/views/onboarding/targetedOnboarding/types.ts

@@ -0,0 +1,22 @@
+import {PlatformKey} from 'sentry/data/platformCategories';
+import {Organization} from 'sentry/types';
+
+export type StepData = {
+  platform?: PlatformKey | null;
+};
+
+export type StepProps = {
+  active: boolean;
+  onComplete: () => void;
+  orgId: string;
+  organization: Organization;
+  search: string;
+};
+
+export type StepDescriptor = {
+  Component: React.ComponentType<StepProps>;
+  id: string;
+  title: string;
+  centered?: boolean;
+  hasFooter?: boolean;
+};

+ 4 - 10
static/app/views/onboarding/targetedOnboarding/welcome.tsx

@@ -1,5 +1,4 @@
 import * as React from 'react';
-import {browserHistory} from 'react-router';
 import styled from '@emotion/styled';
 import {motion, MotionProps} from 'framer-motion';
 
@@ -13,13 +12,12 @@ import DemoSandboxButton from 'sentry/components/demoSandboxButton';
 import Link from 'sentry/components/links/link';
 import {t, tct} from 'sentry/locale';
 import space from 'sentry/styles/space';
-import {Organization} from 'sentry/types';
 import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
 import testableTransition from 'sentry/utils/testableTransition';
-import withOrganization from 'sentry/utils/withOrganization';
 import FallingError from 'sentry/views/onboarding/components/fallingError';
 
 import WelcomeBackground from './components/welcomeBackground';
+import {StepProps} from './types';
 
 const fadeAway: MotionProps = {
   variants: {
@@ -50,11 +48,7 @@ function InnerAction({title, subText, cta, src}: TextWrapperProps) {
   );
 }
 
-type Props = {
-  organization: Organization;
-};
-
-function TargetedOnboardingWelcome({organization}: Props) {
+function TargetedOnboardingWelcome({organization, ...props}: StepProps) {
   const source = 'targeted_onboarding';
   React.useEffect(() => {
     trackAdvancedAnalyticsEvent('growth.onboarding_start_onboarding', {
@@ -69,7 +63,7 @@ function TargetedOnboardingWelcome({organization}: Props) {
       source,
     });
 
-    browserHistory.push(`/onboarding/${organization.slug}/select-platform/`);
+    props.onComplete();
   };
   return (
     <FallingError>
@@ -182,7 +176,7 @@ function TargetedOnboardingWelcome({organization}: Props) {
   );
 }
 
-export default withOrganization(TargetedOnboardingWelcome);
+export default TargetedOnboardingWelcome;
 
 const PositionedFallingError = styled('span')`
   display: block;

+ 26 - 1
tests/js/spec/views/onboarding/onboardingController.spec.jsx

@@ -4,7 +4,7 @@ import {render, screen} from 'sentry-test/reactTestingLibrary';
 import OnboardingController from 'sentry/views/onboarding/onboardingController';
 import {OrganizationContext} from 'sentry/views/organizationContext';
 
-describe('Onboarding', function () {
+describe('OnboardingController', function () {
   it('Shows targeted onboarding with experiment active', function () {
     const {organization, router, routerContext} = initializeOrg({
       organization: {
@@ -78,4 +78,29 @@ describe('Onboarding', function () {
     );
     expect(screen.queryByTestId('targeted-onboarding')).not.toBeInTheDocument();
   });
+  it('Shows targeted onboarding with multi-select experiment active', function () {
+    const {organization, router, routerContext} = initializeOrg({
+      organization: {
+        experiments: {
+          TargetedOnboardingMultiSelectExperiment: 1,
+        },
+      },
+      router: {
+        params: {
+          step: 'setup-docs',
+        },
+      },
+    });
+
+    const {container} = render(
+      <OrganizationContext.Provider value={organization}>
+        <OnboardingController {...router} />
+      </OrganizationContext.Provider>,
+      {
+        context: routerContext,
+      }
+    );
+    expect(screen.getByTestId('targeted-onboarding')).toBeInTheDocument();
+    expect(container).toSnapshot();
+  });
 });

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