Browse Source

feat(targeted-onboarding): onboarding platform select (#32944)

This PR adds the platform selection stage for targeted onboarding to handle multiple platforms
Stephen Cefali 3 years ago
parent
commit
76f3a34a9f

+ 303 - 0
static/app/components/multiPlatformPicker.tsx

@@ -0,0 +1,303 @@
+import * as React from 'react';
+import styled from '@emotion/styled';
+import debounce from 'lodash/debounce';
+import {PlatformIcon} from 'platformicons';
+
+import Button from 'sentry/components/button';
+import ExternalLink from 'sentry/components/links/externalLink';
+import ListLink from 'sentry/components/links/listLink';
+import NavTabs from 'sentry/components/navTabs';
+import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
+import categoryList, {
+  filterAliases,
+  PlatformKey,
+  popularPlatformCategories,
+} from 'sentry/data/platformCategories';
+import platforms from 'sentry/data/platforms';
+import {IconClose, IconProject, IconSearch} from 'sentry/icons';
+import {t, tct} from 'sentry/locale';
+import {inputStyles} from 'sentry/styles/input';
+import space from 'sentry/styles/space';
+import {Organization, PlatformIntegration} from 'sentry/types';
+import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
+import EmptyMessage from 'sentry/views/settings/components/emptyMessage';
+
+const PLATFORM_CATEGORIES = [{id: 'all', name: t('All')}, ...categoryList] as const;
+
+const isPopular = (platform: PlatformIntegration) =>
+  popularPlatformCategories.includes(
+    platform.id as typeof popularPlatformCategories[number]
+  );
+
+const PlatformList = styled('div')`
+  display: grid;
+  gap: ${space(1)};
+  grid-template-columns: repeat(auto-fill, 112px);
+  margin-bottom: ${space(2)};
+`;
+
+type Category = typeof PLATFORM_CATEGORIES[number]['id'];
+
+interface PlatformPickerProps {
+  addPlatform: (key: PlatformKey) => void;
+  organization: Organization;
+  platforms: PlatformKey[];
+  removePlatform: (key: PlatformKey) => void;
+  defaultCategory?: Category;
+  listClassName?: string;
+  listProps?: React.HTMLAttributes<HTMLDivElement>;
+  noAutoFilter?: boolean;
+  showOther?: boolean;
+  source?: string;
+}
+
+function PlatformPicker(props: PlatformPickerProps) {
+  const {organization, source} = props;
+  const [category, setCategory] = React.useState<Category>(
+    props.defaultCategory ?? PLATFORM_CATEGORIES[0].id
+  );
+  const [filter, setFilter] = React.useState<string>(
+    props.noAutoFilter ? '' : (props.platforms[0] || '').split('-')[0]
+  );
+
+  function getPlatformList() {
+    const currentCategory = categoryList.find(({id}) => id === category);
+
+    const filterLowerCase = filter.toLowerCase();
+
+    const subsetMatch = (platform: PlatformIntegration) =>
+      platform.id.includes(filterLowerCase) ||
+      platform.name.toLowerCase().includes(filterLowerCase) ||
+      filterAliases[platform.id as PlatformKey]?.some(alias =>
+        alias.includes(filterLowerCase)
+      );
+
+    const categoryMatch = (platform: PlatformIntegration) =>
+      category === 'all' ||
+      (currentCategory?.platforms as undefined | string[])?.includes(platform.id);
+
+    const popularTopOfAllCompare = (a: PlatformIntegration, b: PlatformIntegration) => {
+      // for the all category, put popular ones at the top
+      if (category === 'all') {
+        if (isPopular(a) !== isPopular(b)) {
+          return isPopular(a) ? -1 : 1;
+        }
+      }
+      return a.id.localeCompare(b.id);
+    };
+
+    const filtered = platforms
+      .filter(filterLowerCase ? subsetMatch : categoryMatch)
+      .sort(popularTopOfAllCompare);
+
+    return props.showOther ? filtered : filtered.filter(({id}) => id !== 'other');
+  }
+
+  const platformList = getPlatformList();
+  const {addPlatform, removePlatform, listProps, listClassName} = props;
+
+  const logSearch = debounce(() => {
+    if (filter) {
+      trackAdvancedAnalyticsEvent('growth.platformpicker_search', {
+        search: filter.toLowerCase(),
+        num_results: platformList.length,
+        source,
+        organization,
+      });
+    }
+  }, DEFAULT_DEBOUNCE_DURATION);
+
+  React.useEffect(logSearch, [filter]);
+
+  return (
+    <React.Fragment>
+      <NavContainer>
+        <CategoryNav>
+          {PLATFORM_CATEGORIES.map(({id, name}) => (
+            <ListLink
+              key={id}
+              onClick={(e: React.MouseEvent) => {
+                trackAdvancedAnalyticsEvent('growth.platformpicker_category', {
+                  category: id,
+                  source,
+                  organization,
+                });
+                setCategory(id);
+                setFilter('');
+                e.preventDefault();
+              }}
+              to=""
+              isActive={() => id === (filter ? 'all' : category)}
+            >
+              {name}
+            </ListLink>
+          ))}
+        </CategoryNav>
+        <SearchBar>
+          <IconSearch size="xs" />
+          <input
+            type="text"
+            value={filter}
+            placeholder={t('Filter Platforms')}
+            onChange={e => {
+              setFilter(e.target.value);
+            }}
+          />
+        </SearchBar>
+      </NavContainer>
+      <PlatformList className={listClassName} {...listProps}>
+        {platformList.map(platform => (
+          <PlatformCard
+            data-test-id={`platform-${platform.id}`}
+            key={platform.id}
+            platform={platform}
+            selected={props.platforms.includes(platform.id as PlatformKey)}
+            onClear={(e: React.MouseEvent) => {
+              removePlatform(platform.id as PlatformKey);
+              e.stopPropagation();
+            }}
+            onClick={() => {
+              trackAdvancedAnalyticsEvent('growth.select_platform', {
+                platform_id: platform.id,
+                source,
+                organization,
+              });
+              addPlatform(platform.id as PlatformKey);
+            }}
+          />
+        ))}
+      </PlatformList>
+      {platformList.length === 0 && (
+        <EmptyMessage
+          icon={<IconProject size="xl" />}
+          title={t("We don't have an SDK for that yet!")}
+        >
+          {tct(
+            `Not finding your platform? You can still create your project,
+            but looks like we don't have an official SDK for your platform
+            yet. However, there's a rich ecosystem of community supported
+            SDKs (including Perl, CFML, Clojure, and ActionScript). Try
+            [search:searching for Sentry clients] or contacting support.`,
+            {
+              search: (
+                <ExternalLink href="https://github.com/search?q=-org%3Agetsentry+topic%3Asentry&type=Repositories" />
+              ),
+            }
+          )}
+        </EmptyMessage>
+      )}
+    </React.Fragment>
+  );
+}
+
+const NavContainer = styled('div')`
+  margin-bottom: ${space(2)};
+  display: grid;
+  gap: ${space(2)};
+  grid-template-columns: 1fr minmax(0, 300px);
+  align-items: start;
+  border-bottom: 1px solid ${p => p.theme.border};
+`;
+
+const SearchBar = styled('div')`
+  ${p => inputStyles(p)};
+  padding: 0 8px;
+  color: ${p => p.theme.subText};
+  display: flex;
+  align-items: center;
+  font-size: 15px;
+  margin-top: -${space(0.75)};
+
+  input {
+    border: none;
+    background: none;
+    padding: 2px 4px;
+    width: 100%;
+    /* Ensure a consistent line height to keep the input the desired height */
+    line-height: 24px;
+
+    &:focus {
+      outline: none;
+    }
+  }
+`;
+
+const CategoryNav = styled(NavTabs)`
+  margin: 0;
+  margin-top: 4px;
+  white-space: nowrap;
+
+  > li {
+    float: none;
+    display: inline-block;
+  }
+`;
+
+const StyledPlatformIcon = styled(PlatformIcon)`
+  margin: ${space(2)};
+`;
+
+const ClearButton = styled(Button)`
+  position: absolute;
+  top: -6px;
+  right: -6px;
+  height: 22px;
+  width: 22px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 50%;
+  background: ${p => p.theme.background};
+  color: ${p => p.theme.textColor};
+`;
+
+ClearButton.defaultProps = {
+  icon: <IconClose isCircled size="xs" />,
+  borderless: true,
+  size: 'xsmall',
+};
+
+const PlatformCard = styled(({platform, selected, onClear, ...props}) => (
+  <div {...props}>
+    <StyledPlatformIcon
+      platform={platform.id}
+      size={56}
+      radius={5}
+      withLanguageIcon
+      format="lg"
+    />
+
+    <h3>{platform.name}</h3>
+    {selected && <ClearButton onClick={onClear} aria-label={t('Clear')} />}
+  </div>
+))`
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  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};
+  }
+
+  h3 {
+    flex-grow: 1;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 100%;
+    color: ${p => (p.selected ? p.theme.textColor : p.theme.subText)};
+    text-align: center;
+    font-size: ${p => p.theme.fontSizeExtraSmall};
+    text-transform: uppercase;
+    margin: 0;
+    padding: 0 ${space(0.5)};
+    line-height: 1.2;
+  }
+`;
+
+export default PlatformPicker;

+ 3 - 3
static/app/data/platformCategories.tsx

@@ -1,6 +1,6 @@
 import {t} from 'sentry/locale';
 
-const popular = [
+export const popularPlatformCategories = [
   'javascript',
   'javascript-react',
   'javascript-nextjs',
@@ -132,7 +132,7 @@ export const desktop = [
 ] as const;
 
 const categoryList = [
-  {id: 'popular', name: t('Popular'), platforms: popular},
+  {id: 'popular', name: t('Popular'), platforms: popularPlatformCategories},
   {id: 'browser', name: t('Browser'), platforms: frontend},
   {id: 'server', name: t('Server'), platforms: backend},
   {id: 'mobile', name: t('Mobile'), platforms: mobile},
@@ -229,7 +229,7 @@ export const filterAliases: Partial<Record<PlatformKey, string[]>> = {
 };
 
 export type PlatformKey =
-  | typeof popular[number]
+  | typeof popularPlatformCategories[number]
   | typeof frontend[number]
   | typeof mobile[number]
   | typeof backend[number]

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

@@ -55,6 +55,7 @@ export type GrowthEventParameters = {
   'growth.onboarding_clicked_skip': {source?: string};
   'growth.onboarding_load_choose_platform': {};
   'growth.onboarding_set_up_your_project': PlatformParam;
+  'growth.onboarding_set_up_your_projects': {platforms: string};
   'growth.onboarding_start_onboarding': {
     source?: string;
   };
@@ -106,6 +107,8 @@ export const growthEventMap: Record<GrowthAnalyticsKey, string> = {
   'growth.onboarding_load_choose_platform':
     'Growth: Onboarding Load Choose Platform Page',
   'growth.onboarding_set_up_your_project': 'Growth: Onboarding Click Set Up Your Project',
+  'growth.onboarding_set_up_your_projects':
+    'Growth: Onboarding Click Set Up Your Projects',
   'growth.select_platform': 'Growth: Onboarding Choose Platform',
   'growth.platformpicker_category': 'Growth: Onboarding Platform Category',
   'growth.platformpicker_search': 'Growth: Onboarding Platform Search',

+ 119 - 0
static/app/views/onboarding/targetedOnboarding/components/createProjectsFooter.tsx

@@ -0,0 +1,119 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+import * as Sentry from '@sentry/react';
+import {motion} from 'framer-motion';
+import {PlatformIcon} from 'platformicons';
+
+import {addErrorMessage, addLoadingMessage} from 'sentry/actionCreators/indicator';
+import {createProject} from 'sentry/actionCreators/projects';
+import ProjectActions from 'sentry/actions/projectActions';
+import Button from 'sentry/components/button';
+import {PlatformKey} from 'sentry/data/platformCategories';
+import {t, tn} 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 useApi from 'sentry/utils/useApi';
+import useTeams from 'sentry/utils/useTeams';
+
+import GenericFooter from './genericFooter';
+
+type Props = {
+  genSkipOnboardingLink: () => React.ReactNode;
+  onComplete: () => void;
+  organization: Organization;
+  platforms: PlatformKey[];
+};
+
+export default function CreateProjectsFooter({
+  organization,
+  platforms,
+  onComplete,
+  genSkipOnboardingLink,
+}: Props) {
+  const api = useApi();
+  const {teams} = useTeams();
+
+  const createProjects = async () => {
+    // TODO: add logic to prevent creating project if step repeated
+    try {
+      addLoadingMessage(t('Creating projects'));
+      const responses = await Promise.all(
+        platforms.map(platform =>
+          createProject(api, organization.slug, teams[0].slug, platform, platform)
+        )
+      );
+      responses.map(ProjectActions.createSuccess);
+      trackAdvancedAnalyticsEvent('growth.onboarding_set_up_your_projects', {
+        platforms: platforms.join(','),
+        organization,
+      });
+      onComplete();
+    } catch (err) {
+      addErrorMessage(t('Failed to create projects'));
+      Sentry.captureException(err);
+    }
+  };
+
+  const renderPlatform = (platform: PlatformKey) => {
+    platform = platform || 'other';
+    return <SelectedPlatformIcon key={platform} platform={platform} size={23} />;
+  };
+
+  return (
+    <GenericFooter>
+      {genSkipOnboardingLink()}
+      <SelectionWrapper>
+        {platforms.length ? (
+          <Fragment>
+            <div>{platforms.map(renderPlatform)}</div>
+            <PlatformSelected>
+              {tn('%s platform selected', '%s platforms selected', platforms.length)}
+            </PlatformSelected>
+          </Fragment>
+        ) : null}
+      </SelectionWrapper>
+      <ButtonWrapper>
+        <Button priority="primary" onClick={createProjects}>
+          {tn('Create Project', 'Create Projects', platforms.length)}
+        </Button>
+      </ButtonWrapper>
+    </GenericFooter>
+  );
+}
+
+const SelectionWrapper = styled(motion.div)`
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  margin-right: ${space(4)};
+`;
+
+SelectionWrapper.defaultProps = {
+  transition: testableTransition({
+    duration: 1.8,
+  }),
+};
+
+const ButtonWrapper = styled(motion.div)`
+  display: flex;
+  height: 100%;
+  align-items: center;
+  margin-right: ${space(4)};
+`;
+
+ButtonWrapper.defaultProps = {
+  transition: testableTransition({
+    duration: 1.3,
+  }),
+};
+
+const SelectedPlatformIcon = styled(PlatformIcon)`
+  margin-right: ${space(1)};
+`;
+
+const PlatformSelected = styled('div')`
+  margin-top: ${space(1)};
+`;

+ 4 - 15
static/app/views/onboarding/targetedOnboarding/components/firstEventFooter.tsx

@@ -15,6 +15,8 @@ import EventWaiter from 'sentry/utils/eventWaiter';
 import testableTransition from 'sentry/utils/testableTransition';
 import CreateSampleEventButton from 'sentry/views/onboarding/createSampleEventButton';
 
+import GenericFooter from './genericFooter';
+
 interface FirstEventFooterProps {
   handleFirstIssueReceived: () => void;
   hasFirstEvent: boolean;
@@ -91,7 +93,7 @@ export default function FirstEventFooter({
   };
 
   return (
-    <Wrapper>
+    <GenericFooter>
       <SkipOnboardingLink
         onClick={() =>
           trackAdvancedAnalyticsEvent('growth.onboarding_clicked_skip', {
@@ -127,23 +129,10 @@ export default function FirstEventFooter({
           </Fragment>
         )}
       </EventWaiter>
-    </Wrapper>
+    </GenericFooter>
   );
 }
 
-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)};
 `;

+ 29 - 0
static/app/views/onboarding/targetedOnboarding/components/genericFooter.tsx

@@ -0,0 +1,29 @@
+import styled from '@emotion/styled';
+import {motion} from 'framer-motion';
+
+import testableTransition from 'sentry/utils/testableTransition';
+
+const GenericFooter = styled(motion.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);
+`;
+
+GenericFooter.defaultProps = {
+  initial: 'initial',
+  animate: 'animate',
+  exit: 'exit',
+  variants: {animate: {}},
+  transition: testableTransition({
+    staggerChildren: 0.2,
+  }),
+};
+
+export default GenericFooter;

+ 49 - 4
static/app/views/onboarding/targetedOnboarding/onboarding.tsx

@@ -5,12 +5,15 @@ import {AnimatePresence, motion, MotionProps, useAnimation} from 'framer-motion'
 
 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 testableTransition from 'sentry/utils/testableTransition';
 import withOrganization from 'sentry/utils/withOrganization';
 import withProjects from 'sentry/utils/withProjects';
@@ -40,8 +43,9 @@ const ONBOARDING_STEPS: StepDescriptor[] = [
   },
   {
     id: 'select-platform',
-    title: t('Select a platform'),
+    title: t('Select platforms'),
     Component: PlatformSelection,
+    hasFooter: true,
   },
   {
     id: 'setup-docs',
@@ -52,7 +56,10 @@ const ONBOARDING_STEPS: StepDescriptor[] = [
 ];
 
 function Onboarding(props: Props) {
-  const stepId = props.params.step;
+  const {
+    organization,
+    params: {step: stepId},
+  } = props;
   const stepObj = ONBOARDING_STEPS.find(({id}) => stepId === id);
   if (!stepObj) {
     return <div>Can't find</div>;
@@ -61,10 +68,22 @@ function Onboarding(props: Props) {
   const cornerVariantControl = useAnimation();
   const updateCornerVariant = () => {
     // TODO: find better way to delay thhe corner animation
-    setTimeout(() => cornerVariantControl.start('top-right'), 1000);
+    setTimeout(
+      () => cornerVariantControl.start(activeStepIndex === 0 ? 'top-right' : 'top-left'),
+      1000
+    );
   };
 
   React.useEffect(updateCornerVariant, []);
+  const [platforms, setPlatforms] = React.useState<PlatformKey[]>([]);
+
+  const addPlatform = (platform: PlatformKey) => {
+    setPlatforms([...platforms, platform]);
+  };
+
+  const removePlatform = (platform: PlatformKey) => {
+    setPlatforms(platforms.filter(p => p !== platform));
+  };
 
   const goNextStep = (step: StepDescriptor) => {
     const stepIndex = ONBOARDING_STEPS.findIndex(s => s.id === step.id);
@@ -80,9 +99,26 @@ function Onboarding(props: Props) {
     browserHistory.replace(`/onboarding/${props.params.orgId}/${previousStep.id}/`);
   };
 
+  const genSkipOnboardingLink = () => {
+    const source = `targeted-onboarding-${stepId}`;
+    return (
+      <SkipOnboardingLink
+        onClick={() =>
+          trackAdvancedAnalyticsEvent('growth.onboarding_clicked_skip', {
+            organization,
+            source,
+          })
+        }
+        to={`/organizations/${organization.slug}/issues/`}
+      >
+        {t('Skip Onboarding')}
+      </SkipOnboardingLink>
+    );
+  };
+
   return (
     <OnboardingWrapper data-test-id="targeted-onboarding">
-      <SentryDocumentTitle title={t('Welcome')} />
+      <SentryDocumentTitle title={stepObj.title} />
       <Header>
         <LogoSvg />
         <Hook name="onboarding:targeted-onboarding-header" />
@@ -101,10 +137,15 @@ function Onboarding(props: Props) {
             {stepObj.Component && (
               <stepObj.Component
                 active
+                stepIndex={activeStepIndex}
                 onComplete={() => goNextStep(stepObj)}
                 orgId={props.params.orgId}
                 organization={props.organization}
                 search={props.location.search}
+                platforms={platforms}
+                addPlatform={addPlatform}
+                removePlatform={removePlatform}
+                genSkipOnboardingLink={genSkipOnboardingLink}
               />
             )}
           </OnboardingStep>
@@ -234,4 +275,8 @@ const Back = styled(({className, animate, ...props}: BackButtonProps) => (
   }
 `;
 
+const SkipOnboardingLink = styled(Link)`
+  margin: auto ${space(4)};
+`;
+
 export default withOrganization(withProjects(Onboarding));

+ 43 - 2
static/app/views/onboarding/targetedOnboarding/platform.tsx

@@ -1,5 +1,46 @@
+import styled from '@emotion/styled';
+import {motion} from 'framer-motion';
+
+import ExternalLink from 'sentry/components/links/externalLink';
+import MultiPlatformPicker from 'sentry/components/multiPlatformPicker';
+import {t, tct} from 'sentry/locale';
+import testableTransition from 'sentry/utils/testableTransition';
+import StepHeading from 'sentry/views/onboarding/components/stepHeading';
+
+import CreateProjectsFooter from './components/createProjectsFooter';
 import {StepProps} from './types';
 
-export default function Platform(_props: StepProps) {
-  return <div>Platform Selection</div>;
+function OnboardingPlatform(props: StepProps) {
+  return (
+    <Wrapper>
+      <StepHeading step={props.stepIndex}>
+        {t('Select all your projects platform')}
+      </StepHeading>
+      <motion.div
+        transition={testableTransition()}
+        variants={{
+          initial: {y: 30, opacity: 0},
+          animate: {y: 0, opacity: 1},
+          exit: {opacity: 0},
+        }}
+      >
+        <p>
+          {tct(
+            `Variety is the spice of application monitoring. Sentry SDKs integrate
+           with most languages and platforms your developer heart desires.
+           [link:View the full list].`,
+            {link: <ExternalLink href="https://docs.sentry.io/platforms/" />}
+          )}
+        </p>
+        <MultiPlatformPicker noAutoFilter source="targeted-onboarding" {...props} />
+        <CreateProjectsFooter {...props} />
+      </motion.div>
+    </Wrapper>
+  );
 }
+
+export default OnboardingPlatform;
+
+const Wrapper = styled('div')`
+  width: 850px;
+`;

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

@@ -5,12 +5,18 @@ export type StepData = {
   platform?: PlatformKey | null;
 };
 
+// Not sure if we need platform info to be passed down
 export type StepProps = {
   active: boolean;
+  addPlatform: (platform: PlatformKey) => void;
+  genSkipOnboardingLink: () => React.ReactNode;
   onComplete: () => void;
   orgId: string;
   organization: Organization;
+  platforms: PlatformKey[];
+  removePlatform: (platform: PlatformKey) => void;
   search: string;
+  stepIndex: number;
 };
 
 export type StepDescriptor = {