Browse Source

feat(relocation): Get Started and Encrypt Backup pages (#61322)

This PR includes the get started page and encrypt backup page.

If `relocation:enabled` is not set, the page will not load

<img width="1174" alt="Screenshot 2023-12-07 at 1 20 51 PM"
src="https://github.com/getsentry/sentry/assets/25517925/4c43601c-4452-48d2-bc2b-03348d7dfffd">

<img width="1172" alt="Screenshot 2023-12-07 at 1 21 00 PM"
src="https://github.com/getsentry/sentry/assets/25517925/18aee989-dbbc-4663-8c9c-d6d58b097274">
Hubert Deng 1 year ago
parent
commit
dc65f986f4

+ 58 - 0
static/app/components/onboarding/relocationOnboardingContext.tsx

@@ -0,0 +1,58 @@
+import {createContext, useCallback} from 'react';
+
+import {useSessionStorage} from 'sentry/utils/useSessionStorage';
+
+type Data = {
+  orgSlugs: string;
+  region: string;
+  file?: File;
+};
+
+export type RelocationOnboardingContextProps = {
+  data: Data;
+  setData: (data: Data) => void;
+};
+
+export const RelocationOnboardingContext =
+  createContext<RelocationOnboardingContextProps>({
+    data: {
+      orgSlugs: '',
+      region: '',
+      file: undefined,
+    },
+    setData: () => {},
+  });
+
+type ProviderProps = {
+  children: React.ReactNode;
+  value?: Data;
+};
+
+export function RelocationOnboardingContextProvider({children, value}: ProviderProps) {
+  const [sessionStorage, setSessionStorage] = useSessionStorage<Data>(
+    'relocationOnboarding',
+    {
+      orgSlugs: value?.orgSlugs || '',
+      region: value?.region || '',
+      file: value?.file || undefined,
+    }
+  );
+
+  const setData = useCallback(
+    (data: Data) => {
+      setSessionStorage(data);
+    },
+    [setSessionStorage]
+  );
+
+  return (
+    <RelocationOnboardingContext.Provider
+      value={{
+        data: sessionStorage,
+        setData,
+      }}
+    >
+      {children}
+    </RelocationOnboardingContext.Provider>
+  );
+}

+ 21 - 0
static/app/routes.tsx

@@ -279,6 +279,27 @@ function buildRoutes() {
         )}
         key="org-join-request"
       />
+      {usingCustomerDomain && (
+        <Route
+          path="/relocation/"
+          component={errorHandler(withDomainRequired(OrganizationContextContainer))}
+          key="orgless-relocation"
+        >
+          <IndexRedirect to="welcome/" />
+          <Route
+            path=":step/"
+            component={make(() => import('sentry/views/relocation'))}
+          />
+        </Route>
+      )}
+      <Route
+        path="/relocation/:orgId/"
+        component={withDomainRedirect(errorHandler(OrganizationContextContainer))}
+        key="org-relocation"
+      >
+        <IndexRedirect to="welcome/" />
+        <Route path=":step/" component={make(() => import('sentry/views/relocation'))} />
+      </Route>
       {usingCustomerDomain && (
         <Route
           path="/onboarding/"

+ 40 - 0
static/app/views/relocation/components/stepHeading.tsx

@@ -0,0 +1,40 @@
+import styled from '@emotion/styled';
+import {motion} from 'framer-motion';
+
+import {space} from 'sentry/styles/space';
+import testableTransition from 'sentry/utils/testableTransition';
+
+const StepHeading = styled(motion.h2)<{step: number}>`
+  position: relative;
+  display: inline-grid;
+  grid-template-columns: max-content auto;
+  gap: ${space(2)};
+  align-items: center;
+  font-size: 21px;
+
+  &:before {
+    content: '${p => p.step}';
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 30px;
+    height: 30px;
+    background-color: ${p => p.theme.yellow300};
+    border-radius: 50%;
+    color: ${p => p.theme.textColor};
+    font-size: 1rem;
+  }
+`;
+
+StepHeading.defaultProps = {
+  variants: {
+    initial: {clipPath: 'inset(0% 100% 0% 0%)', opacity: 1},
+    animate: {clipPath: 'inset(0% 0% 0% 0%)', opacity: 1},
+    exit: {opacity: 0},
+  },
+  transition: testableTransition({
+    duration: 0.3,
+  }),
+};
+
+export default StepHeading;

+ 103 - 0
static/app/views/relocation/encryptBackup.tsx

@@ -0,0 +1,103 @@
+import styled from '@emotion/styled';
+import {motion} from 'framer-motion';
+
+import {Button} from 'sentry/components/button';
+import {CodeSnippet} from 'sentry/components/codeSnippet';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import testableTransition from 'sentry/utils/testableTransition';
+import StepHeading from 'sentry/views/relocation/components/stepHeading';
+
+import {StepProps} from './types';
+
+export function EncryptBackup(props: StepProps) {
+  const code =
+    './sentry-admin.sh export global --encrypt-with /path/to/public_key.pub\n/path/to/encrypted/backup/file.tar';
+  return (
+    <Wrapper>
+      <StepHeading step={3}>
+        {t('Create an encrypted backup of current self-hosted instance')}
+      </StepHeading>
+      <motion.div
+        transition={testableTransition()}
+        variants={{
+          initial: {y: 30, opacity: 0},
+          animate: {y: 0, opacity: 1},
+          exit: {opacity: 0},
+        }}
+      >
+        <p>
+          {t(
+            'You’ll need to have the public key saved in the previous step accessible when you run the following command in your terminal. Make sure your current working directory is the root of your `self-hosted` install when you execute it.'
+          )}
+        </p>
+        <EncryptCodeSnippet
+          dark
+          language="bash"
+          filename=">_ TERMINAL"
+          hideCopyButton={false}
+        >
+          {code}
+        </EncryptCodeSnippet>
+        <p className="encrypt-help">
+          <b>{t('Understanding the command:')}</b>
+        </p>
+        <p>
+          <mark>{'./sentry-admin.sh'}</mark>
+          {t('this is a script present in your self-hosted installation')}
+        </p>
+        <p>
+          <mark>{'/path/to/public/key/file.pub'}</mark>
+          {t('path to file you created in the previous step')}
+        </p>
+        <p>
+          <mark>{'/path/to/encrypted/backup/output/file.tar'}</mark>
+          {t('file that will be uploaded in the next step')}
+        </p>
+        <ContinueButton size="md" priority="primary" onClick={() => props.onComplete()}>
+          {t('Continue')}
+        </ContinueButton>
+      </motion.div>
+    </Wrapper>
+  );
+}
+
+export default EncryptBackup;
+
+const EncryptCodeSnippet = styled(CodeSnippet)`
+  margin: ${space(2)} 0 ${space(4)};
+  padding: 4px;
+`;
+
+const Wrapper = styled('div')`
+  max-width: 769px;
+  max-height: 525px;
+  margin-left: auto;
+  margin-right: auto;
+  padding: ${space(4)};
+  background-color: ${p => p.theme.surface400};
+  z-index: 100;
+  box-shadow: 0 5px 10px rgba(0, 0, 0, 0.05);
+  border-radius: 10px;
+  width: 100%;
+  color: ${p => p.theme.gray300};
+  mark {
+    border-radius: 8px;
+    padding: ${space(0.25)} ${space(0.5)} ${space(0.25)} ${space(0.5)};
+    background: ${p => p.theme.gray100};
+    margin-right: ${space(1)};
+  }
+  h2 {
+    color: ${p => p.theme.gray500};
+  }
+  p {
+    margin-bottom: ${space(1)};
+  }
+  .encrypt-help {
+    color: ${p => p.theme.gray500};
+  }
+`;
+
+const ContinueButton = styled(Button)`
+  margin-top: ${space(1.5)};
+`;

+ 154 - 0
static/app/views/relocation/getStarted.tsx

@@ -0,0 +1,154 @@
+import {useContext, useState} from 'react';
+import styled from '@emotion/styled';
+import {motion} from 'framer-motion';
+
+import {Button} from 'sentry/components/button';
+import SelectControl from 'sentry/components/forms/controls/selectControl';
+import Input from 'sentry/components/input';
+import {RelocationOnboardingContext} from 'sentry/components/onboarding/relocationOnboardingContext';
+import {t} from 'sentry/locale';
+import ConfigStore from 'sentry/stores/configStore';
+import {space} from 'sentry/styles/space';
+import testableTransition from 'sentry/utils/testableTransition';
+import StepHeading from 'sentry/views/relocation/components/stepHeading';
+
+import {StepProps} from './types';
+
+function GetStarted(props: StepProps) {
+  const regions = ConfigStore.get('regions');
+  const [region, setRegion] = useState('');
+  const [orgSlugs, setOrgSlugs] = useState('');
+  const relocationOnboardingContext = useContext(RelocationOnboardingContext);
+
+  const handleContinue = (event: any) => {
+    event.preventDefault();
+    relocationOnboardingContext.setData({orgSlugs, region});
+    props.onComplete();
+  };
+  // TODO(getsentry/team-ospo#214): Make a popup to warn users about data region selection
+  return (
+    <Wrapper>
+      <StepHeading step={1}>{t('Basic information needed to get started')}</StepHeading>
+      <motion.div
+        transition={testableTransition()}
+        variants={{
+          initial: {y: 30, opacity: 0},
+          animate: {y: 0, opacity: 1},
+          exit: {opacity: 0},
+        }}
+      >
+        <Form onSubmit={handleContinue}>
+          <p>
+            {t(
+              'In order to best facilitate the process some basic information will be required to ensure sucess with the relocation process of you self-hosted instance'
+            )}
+          </p>
+          <RequiredLabel>{t('Organization slugs being relocated')}</RequiredLabel>
+          <Input
+            type="text"
+            name="orgs"
+            aria-label="org-slugs"
+            onChange={evt => setOrgSlugs(evt.target.value)}
+            required
+            minLength={3}
+            placeholder="org-slug-1, org-slug-2, ..."
+          />
+          <Label>{t('Choose a datacenter region')}</Label>
+          <RegionSelect
+            value={region}
+            name="region"
+            aria-label="region"
+            placeholder="Select Region"
+            options={regions.map(r => ({label: r.name, value: r.name}))}
+            onChange={opt => setRegion(opt.value)}
+          />
+          {region && <p>{t('This is an important decision and cannot be changed.')}</p>}
+          <ContinueButton
+            disabled={!orgSlugs || !region}
+            size="md"
+            priority="primary"
+            type="submit"
+          >
+            {t('Continue')}
+          </ContinueButton>
+        </Form>
+      </motion.div>
+    </Wrapper>
+  );
+}
+
+export default GetStarted;
+
+const AnimatedContentWrapper = styled(motion.div)`
+  overflow: hidden;
+`;
+
+AnimatedContentWrapper.defaultProps = {
+  initial: {
+    height: 0,
+  },
+  animate: {
+    height: 'auto',
+  },
+  exit: {
+    height: 0,
+  },
+};
+
+const DocsWrapper = styled(motion.div)``;
+
+DocsWrapper.defaultProps = {
+  initial: {opacity: 0, y: 40},
+  animate: {opacity: 1, y: 0},
+  exit: {opacity: 0},
+};
+
+const Wrapper = styled('div')`
+  margin-left: auto;
+  margin-right: auto;
+  padding: ${space(4)};
+  background-color: ${p => p.theme.surface400};
+  z-index: 100;
+  box-shadow: 0 5px 10px rgba(0, 0, 0, 0.05);
+  border-radius: 10px;
+  max-width: 769px;
+  max-height: 525px;
+  color: ${p => p.theme.gray300};
+  h2 {
+    color: ${p => p.theme.gray500};
+  }
+`;
+
+const ContinueButton = styled(Button)`
+  margin-top: ${space(4)};
+`;
+
+const Form = styled('form')`
+  position: relative;
+`;
+
+const Label = styled('label')`
+  display: block;
+  text-transform: uppercase;
+  color: ${p => p.theme.gray500};
+  margin-top: ${space(2)};
+`;
+
+const RequiredLabel = styled('label')`
+  display: block;
+  text-transform: uppercase;
+  color: ${p => p.theme.gray500};
+  margin-top: ${space(2)};
+  &:after {
+    content: '•';
+    width: 6px;
+    color: ${p => p.theme.red300};
+  }
+`;
+
+const RegionSelect = styled(SelectControl)`
+  padding-bottom: ${space(2)};
+  button {
+    width: 709px;
+  }
+`;

+ 40 - 0
static/app/views/relocation/index.spec.tsx

@@ -0,0 +1,40 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import ConfigStore from 'sentry/stores/configStore';
+
+import RelocationOnboardingContainer from './index';
+
+describe('Relocation Onboarding Container', function () {
+  it('should render if feature enabled', function () {
+    const {routerProps, routerContext, organization} = initializeOrg({
+      router: {
+        params: {step: '1'},
+      },
+    });
+    ConfigStore.set('features', new Set(['relocation:enabled']));
+    render(<RelocationOnboardingContainer {...routerProps} />, {
+      context: routerContext,
+      organization,
+    });
+    expect(
+      screen.queryByText("You don't have access to this feature")
+    ).not.toBeInTheDocument();
+  });
+
+  it('should not render if feature disabled', async function () {
+    const {routerProps, routerContext, organization} = initializeOrg({
+      router: {
+        params: {step: '1'},
+      },
+    });
+    ConfigStore.set('features', new Set([]));
+    render(<RelocationOnboardingContainer {...routerProps} />, {
+      context: routerContext,
+      organization,
+    });
+    expect(
+      await screen.queryByText("You don't have access to this feature")
+    ).toBeInTheDocument();
+  });
+});

+ 25 - 0
static/app/views/relocation/index.tsx

@@ -0,0 +1,25 @@
+import {RouteComponentProps} from 'react-router';
+
+import Feature from 'sentry/components/acl/feature';
+import {Alert} from 'sentry/components/alert';
+import * as Layout from 'sentry/components/layouts/thirds';
+import {t} from 'sentry/locale';
+
+import RelocationOnboarding from './relocation';
+
+type Props = RouteComponentProps<{step: string}, {}>;
+
+export default function RelocationOnboardingContainer(props: Props) {
+  return (
+    <Feature
+      features={['relocation:enabled']}
+      renderDisabled={() => (
+        <Layout.Page withPadding>
+          <Alert type="warning">{t("You don't have access to this feature")}</Alert>
+        </Layout.Page>
+      )}
+    >
+      <RelocationOnboarding {...props} />
+    </Feature>
+  );
+}

+ 64 - 0
static/app/views/relocation/relocation.spec.tsx

@@ -0,0 +1,64 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import ConfigStore from 'sentry/stores/configStore';
+import Relocation from 'sentry/views/relocation/relocation';
+
+describe('Relocation', function () {
+  function renderPage(step) {
+    const routeParams = {
+      step,
+    };
+
+    const {routerProps, routerContext, organization} = initializeOrg({
+      router: {
+        params: routeParams,
+      },
+    });
+
+    return render(<Relocation {...routerProps} />, {
+      context: routerContext,
+      organization,
+    });
+  }
+  describe('Get Started', function () {
+    it('renders', async function () {
+      renderPage('get-started');
+      expect(
+        await screen.findByText('Basic information needed to get started')
+      ).toBeInTheDocument();
+      expect(
+        await screen.findByText('Organization slugs being relocated')
+      ).toBeInTheDocument();
+      expect(await screen.findByText('Choose a datacenter region')).toBeInTheDocument();
+    });
+
+    it('should prevent user from going to the next step if no org slugs or region are entered', function () {
+      renderPage('get-started');
+      expect(screen.getByRole('button', {name: 'Continue'})).toBeDisabled();
+    });
+
+    it('should be allowed to go to next step if org slug is entered and region is selected', async function () {
+      renderPage('get-started');
+      ConfigStore.set('regions', [{name: 'USA', url: 'https://example.com'}]);
+      const orgSlugsInput = screen.getByLabelText('org-slugs');
+      const continueButton = screen.getByRole('button', {name: 'Continue'});
+      await userEvent.type(orgSlugsInput, 'test-org');
+      await userEvent.type(screen.getByLabelText('region'), 'U');
+      await userEvent.click(screen.getByRole('menuitemradio'));
+      expect(continueButton).toBeEnabled();
+    });
+  });
+
+  describe('Select Platform', function () {
+    it('renders', async function () {
+      renderPage('encrypt-backup');
+      expect(
+        await screen.findByText(
+          'Create an encrypted backup of current self-hosted instance'
+        )
+      ).toBeInTheDocument();
+      expect(await screen.findByText('./sentry-admin.sh')).toBeInTheDocument();
+    });
+  });
+});

+ 275 - 0
static/app/views/relocation/relocation.tsx

@@ -0,0 +1,275 @@
+import {useCallback, useEffect, useRef} from 'react';
+import {RouteComponentProps} from 'react-router';
+import styled from '@emotion/styled';
+import {AnimatePresence, motion, MotionProps, useAnimation} from 'framer-motion';
+
+import {Button, ButtonProps} from 'sentry/components/button';
+import LogoSentry from 'sentry/components/logoSentry';
+import {RelocationOnboardingContextProvider} from 'sentry/components/onboarding/relocationOnboardingContext';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {IconArrow} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import Redirect from 'sentry/utils/redirect';
+import testableTransition from 'sentry/utils/testableTransition';
+import useOrganization from 'sentry/utils/useOrganization';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import PageCorners from 'sentry/views/onboarding/components/pageCorners';
+import Stepper from 'sentry/views/onboarding/components/stepper';
+
+import EncryptBackup from './encryptBackup';
+import GetStarted from './getStarted';
+import {StepDescriptor} from './types';
+
+type RouteParams = {
+  step: string;
+};
+
+type Props = RouteComponentProps<RouteParams, {}>;
+
+function getOrganizationOnboardingSteps(): StepDescriptor[] {
+  return [
+    {
+      id: 'get-started',
+      title: t('Get Started'),
+      Component: GetStarted,
+      cornerVariant: 'top-left',
+    },
+    {
+      id: 'encrypt-backup',
+      title: t('Encrypt backup'),
+      Component: EncryptBackup,
+      cornerVariant: 'top-left',
+    },
+  ];
+}
+
+function RelocationOnboarding(props: Props) {
+  const organization = useOrganization();
+
+  const {
+    params: {step: stepId},
+  } = props;
+
+  const onboardingSteps = getOrganizationOnboardingSteps();
+  const stepObj = onboardingSteps.find(({id}) => stepId === id);
+  const stepIndex = onboardingSteps.findIndex(({id}) => stepId === id);
+
+  const cornerVariantTimeoutRed = useRef<number | undefined>(undefined);
+
+  useEffect(() => {
+    return () => {
+      window.clearTimeout(cornerVariantTimeoutRed.current);
+    };
+  }, []);
+
+  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 updateAnimationState = () => {
+    if (!stepObj) {
+      return;
+    }
+  };
+
+  const goToStep = (step: StepDescriptor) => {
+    if (!stepObj) {
+      return;
+    }
+    if (step.cornerVariant !== stepObj.cornerVariant) {
+      cornerVariantControl.start('none');
+    }
+    props.router.push(normalizeUrl(`/relocation/${organization.slug}/${step.id}/`));
+  };
+
+  const goNextStep = useCallback(
+    (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(`/relocation/${organization.slug}/${nextStep.id}/`));
+    },
+    [organization.slug, onboardingSteps, cornerVariantControl, props.router]
+  );
+
+  if (!stepObj || stepIndex === -1) {
+    return (
+      <Redirect
+        to={normalizeUrl(`/relocation/${organization.slug}/${onboardingSteps[0].id}/`)}
+      />
+    );
+  }
+
+  return (
+    <OnboardingWrapper data-test-id="relocation-onboarding">
+      <RelocationOnboardingContextProvider>
+        <SentryDocumentTitle title={stepObj.title} />
+        <Header>
+          <LogoSvg />
+          {stepIndex !== -1 && (
+            <StyledStepper
+              numSteps={onboardingSteps.length}
+              currentStepIndex={stepIndex}
+              onClick={i => {
+                goToStep(onboardingSteps[i]);
+              }}
+            />
+          )}
+        </Header>
+        <Container>
+          <Back
+            onClick={() => goToStep(onboardingSteps[stepIndex - 1])}
+            animate={stepIndex > 0 ? 'visible' : 'hidden'}
+          />
+          <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={() => {
+                    if (stepObj) {
+                      goNextStep(stepObj);
+                    }
+                  }}
+                  route={props.route}
+                  router={props.router}
+                  location={props.location}
+                />
+              )}
+            </OnboardingStep>
+          </AnimatePresence>
+          <AdaptivePageCorners animateVariant={cornerVariantControl} />
+        </Container>
+      </RelocationOnboardingContextProvider>
+    </OnboardingWrapper>
+  );
+}
+
+const Container = styled('div')`
+  flex-grow: 1;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  background: #faf9fb;
+  padding: 120px ${space(3)};
+  width: 100%;
+  margin: 0 auto;
+`;
+
+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 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 OnboardingWrapper = styled('main')`
+  flex-grow: 1;
+  display: flex;
+  flex-direction: column;
+`;
+
+export default RelocationOnboarding;

+ 17 - 0
static/app/views/relocation/types.ts

@@ -0,0 +1,17 @@
+import {RouteComponentProps} from 'react-router';
+
+export type StepProps = Pick<
+  RouteComponentProps<{}, {}>,
+  'router' | 'route' | 'location'
+> & {
+  active: boolean;
+  onComplete: () => void;
+  stepIndex: number;
+};
+
+export type StepDescriptor = {
+  Component: React.ComponentType<StepProps>;
+  cornerVariant: 'top-right' | 'top-left';
+  id: string;
+  title: string;
+};