Browse Source

feat(targeted-onboarding): onboarding integration setup (#34252)

This PR adds the new step for installing integrations for the targeted onboarding flow. It also changes the first event indicator in onboarding to match what we use elsewhere.
Stephen Cefali 2 years ago
parent
commit
96925a7fac

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

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

+ 9 - 0
static/app/utils/analytics/growthAnalyticsEvents.tsx

@@ -52,7 +52,12 @@ export type GrowthEventParameters = {
   'growth.demo_modal_clicked_signup': {};
   'growth.issue_open_in_discover_btn_clicked': {};
   'growth.onboarding_clicked_instrument_app': {source?: string};
+  'growth.onboarding_clicked_integration_in_sidebar': {integration: string};
   'growth.onboarding_clicked_project_in_sidebar': {platform: string};
+  'growth.onboarding_clicked_setup_integration_later': {
+    integration: string;
+    integration_index: number;
+  };
   'growth.onboarding_clicked_setup_platform_later': PlatformParam & {
     project_index: number;
   };
@@ -125,6 +130,8 @@ export const growthEventMap: Record<GrowthAnalyticsKey, string> = {
   'growth.onboarding_clicked_instrument_app': 'Growth: Onboarding Clicked Instrument App',
   'growth.onboarding_clicked_setup_platform_later':
     'Growth: Onboarding Clicked Setup Platform Later',
+  'growth.onboarding_clicked_setup_integration_later':
+    'Growth: Onboarding Clicked Setup Integration Later',
   'growth.onboarding_quick_start_cta': 'Growth: Quick Start Onboarding CTA',
   'invite_request.approved': 'Invite Request Approved',
   'invite_request.denied': 'Invite Request Denied',
@@ -132,6 +139,8 @@ export const growthEventMap: Record<GrowthAnalyticsKey, string> = {
   'growth.demo_modal_clicked_continue': 'Growth: Demo Modal Clicked Continue',
   'growth.clicked_enter_sandbox': 'Growth: Clicked Enter Sandbox',
   'growth.onboarding_clicked_project_in_sidebar': 'Growth: Clicked Project Sidebar',
+  'growth.onboarding_clicked_integration_in_sidebar':
+    'Growth: Clicked Integration Sidebar',
   'growth.sample_transaction_docs_link_clicked':
     'Growth: Sample Transaction Docs Link Clicked',
   'growth.sample_error_onboarding_link_clicked':

+ 2 - 1
static/app/views/onboarding/targetedOnboarding/components/createProjectsFooter.tsx

@@ -72,6 +72,7 @@ export default function CreateProjectsFooter({
         state: 'projects_selected',
         url: 'setup-docs/',
         mobileEmailSent: true,
+        selectedIntegrations: [],
       };
       responses.forEach(p => (nextState.platformToProjectIdMap[p.platform] = p.slug));
       setPersistedOnboardingState(nextState);
@@ -101,7 +102,7 @@ export default function CreateProjectsFooter({
         }
         browserHistory.push(`/onboarding/${organization.slug}/mobile-redirect/`);
       } else {
-        onComplete();
+        setTimeout(onComplete);
       }
     } catch (err) {
       addErrorMessage(t('Failed to create projects'));

+ 2 - 3
static/app/views/onboarding/targetedOnboarding/components/firstEventFooter.tsx

@@ -174,8 +174,7 @@ const AnimatedText = styled(motion.div, {
   shouldForwardProp: prop => prop !== 'errorReceived',
 })<{errorReceived: boolean}>`
   margin-left: ${space(1)};
-  color: ${p =>
-    p.errorReceived ? p.theme.successText : p.theme.charts.getColorPalette(5)[4]};
+  color: ${p => (p.errorReceived ? p.theme.successText : p.theme.pink300)};
 `;
 
 const indicatorAnimation: Variants = {
@@ -191,7 +190,7 @@ AnimatedText.defaultProps = {
 
 const WaitingIndicator = styled(motion.div)`
   ${pulsingIndicatorStyles};
-  background-color: ${p => p.theme.charts.getColorPalette(5)[4]};
+  background-color: ${p => p.theme.pink300};
 `;
 
 WaitingIndicator.defaultProps = {

+ 215 - 0
static/app/views/onboarding/targetedOnboarding/components/integrationInstaller.tsx

@@ -0,0 +1,215 @@
+import * as React from 'react';
+import styled from '@emotion/styled';
+import startCase from 'lodash/startCase';
+
+import Button from 'sentry/components/button';
+import Tag from 'sentry/components/tag';
+import {IconClose, IconOpen} from 'sentry/icons';
+import PluginIcon from 'sentry/plugins/components/pluginIcon';
+import space from 'sentry/styles/space';
+import {IntegrationProvider, Organization} from 'sentry/types';
+import {
+  getCategories,
+  getIntegrationFeatureGate,
+  trackIntegrationAnalytics,
+} from 'sentry/utils/integrationUtil';
+import marked, {singleLineRenderer} from 'sentry/utils/marked';
+import withOrganization from 'sentry/utils/withOrganization';
+import AddIntegrationButton from 'sentry/views/organizationIntegrations/addIntegrationButton';
+import {INSTALLED, NOT_INSTALLED} from 'sentry/views/organizationIntegrations/constants';
+import IntegrationStatus from 'sentry/views/organizationIntegrations/integrationStatus';
+
+type Props = {
+  isInstalled: boolean;
+  organization: Organization;
+  provider: IntegrationProvider;
+  setIntegrationInstalled: () => void;
+};
+
+function IntegrationInstaller({
+  isInstalled,
+  provider,
+  setIntegrationInstalled,
+  organization,
+}: Props) {
+  const installationStatus = isInstalled ? INSTALLED : NOT_INSTALLED;
+  const featureData = provider.metadata.features;
+  const tags = getCategories(featureData);
+  const features = featureData.map(f => ({
+    featureGate: f.featureGate,
+    description: (
+      <FeatureListItem
+        dangerouslySetInnerHTML={{__html: singleLineRenderer(f.description)}}
+      />
+    ),
+  }));
+  const {IntegrationFeatures, FeatureList} = getIntegrationFeatureGate();
+  const {metadata, slug} = provider;
+  const featureProps = {organization, features};
+  const installButton = (disabled: boolean) => {
+    const buttonProps = {
+      style: {marginBottom: space(1)},
+      size: 'small' as const,
+      priority: 'primary' as const,
+      'data-test-id': 'install-button',
+      disabled,
+      organization,
+    };
+    // must be installed externally
+    if (metadata.aspects.externalInstall) {
+      return (
+        <Button
+          icon={<IconOpen />}
+          href={metadata.aspects.externalInstall.url}
+          onClick={() =>
+            trackIntegrationAnalytics('integrations.installation_start', {
+              integration: slug,
+              integration_type: 'first_party',
+              already_installed: isInstalled,
+              organization,
+              view: 'onboarding',
+            })
+          }
+          external
+          {...buttonProps}
+        >
+          {metadata.aspects.externalInstall.buttonText}
+        </Button>
+      );
+    }
+    return (
+      <AddIntegrationButton
+        provider={provider}
+        onAddIntegration={() => setIntegrationInstalled()}
+        analyticsParams={{
+          view: 'onboarding',
+          already_installed: isInstalled,
+        }}
+        {...buttonProps}
+      />
+    );
+  };
+  return (
+    <Wrapper>
+      <TopSectionWrapper>
+        <Flex>
+          <PluginIcon pluginId={provider.slug} size={50} />
+          <NameContainer>
+            <Flex>
+              <Name>{provider.name}</Name>
+              <StatusWrapper>
+                <IntegrationStatus status={installationStatus} />
+              </StatusWrapper>
+            </Flex>
+            <Flex>
+              {tags.map(feature => (
+                <StyledTag key={feature}>{startCase(feature)}</StyledTag>
+              ))}
+            </Flex>
+          </NameContainer>
+        </Flex>
+        <Flex>
+          <IntegrationFeatures {...featureProps}>
+            {({disabled, disabledReason}) => (
+              <DisableWrapper>
+                {installButton(disabled)}
+                {disabled && <DisabledNotice reason={disabledReason} />}
+              </DisableWrapper>
+            )}
+          </IntegrationFeatures>
+        </Flex>
+      </TopSectionWrapper>
+
+      <Flex>
+        <FlexContainer>
+          <Description dangerouslySetInnerHTML={{__html: marked(metadata.description)}} />
+          <FeatureList {...featureProps} provider={{key: slug}} />
+        </FlexContainer>
+      </Flex>
+    </Wrapper>
+  );
+}
+
+export default withOrganization(IntegrationInstaller);
+
+const Wrapper = styled('div')``;
+
+const Flex = styled('div')`
+  display: flex;
+`;
+
+const FlexContainer = styled('div')`
+  flex: 1;
+`;
+
+const Description = styled('div')`
+  li {
+    margin-bottom: 6px;
+  }
+`;
+
+const TopSectionWrapper = styled('div')`
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: ${space(2)};
+`;
+
+const NameContainer = styled('div')`
+  display: flex;
+  align-items: flex-start;
+  flex-direction: column;
+  justify-content: center;
+  padding-left: ${space(2)};
+`;
+
+const StyledTag = styled(Tag)`
+  text-transform: none;
+  &:not(:first-child) {
+    margin-left: ${space(0.5)};
+  }
+`;
+
+const Name = styled('div')`
+  font-weight: bold;
+  font-size: 1.4em;
+  margin-bottom: ${space(1)};
+`;
+
+const StatusWrapper = styled('div')`
+  margin-bottom: ${space(1)};
+  padding-left: ${space(2)};
+  line-height: 1.5em;
+`;
+
+const DisabledNotice = styled(({reason, ...p}: {reason: React.ReactNode}) => (
+  <div
+    style={{
+      display: 'flex',
+      alignItems: 'center',
+    }}
+    {...p}
+  >
+    <IconCloseCircle isCircled />
+    <span>{reason}</span>
+  </div>
+))`
+  padding-top: ${space(0.5)};
+  font-size: 0.9em;
+`;
+
+const DisableWrapper = styled('div')`
+  margin-left: auto;
+  align-self: center;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+`;
+
+const IconCloseCircle = styled(IconClose)`
+  color: ${p => p.theme.red300};
+  margin-right: ${space(1)};
+`;
+
+const FeatureListItem = styled('span')`
+  line-height: 24px;
+`;

+ 98 - 0
static/app/views/onboarding/targetedOnboarding/components/integrationSidebarSection.tsx

@@ -0,0 +1,98 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+
+import {IconCheckmark} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import PluginIcon from 'sentry/plugins/components/pluginIcon';
+import space from 'sentry/styles/space';
+import {IntegrationProvider} from 'sentry/types';
+
+type Props = {
+  activeIntegration: string | null;
+  installedIntegrations: Set<string>;
+  providers: IntegrationProvider[];
+  selectActiveIntegration: (slug: string) => void;
+  selectedIntegrations: string[];
+};
+function IntegrationSidebarSection({
+  activeIntegration,
+  installedIntegrations,
+  selectedIntegrations,
+  selectActiveIntegration,
+  providers,
+}: Props) {
+  const oneIntegration = (integration: string) => {
+    const isActive = activeIntegration === integration;
+    const isInstalled = installedIntegrations.has(integration);
+    const provider = providers.find(p => p.slug === integration);
+    // should never happen
+    if (!provider) {
+      return null;
+    }
+    return (
+      <IntegrationWrapper
+        key={integration}
+        isActive={isActive}
+        onClick={() => selectActiveIntegration(integration)}
+      >
+        <PluginIcon pluginId={integration} size={36} />
+        <MiddleWrapper>
+          <NameWrapper>{provider.name}</NameWrapper>
+          <SubHeader
+            isInstalled={isInstalled}
+            data-test-id="sidebar-integration-indicator"
+          >
+            {isInstalled ? t('Installed') : t('Not Installed')}
+          </SubHeader>
+        </MiddleWrapper>
+        {isInstalled ? <StyledIconCheckmark isCircled color="green400" /> : null}
+      </IntegrationWrapper>
+    );
+  };
+  return (
+    <Fragment>
+      <Title>{t('Integrations to Setup')}</Title>
+      {selectedIntegrations.map(oneIntegration)}
+    </Fragment>
+  );
+}
+
+export default IntegrationSidebarSection;
+
+const Title = styled('span')`
+  font-size: 12px;
+  font-weight: 600;
+  text-transform: uppercase;
+  margin-left: ${space(2)};
+`;
+
+const SubHeader = styled('div')<{isInstalled: boolean}>`
+  color: ${p => (p.isInstalled ? p.theme.successText : p.theme.textColor)};
+`;
+
+const IntegrationWrapper = styled('div')<{isActive: boolean}>`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  background-color: ${p => p.isActive && p.theme.gray100};
+  padding: ${space(2)};
+  cursor: pointer;
+  border-radius: 4px;
+  user-select: none;
+`;
+
+const StyledIconCheckmark = styled(IconCheckmark)`
+  flex-shrink: 0;
+`;
+
+const MiddleWrapper = styled('div')`
+  margin: 0 ${space(1)};
+  flex-grow: 1;
+  overflow: hidden;
+`;
+
+const NameWrapper = styled('div')`
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;

+ 8 - 22
static/app/views/onboarding/targetedOnboarding/components/sidebar.tsx → static/app/views/onboarding/targetedOnboarding/components/projectSidebarSection.tsx

@@ -1,3 +1,4 @@
+import {Fragment} from 'react';
 import styled from '@emotion/styled';
 import {motion, Variants} from 'framer-motion';
 import {PlatformIcon} from 'platformicons';
@@ -12,14 +13,14 @@ import {Project} from 'sentry/types';
 import testableTransition from 'sentry/utils/testableTransition';
 
 type Props = {
+  activeProject: Project | null;
   checkProjectHasFirstEvent: (project: Project) => boolean;
   projects: Project[];
   selectProject: (newProjectId: string) => void;
   // A map from selected platform keys to the projects created by onboarding.
   selectedPlatformToProjectIdMap: {[key in PlatformKey]?: string};
-  activeProject?: Project;
 };
-function Sidebar({
+function ProjectSidebarSection({
   projects,
   activeProject,
   selectProject,
@@ -59,16 +60,16 @@ function Sidebar({
     );
   };
   return (
-    <Wrapper>
+    <Fragment>
       <Title>{t('Projects to Setup')}</Title>
       {Object.entries(selectedPlatformToProjectIdMap).map(
         ([platformOnCreate, projectSlug]) => oneProject(platformOnCreate, projectSlug)
       )}
-    </Wrapper>
+    </Fragment>
   );
 }
 
-export default Sidebar;
+export default ProjectSidebarSection;
 
 const Title = styled('span')`
   font-size: 12px;
@@ -78,8 +79,7 @@ const Title = styled('span')`
 `;
 
 const SubHeader = styled('div')<{errorReceived: boolean}>`
-  color: ${p =>
-    p.errorReceived ? p.theme.successText : p.theme.charts.getColorPalette(5)[4]};
+  color: ${p => (p.errorReceived ? p.theme.successText : p.theme.pink300)};
 `;
 
 const StyledPlatformIcon = styled(PlatformIcon)``;
@@ -119,7 +119,7 @@ const WaitingIndicator = styled(motion.div)`
   margin: 0 6px;
   flex-shrink: 0;
   ${pulsingIndicatorStyles};
-  background-color: ${p => p.theme.charts.getColorPalette(5)[4]};
+  background-color: ${p => p.theme.pink300};
 `;
 const StyledIconCheckmark = styled(IconCheckmark)`
   flex-shrink: 0;
@@ -141,17 +141,3 @@ const NameWrapper = styled('div')`
   white-space: nowrap;
   text-overflow: ellipsis;
 `;
-
-// 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 Wrapper = styled('div')`
-  margin: ${space(1)} calc(${space(2)} + 30px + ${space(4)}) 0 ${space(2)};
-  @media (max-width: 1150px) {
-    display: none;
-  }
-  flex-basis: 240px;
-  flex-grow: 0;
-  flex-shrink: 0;
-  min-width: 240px;
-`;

+ 75 - 0
static/app/views/onboarding/targetedOnboarding/components/setupIntegrationsFooter.tsx

@@ -0,0 +1,75 @@
+import styled from '@emotion/styled';
+
+import Button from 'sentry/components/button';
+import Link from 'sentry/components/links/link';
+import {t} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {Organization} from 'sentry/types';
+import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
+
+import {usePersistedOnboardingState} from '../utils';
+
+import GenericFooter from './genericFooter';
+
+interface FirstEventFooterProps {
+  onClickSetupLater: () => void;
+  organization: Organization;
+}
+
+export default function FirstEventFooter({
+  organization,
+  onClickSetupLater,
+}: FirstEventFooterProps) {
+  const source = 'targeted_onboarding_setup_integrations_footer';
+  const [clientState, setClientState] = usePersistedOnboardingState();
+
+  return (
+    <GridFooter>
+      <SkipOnboardingLink
+        onClick={() => {
+          trackAdvancedAnalyticsEvent('growth.onboarding_clicked_skip', {
+            organization,
+            source,
+          });
+          if (clientState) {
+            setClientState({
+              ...clientState,
+              state: 'skipped',
+            });
+          }
+        }}
+        to={`/organizations/${organization.slug}/issues/`}
+      >
+        {t('Skip Onboarding')}
+      </SkipOnboardingLink>
+      <div />
+      <ButtonWrapper>
+        <Button onClick={onClickSetupLater}>{t('Next')}</Button>
+      </ButtonWrapper>
+    </GridFooter>
+  );
+}
+
+const ButtonWrapper = styled('div')`
+  margin: ${space(2)} ${space(4)};
+  justify-self: end;
+  margin-left: auto;
+`;
+
+const SkipOnboardingLink = styled(Link)`
+  margin: auto ${space(4)};
+  white-space: nowrap;
+  @media (max-width: ${p => p.theme.breakpoints[0]}) {
+    display: none;
+  }
+`;
+
+const GridFooter = styled(GenericFooter)`
+  display: grid;
+  grid-template-columns: 1fr 1fr 1fr;
+  @media (max-width: ${p => p.theme.breakpoints[0]}) {
+    display: flex;
+    flex-direction: row;
+    justify-content: end;
+  }
+`;

+ 1 - 1
static/app/views/onboarding/targetedOnboarding/onboarding.tsx

@@ -126,7 +126,6 @@ function Onboarding(props: Props) {
     if (step.cornerVariant !== nextStep.cornerVariant) {
       cornerVariantControl.start('none');
     }
-
     browserHistory.push(`/onboarding/${props.params.orgId}/${nextStep.id}/`);
   };
 
@@ -201,6 +200,7 @@ function Onboarding(props: Props) {
             {stepObj.Component && (
               <stepObj.Component
                 active
+                data-test-id={`onboarding-step-${stepObj.id}`}
                 stepIndex={stepIndex}
                 onComplete={() => stepObj && goNextStep(stepObj)}
                 orgId={props.params.orgId}

+ 231 - 39
static/app/views/onboarding/targetedOnboarding/setupDocs.tsx

@@ -9,13 +9,14 @@ import * as qs from 'query-string';
 
 import {loadDocs} from 'sentry/actionCreators/projects';
 import Alert, {alertStyles} from 'sentry/components/alert';
+import AsyncComponent from 'sentry/components/asyncComponent';
 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 {t, tct} from 'sentry/locale';
 import space from 'sentry/styles/space';
-import {Project} from 'sentry/types';
+import {Integration, IntegrationProvider, Project} from 'sentry/types';
 import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
 import getDynamicText from 'sentry/utils/getDynamicText';
 import {Theme} from 'sentry/utils/theme';
@@ -24,7 +25,10 @@ import withProjects from 'sentry/utils/withProjects';
 
 import FirstEventFooter from './components/firstEventFooter';
 import FullIntroduction from './components/fullIntroduction';
-import TargetedOnboardingSidebar from './components/sidebar';
+import IntegrationInstaller from './components/integrationInstaller';
+import IntegrationSidebarSection from './components/integrationSidebarSection';
+import ProjectSidebarSection from './components/projectSidebarSection';
+import SetupIntegrationsFooter from './components/setupIntegrationsFooter';
 import {StepProps} from './types';
 import {usePersistedOnboardingState} from './utils';
 
@@ -37,11 +41,21 @@ const INCOMPLETE_DOC_FLAG = 'TODO-ADD-VERIFICATION-EXAMPLE';
 type PlatformDoc = {html: string; link: string};
 
 type Props = {
+  configurations: Integration[];
   projects: Project[];
+  providers: IntegrationProvider[];
   search: string;
+  loadingProjects?: boolean;
 } & StepProps;
 
-function SetupDocs({organization, projects, search}: Props) {
+function SetupDocs({
+  organization,
+  projects,
+  search,
+  configurations,
+  providers,
+  loadingProjects,
+}: Props) {
   const api = useApi();
   const [clientState, setClientState] = usePersistedOnboardingState();
   const selectedProjectsSet = new Set(
@@ -50,6 +64,7 @@ function SetupDocs({organization, projects, search}: Props) {
     ) || []
   );
 
+  // SDK instrumentation
   const [hasError, setHasError] = useState(false);
   const [platformDocs, setPlatformDocs] = useState<PlatformDoc | null>(null);
   const [loadedPlatform, setLoadedPlatform] = useState<PlatformKey | null>(null);
@@ -65,7 +80,11 @@ function SetupDocs({organization, projects, search}: Props) {
   };
 
   // TODO: Check no projects
-  const {sub_step: rawSubStep, project_id: rawProjectId} = qs.parse(search);
+  const {
+    sub_step: rawSubStep,
+    project_id: rawProjectId,
+    integration: rawActiveIntegration,
+  } = qs.parse(search);
   const subStep = rawSubStep === 'integration' ? 'integration' : 'project';
   const rawProjectIndex = projects.findIndex(p => p.id === rawProjectId);
   const firstProjectNoError = projects.findIndex(
@@ -73,14 +92,52 @@ function SetupDocs({organization, projects, search}: Props) {
   );
   // Select a project based on search params. If non exist, use the first project without first event.
   const projectIndex = rawProjectIndex >= 0 ? rawProjectIndex : firstProjectNoError;
-  const project = projects[projectIndex];
+  const project = subStep === 'project' ? projects[projectIndex] : null;
+
+  // integration installation
+  const selectedIntegrations = clientState?.selectedIntegrations || [];
+  const [installedIntegrations, setInstalledIntegrations] = useState(
+    new Set(configurations.map(config => config.provider.slug))
+  );
+  const integrationsNotInstalled = selectedIntegrations.filter(
+    i => !installedIntegrations.has(i)
+  );
+  const setIntegrationInstalled = (integration: string) =>
+    setInstalledIntegrations(new Set([...installedIntegrations, integration]));
+
+  const rawActiveIntegrationIndex = selectedIntegrations.findIndex(
+    i => i === rawActiveIntegration
+  );
+
+  const firstUninstalledIntegrationIndex = selectedIntegrations.findIndex(
+    i => !installedIntegrations.has(i)
+  );
+
+  const activeIntegrationIndex =
+    rawActiveIntegrationIndex >= 0
+      ? rawActiveIntegrationIndex
+      : firstUninstalledIntegrationIndex;
+
+  const activeIntegration =
+    subStep === 'integration' ? selectedIntegrations[activeIntegrationIndex] : null;
+  const activeProvider = activeIntegration
+    ? providers.find(p => p.key === activeIntegration)
+    : null;
 
   useEffect(() => {
-    if (clientState && !project && projects.length > 0) {
-      // Can't find a project to show, probably because all projects are either deleted or finished.
+    // should not redirect if we don't have an active client state or projects aren't loaded
+    if (!clientState || loadingProjects) {
+      return;
+    }
+    if (
+      // no integrations left on integration step
+      (subStep === 'integration' && !activeIntegration) ||
+      // If no projects remaining and no integrations to install, then we can leave
+      (subStep === 'project' && !project && !integrationsNotInstalled.length)
+    ) {
       browserHistory.push('/');
     }
-  }, [clientState, project, projects]);
+  });
 
   const currentPlatform = loadedPlatform ?? project?.platform ?? 'other';
 
@@ -117,6 +174,7 @@ function SetupDocs({organization, projects, search}: Props) {
 
   const setNewProject = (newProjectId: string) => {
     const searchParams = new URLSearchParams({
+      sub_step: 'project',
       project_id: newProjectId,
     });
     browserHistory.push(`${window.location.pathname}?${searchParams}`);
@@ -138,6 +196,29 @@ function SetupDocs({organization, projects, search}: Props) {
     setNewProject(newProjectId);
   };
 
+  const setNewActiveIntegration = (integration: string) => {
+    const searchParams = new URLSearchParams({
+      integration,
+      sub_step: 'integration',
+    });
+    browserHistory.push(`${window.location.pathname}?${searchParams}`);
+    if (clientState) {
+      setClientState({
+        ...clientState,
+        state: 'integrations_selected',
+        url: `setup-docs/?${searchParams}`,
+      });
+    }
+  };
+
+  const selectActiveIntegration = (integration: string) => {
+    trackAdvancedAnalyticsEvent('growth.onboarding_clicked_integration_in_sidebar', {
+      organization,
+      integration,
+    });
+    setNewActiveIntegration(integration);
+  };
+
   const missingExampleWarning = () => {
     const missingExample =
       platformDocs && platformDocs.html.includes(INCOMPLETE_DOC_FLAG);
@@ -171,7 +252,7 @@ function SetupDocs({organization, projects, search}: Props) {
 
   const loadingError = (
     <LoadingError
-      message={t('Failed to load documentation for the %s platform.', project.platform)}
+      message={t('Failed to load documentation for the %s platform.', project?.platform)}
       onRetry={fetchData}
     />
   );
@@ -185,30 +266,54 @@ function SetupDocs({organization, projects, search}: Props) {
   return (
     <Fragment>
       <Wrapper>
-        <TargetedOnboardingSidebar
-          projects={projects}
-          selectedPlatformToProjectIdMap={
-            clientState
-              ? Object.fromEntries(
-                  clientState.selectedPlatforms.map(platform => [
-                    platform,
-                    clientState.platformToProjectIdMap[platform],
-                  ])
-                )
-              : {}
-          }
-          activeProject={project}
-          {...{checkProjectHasFirstEvent, selectProject}}
-        />
-        <MainContent>
-          <FullIntroduction
-            currentPlatform={currentPlatform}
-            organization={organization}
+        <SidebarWrapper>
+          <ProjectSidebarSection
+            projects={projects}
+            selectedPlatformToProjectIdMap={
+              clientState
+                ? Object.fromEntries(
+                    clientState.selectedPlatforms.map(platform => [
+                      platform,
+                      clientState.platformToProjectIdMap[platform],
+                    ])
+                  )
+                : {}
+            }
+            activeProject={project}
+            {...{checkProjectHasFirstEvent, selectProject}}
           />
-          {getDynamicText({
-            value: !hasError ? docs : loadingError,
-            fixed: testOnlyAlert,
-          })}
+          {selectedIntegrations.length ? (
+            <IntegrationSidebarSection
+              {...{
+                installedIntegrations,
+                selectedIntegrations,
+                activeIntegration,
+                selectActiveIntegration,
+                providers,
+              }}
+            />
+          ) : null}
+        </SidebarWrapper>
+        <MainContent>
+          {subStep === 'project' ? (
+            <Fragment>
+              <FullIntroduction
+                currentPlatform={currentPlatform}
+                organization={organization}
+              />
+              {getDynamicText({
+                value: !hasError ? docs : loadingError,
+                fixed: testOnlyAlert,
+              })}
+            </Fragment>
+          ) : null}
+          {activeProvider && (
+            <IntegrationInstaller
+              provider={activeProvider}
+              isInstalled={installedIntegrations.has(activeProvider.slug)}
+              setIntegrationInstalled={() => setIntegrationInstalled(activeProvider.slug)}
+            />
+          )}
         </MainContent>
       </Wrapper>
 
@@ -221,7 +326,8 @@ function SetupDocs({organization, projects, search}: Props) {
             project.slug ===
               clientState.platformToProjectIdMap[
                 clientState.selectedPlatforms[clientState.selectedPlatforms.length - 1]
-              ]
+              ] &&
+            !integrationsNotInstalled.length
           }
           hasFirstEvent={checkProjectHasFirstEvent(project)}
           onClickSetupLater={() => {
@@ -243,17 +349,20 @@ function SetupDocs({organization, projects, search}: Props) {
             const nextProjectSlug =
               nextPlatform && clientState.platformToProjectIdMap[nextPlatform];
             const nextProject = projects.find(p => p.slug === nextProjectSlug);
-            if (!nextProject) {
-              // We're done here.
+            // if we have a next project, switch to that
+            if (nextProject) {
+              setNewProject(nextProject.id);
+              // if we have integrations to install switch to that
+            } else if (integrationsNotInstalled.length) {
+              setNewActiveIntegration(integrationsNotInstalled[0]);
+              // otherwise no integrations and no projects so we're done
+            } else {
               setClientState({
                 ...clientState,
                 state: 'finished',
               });
-              // TODO: integrations
               browserHistory.push(orgIssuesURL);
-              return;
             }
-            setNewProject(nextProject.id);
           }}
           handleFirstIssueReceived={() => {
             const newHasFirstEventMap = {...hasFirstEventMap, [project.id]: true};
@@ -261,11 +370,80 @@ function SetupDocs({organization, projects, search}: Props) {
           }}
         />
       )}
+      {activeIntegration && (
+        <SetupIntegrationsFooter
+          organization={organization}
+          onClickSetupLater={() => {
+            trackAdvancedAnalyticsEvent(
+              'growth.onboarding_clicked_setup_integration_later',
+              {
+                organization,
+                integration: activeIntegration,
+                integration_index: activeIntegrationIndex,
+              }
+            );
+            // find the next uninstalled integration
+            const nextIntegration = selectedIntegrations.find(
+              (i, index) =>
+                !installedIntegrations.has(i) && index > activeIntegrationIndex
+            );
+            // check if we have an integration to set up next
+            if (nextIntegration) {
+              setNewActiveIntegration(nextIntegration);
+            } else {
+              // client state should always exist
+              clientState &&
+                setClientState({
+                  ...clientState,
+                  state: 'finished',
+                });
+              const nextUrl = `/organizations/${organization.slug}/issues/`;
+              browserHistory.push(nextUrl);
+            }
+          }}
+        />
+      )}
     </Fragment>
   );
 }
 
-export default withProjects(SetupDocs);
+type WrapperState = {
+  configurations: Integration[] | null;
+  information: {providers: IntegrationProvider[]} | null;
+} & AsyncComponent['state'];
+
+type WrapperProps = {
+  projects: Project[];
+  search: string;
+} & StepProps &
+  AsyncComponent['props'];
+
+class SetupDocsWrapper extends AsyncComponent<WrapperProps, WrapperState> {
+  getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
+    const {slug, experiments} = this.props.organization;
+    // would be better to check the onboarding state for integrations instead of the experiment
+    // but we can't access a hook from a class component
+    if (!experiments.TargetedOnboardingIntegrationSelectExperiment) {
+      return [];
+    }
+    return [
+      ['information', `/organizations/${slug}/config/integrations/`],
+      ['configurations', `/organizations/${slug}/integrations/?includeConfig=0`],
+    ];
+  }
+  renderBody() {
+    const {configurations, information} = this.state;
+    return (
+      <SetupDocs
+        {...this.props}
+        configurations={configurations ?? []}
+        providers={information?.providers || []}
+      />
+    );
+  }
+}
+
+export default withProjects(SetupDocsWrapper);
 
 type AlertType = React.ComponentProps<typeof Alert>['type'];
 
@@ -345,3 +523,17 @@ const MainContent = styled('div')`
   min-width: 0;
   flex-grow: 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
+// also hide the sidebar if the screen is too small
+const SidebarWrapper = styled('div')`
+  margin: ${space(1)} calc(${space(2)} + 30px + ${space(4)}) 0 ${space(2)};
+  @media (max-width: 1150px) {
+    display: none;
+  }
+  flex-basis: 240px;
+  flex-grow: 0;
+  flex-shrink: 0;
+  min-width: 240px;
+`;

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