Browse Source

feat(relocation): Add in progress relocation screen (#63490)

This screen informs a user that their active relocation is in progress.
If a user lands on a `/relocation[/...]` URL, we hit the new
own-relocations GET endpoint for each available region to see if the
user has any active relocations in any regions. If they do, we show this
screen. If they do not, we show the regular flow, with this screen as
the final view once their relocation has been submitted.

![Screenshot 2024-01-18 at 18 32
31](https://github.com/getsentry/sentry/assets/3709945/51b3586a-47c0-4ce1-84fd-74ce92f30b14)
Alex Zaslavsky 1 year ago
parent
commit
4d6f3b5cbd

+ 1 - 1
static/app/views/relocation/encryptBackup.tsx

@@ -16,7 +16,7 @@ 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>
+    <Wrapper data-test-id="encrypt-backup">
       <StepHeading step={3}>
         {t('Create an encrypted backup of your current self-hosted instance')}
       </StepHeading>

+ 1 - 1
static/app/views/relocation/getStarted.tsx

@@ -29,7 +29,7 @@ function GetStarted(props: StepProps) {
     props.onComplete();
   };
   return (
-    <Wrapper>
+    <Wrapper data-test-id="get-started">
       <StepHeading step={1}>{t('Basic information needed to get started')}</StepHeading>
       <motion.div
         transition={testableTransition()}

+ 37 - 0
static/app/views/relocation/inProgress.tsx

@@ -0,0 +1,37 @@
+import {motion} from 'framer-motion';
+
+import {t} from 'sentry/locale';
+import ConfigStore from 'sentry/stores/configStore';
+import testableTransition from 'sentry/utils/testableTransition';
+import Wrapper from 'sentry/views/relocation/components/wrapper';
+
+import {StepProps} from './types';
+
+export function InProgress(props: StepProps) {
+  const userIdentity = ConfigStore.get('userIdentity');
+
+  return (
+    <Wrapper data-test-id="in-progress">
+      <motion.h2>{t('Your relocation is under way!')}</motion.h2>
+      <motion.div
+        transition={testableTransition()}
+        variants={{
+          initial: {y: 30, opacity: 0},
+          animate: {y: 0, opacity: 1},
+          exit: {opacity: 0},
+        }}
+      >
+        <p>
+          {`Your relocation is currently being processed - we\'ll email the latest updates to ${userIdentity.email}. If you don't hear back from us in 24 hours, please `}
+          <a href="https://help.sentry.io">contact support</a>.
+        </p>
+        <hr />
+        <p>
+          UUID: <i>{props.existingRelocationUUID}</i>
+        </p>
+      </motion.div>
+    </Wrapper>
+  );
+}
+
+export default InProgress;

+ 1 - 1
static/app/views/relocation/publicKey.tsx

@@ -61,7 +61,7 @@ export function PublicKey({publicKey, onComplete}: StepProps) {
   );
 
   return (
-    <Wrapper>
+    <Wrapper data-test-id="public-key">
       <StepHeading step={2}>{t("Save Sentry's public key to your machine")}</StepHeading>
       {publicKey ? loaded : unloaded}
     </Wrapper>

+ 204 - 41
static/app/views/relocation/relocation.spec.tsx

@@ -1,3 +1,5 @@
+import {browserHistory} from 'react-router';
+
 import {initializeOrg} from 'sentry-test/initializeOrg';
 import {
   fireEvent,
@@ -21,13 +23,20 @@ eeJa0qflcHHQzxK4/EKKpl/hkt4zi0aE/PuJgvJz2KB+X3+LzekTy90LzW3VhR4y
 IAxCAaGQJVsg9dhKOORjAf4XK9aXHvy/jUSyT43opj6AgNqXlKEQjb1NBA8qbJJS
 8wIDAQAB
 -----END PUBLIC KEY-----`;
+const fakeRegionName = 'Narnia';
+const fakeRegionUrl = 'https://example.com';
 
 describe('Relocation', function () {
+  let fetchExistingRelocation: jest.Mock;
   let fetchPublicKey: jest.Mock;
 
   beforeEach(function () {
     MockApiClient.asyncDelay = undefined;
     MockApiClient.clearMockResponses();
+    fetchExistingRelocation = MockApiClient.addMockResponse({
+      url: '/relocations/',
+      body: [],
+    });
     fetchPublicKey = MockApiClient.addMockResponse({
       url: '/publickeys/relocations/',
       body: {
@@ -44,7 +53,6 @@ describe('Relocation', function () {
   });
 
   afterEach(function () {
-    // console.error = consoleError;
     MockApiClient.clearMockResponses();
     MockApiClient.asyncDelay = undefined;
   });
@@ -66,9 +74,19 @@ describe('Relocation', function () {
     });
   }
 
+  async function waitForRenderSuccess(step) {
+    renderPage(step);
+    await waitFor(() => expect(screen.getByTestId(step)).toBeInTheDocument());
+  }
+
+  async function waitForRenderError(step) {
+    renderPage(step);
+    await waitFor(() => expect(screen.getByTestId('loading-error')).toBeInTheDocument());
+  }
+
   describe('Get Started', function () {
     it('renders', async function () {
-      renderPage('get-started');
+      await waitForRenderSuccess('get-started');
       await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
 
       expect(
@@ -80,31 +98,93 @@ describe('Relocation', function () {
       expect(await screen.findByText('Choose a datacenter region')).toBeInTheDocument();
     });
 
+    it('redirects to `in-progress` page if user already has active relocation', async function () {
+      MockApiClient.clearMockResponses();
+      fetchExistingRelocation = MockApiClient.addMockResponse({
+        url: '/relocations/',
+        body: [
+          {
+            uuid: 'ccef828a-03d8-4dd0-918a-487ffecf8717',
+            status: 'IN_PROGRESS',
+          },
+        ],
+      });
+      fetchPublicKey = MockApiClient.addMockResponse({
+        url: '/publickeys/relocations/',
+        body: {
+          public_key: fakePublicKey,
+        },
+      });
+
+      await waitForRenderSuccess('get-started');
+      await waitFor(() => expect(fetchExistingRelocation).toHaveBeenCalled());
+      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+
+      expect(browserHistory.push).toHaveBeenCalledWith('/relocation/in-progress/');
+    });
+
     it('should prevent user from going to the next step if no org slugs or region are entered', async function () {
-      renderPage('get-started');
+      await waitForRenderSuccess('get-started');
       await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
 
       expect(await 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');
+      await waitForRenderSuccess('get-started');
       await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
 
-      ConfigStore.set('relocationConfig', {selectableRegions: ['USA']});
-      ConfigStore.set('regions', [{name: 'USA', url: 'https://example.com'}]);
+      ConfigStore.set('regions', [{name: fakeRegionName, url: fakeRegionUrl}]);
+      ConfigStore.set('relocationConfig', {selectableRegions: [fakeRegionName]});
       const orgSlugsInput = await screen.getByLabelText('org-slugs');
       const continueButton = await screen.getByRole('button', {name: 'Continue'});
       await userEvent.type(orgSlugsInput, 'test-org');
-      await userEvent.type(await screen.getByLabelText('region'), 'U');
+      await userEvent.type(await screen.getByLabelText('region'), 'Narnia');
       await userEvent.click(await screen.getByRole('menuitemradio'));
       expect(continueButton).toBeEnabled();
     });
+
+    it('should show loading indicator and error message if existing relocation retrieval failed', async function () {
+      MockApiClient.clearMockResponses();
+      fetchExistingRelocation = MockApiClient.addMockResponse({
+        url: '/relocations/',
+        statusCode: 400,
+      });
+      fetchPublicKey = MockApiClient.addMockResponse({
+        url: '/publickeys/relocations/',
+        body: {
+          public_key: fakePublicKey,
+        },
+      });
+
+      await waitForRenderError('get-started');
+      await waitFor(() => expect(fetchExistingRelocation).toHaveBeenCalled());
+
+      expect(fetchPublicKey).toHaveBeenCalledTimes(1);
+      expect(
+        await screen.queryByRole('button', {name: 'Continue'})
+      ).not.toBeInTheDocument();
+      expect(await screen.queryByLabelText('org-slugs')).not.toBeInTheDocument();
+      expect(await screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument();
+
+      MockApiClient.addMockResponse({
+        url: '/relocations/',
+        body: [],
+      });
+
+      await userEvent.click(screen.getByRole('button', {name: 'Retry'}));
+      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+      await waitFor(() => expect(screen.getByTestId('get-started')).toBeInTheDocument());
+
+      expect(fetchExistingRelocation).toHaveBeenCalledTimes(1);
+      expect(await screen.queryByLabelText('org-slugs')).toBeInTheDocument();
+      expect(await screen.queryByRole('button', {name: 'Continue'})).toBeInTheDocument();
+    });
   });
 
   describe('Public Key', function () {
     it('should show instructions if key retrieval was successful', async function () {
-      renderPage('public-key');
+      await waitForRenderSuccess('public-key');
       await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
 
       expect(
@@ -125,14 +205,19 @@ describe('Relocation', function () {
 
     it('should show loading indicator and error message if key retrieval failed', async function () {
       MockApiClient.clearMockResponses();
+      fetchExistingRelocation = MockApiClient.addMockResponse({
+        url: '/relocations/',
+        body: [],
+      });
       fetchPublicKey = MockApiClient.addMockResponse({
         url: '/publickeys/relocations/',
         statusCode: 400,
       });
 
-      renderPage('public-key');
+      await waitForRenderError('public-key');
       await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
 
+      expect(fetchExistingRelocation).toHaveBeenCalledTimes(1);
       expect(
         await screen.queryByRole('button', {name: 'Continue'})
       ).not.toBeInTheDocument();
@@ -148,7 +233,9 @@ describe('Relocation', function () {
 
       await userEvent.click(screen.getByRole('button', {name: 'Retry'}));
       await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+      await waitFor(() => expect(screen.getByTestId('public-key')).toBeInTheDocument());
 
+      expect(fetchExistingRelocation).toHaveBeenCalledTimes(1);
       expect(await screen.queryByText('key.pub')).toBeInTheDocument();
       expect(await screen.queryByRole('button', {name: 'Continue'})).toBeInTheDocument();
     });
@@ -156,7 +243,7 @@ describe('Relocation', function () {
 
   describe('Encrypt Backup', function () {
     it('renders', async function () {
-      renderPage('encrypt-backup');
+      await waitForRenderSuccess('encrypt-backup');
       await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
 
       expect(
@@ -169,14 +256,14 @@ describe('Relocation', function () {
 
   describe('Upload Backup', function () {
     it('renders', async function () {
-      renderPage('upload-backup');
+      await waitForRenderSuccess('upload-backup');
       expect(
         await screen.findByText('Upload Tarball to begin the relocation process')
       ).toBeInTheDocument();
     });
 
     it('accepts a file upload', async function () {
-      renderPage('upload-backup');
+      await waitForRenderSuccess('upload-backup');
       const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
       const input = screen.getByLabelText('file-upload');
       await userEvent.upload(input, relocationFile);
@@ -185,7 +272,7 @@ describe('Relocation', function () {
     });
 
     it('accepts a file upload through drag and drop', async function () {
-      renderPage('upload-backup');
+      await waitForRenderSuccess('upload-backup');
       const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
       const dropzone = screen.getByLabelText('dropzone');
       fireEvent.drop(dropzone, {dataTransfer: {files: [relocationFile]}});
@@ -194,7 +281,7 @@ describe('Relocation', function () {
     });
 
     it('correctly removes file and prompts for file upload', async function () {
-      renderPage('upload-backup');
+      await waitForRenderSuccess('upload-backup');
       const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
       const input = screen.getByLabelText('file-upload');
       await userEvent.upload(input, relocationFile);
@@ -210,7 +297,7 @@ describe('Relocation', function () {
         url: `/relocations/`,
         method: 'POST',
       });
-      renderPage('upload-backup');
+      await waitForRenderSuccess('upload-backup');
       const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
       const input = screen.getByLabelText('file-upload');
       await userEvent.upload(input, relocationFile);
@@ -225,17 +312,25 @@ describe('Relocation', function () {
       const mockapi = MockApiClient.addMockResponse({
         url: `/relocations/`,
         method: 'POST',
+        responseJSON: [
+          {
+            uuid: 'ccef828a-03d8-4dd0-918a-487ffecf8717',
+            status: 'IN_PROGRESS',
+          },
+        ],
       });
-      renderPage('get-started');
-      ConfigStore.set('relocationConfig', {selectableRegions: ['USA']});
-      ConfigStore.set('regions', [{name: 'USA', url: 'https://example.com'}]);
+
+      await waitForRenderSuccess('get-started');
+      ConfigStore.set('regions', [{name: fakeRegionName, url: fakeRegionUrl}]);
+      ConfigStore.set('relocationConfig', {selectableRegions: [fakeRegionName]});
       const orgSlugsInput = await screen.getByLabelText('org-slugs');
       const continueButton = await screen.getByRole('button', {name: 'Continue'});
       await userEvent.type(orgSlugsInput, 'test-org');
-      await userEvent.type(screen.getByLabelText('region'), 'U');
+      await userEvent.type(screen.getByLabelText('region'), 'Narnia');
       await userEvent.click(screen.getByRole('menuitemradio'));
       await userEvent.click(continueButton);
-      renderPage('upload-backup');
+
+      await waitForRenderSuccess('upload-backup');
       const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
       const input = screen.getByLabelText('file-upload');
       await userEvent.upload(input, relocationFile);
@@ -243,12 +338,14 @@ describe('Relocation', function () {
       await waitFor(() =>
         expect(mockapi).toHaveBeenCalledWith(
           '/relocations/',
-          expect.objectContaining({host: 'https://example.com', method: 'POST'})
+          expect.objectContaining({host: fakeRegionUrl, method: 'POST'})
         )
       );
       expect(addSuccessMessage).toHaveBeenCalledWith(
         "Your relocation has started - we'll email you with updates as soon as we have 'em!"
       );
+
+      await waitForRenderSuccess('in-progress');
     });
 
     it('throws error if user already has an in-progress relocation job', async function () {
@@ -257,16 +354,18 @@ describe('Relocation', function () {
         method: 'POST',
         statusCode: 409,
       });
-      renderPage('get-started');
-      ConfigStore.set('relocationConfig', {selectableRegions: ['USA']});
-      ConfigStore.set('regions', [{name: 'USA', url: 'https://example.com'}]);
+
+      await waitForRenderSuccess('get-started');
+      ConfigStore.set('regions', [{name: fakeRegionName, url: fakeRegionUrl}]);
+      ConfigStore.set('relocationConfig', {selectableRegions: [fakeRegionName]});
       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.type(screen.getByLabelText('region'), 'Narnia');
       await userEvent.click(screen.getByRole('menuitemradio'));
       await userEvent.click(continueButton);
-      renderPage('upload-backup');
+
+      await waitForRenderSuccess('upload-backup');
       const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
       const input = screen.getByLabelText('file-upload');
       await userEvent.upload(input, relocationFile);
@@ -283,16 +382,17 @@ describe('Relocation', function () {
         method: 'POST',
         statusCode: 429,
       });
-      renderPage('get-started');
-      ConfigStore.set('relocationConfig', {selectableRegions: ['USA']});
-      ConfigStore.set('regions', [{name: 'USA', url: 'https://example.com'}]);
+
+      await waitForRenderSuccess('get-started');
+      ConfigStore.set('regions', [{name: fakeRegionName, url: fakeRegionUrl}]);
+      ConfigStore.set('relocationConfig', {selectableRegions: [fakeRegionName]});
       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.type(screen.getByLabelText('region'), 'Narnia');
       await userEvent.click(screen.getByRole('menuitemradio'));
       await userEvent.click(continueButton);
-      renderPage('upload-backup');
+      await waitForRenderSuccess('upload-backup');
       const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
       const input = screen.getByLabelText('file-upload');
       await userEvent.upload(input, relocationFile);
@@ -309,16 +409,18 @@ describe('Relocation', function () {
         method: 'POST',
         statusCode: 401,
       });
-      renderPage('get-started');
-      ConfigStore.set('relocationConfig', {selectableRegions: ['USA']});
-      ConfigStore.set('regions', [{name: 'USA', url: 'https://example.com'}]);
+      ConfigStore.set('regions', [{name: fakeRegionName, url: fakeRegionUrl}]);
+      ConfigStore.set('relocationConfig', {selectableRegions: [fakeRegionName]});
+
+      await waitForRenderSuccess('get-started');
       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.type(screen.getByLabelText('region'), 'Narnia');
       await userEvent.click(screen.getByRole('menuitemradio'));
       await userEvent.click(continueButton);
-      renderPage('upload-backup');
+
+      await waitForRenderSuccess('upload-backup');
       const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
       const input = screen.getByLabelText('file-upload');
       await userEvent.upload(input, relocationFile);
@@ -333,16 +435,18 @@ describe('Relocation', function () {
         method: 'POST',
         statusCode: 500,
       });
-      renderPage('get-started');
-      ConfigStore.set('relocationConfig', {selectableRegions: ['USA']});
-      ConfigStore.set('regions', [{name: 'USA', url: 'https://example.com'}]);
+      ConfigStore.set('regions', [{name: fakeRegionName, url: fakeRegionUrl}]);
+      ConfigStore.set('relocationConfig', {selectableRegions: [fakeRegionName]});
+
+      await waitForRenderSuccess('get-started');
       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.type(screen.getByLabelText('region'), 'Narnia');
       await userEvent.click(screen.getByRole('menuitemradio'));
       await userEvent.click(continueButton);
-      renderPage('upload-backup');
+
+      await waitForRenderSuccess('upload-backup');
       const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
       const input = screen.getByLabelText('file-upload');
       await userEvent.upload(input, relocationFile);
@@ -353,4 +457,63 @@ describe('Relocation', function () {
       );
     });
   });
+
+  describe('In Progress', function () {
+    it('renders', async function () {
+      MockApiClient.clearMockResponses();
+      fetchExistingRelocation = MockApiClient.addMockResponse({
+        url: '/relocations/',
+        body: [
+          {
+            uuid: 'ccef828a-03d8-4dd0-918a-487ffecf8717',
+            status: 'IN_PROGRESS',
+          },
+        ],
+      });
+      fetchPublicKey = MockApiClient.addMockResponse({
+        url: '/publickeys/relocations/',
+        body: {
+          public_key: fakePublicKey,
+        },
+      });
+
+      await waitForRenderSuccess('in-progress');
+      expect(
+        await screen.findByText('Your relocation is under way!')
+      ).toBeInTheDocument();
+    });
+
+    it('redirects to `get-started` page if there is no existing relocation', async function () {
+      await waitForRenderSuccess('in-progress');
+      await waitFor(() => expect(fetchExistingRelocation).toHaveBeenCalled());
+      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+
+      expect(browserHistory.push).toHaveBeenCalledWith('/relocation/get-started/');
+    });
+
+    it('redirects to `get-started` page if there is no active relocation', async function () {
+      MockApiClient.clearMockResponses();
+      fetchExistingRelocation = MockApiClient.addMockResponse({
+        url: '/relocations/',
+        body: [
+          {
+            uuid: 'ccef828a-03d8-4dd0-918a-487ffecf8717',
+            status: 'SUCCESS',
+          },
+        ],
+      });
+      fetchPublicKey = MockApiClient.addMockResponse({
+        url: '/publickeys/relocations/',
+        body: {
+          public_key: fakePublicKey,
+        },
+      });
+
+      await waitForRenderSuccess('in-progress');
+      await waitFor(() => expect(fetchExistingRelocation).toHaveBeenCalled());
+      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+
+      expect(browserHistory.push).toHaveBeenCalledWith('/relocation/get-started/');
+    });
+  });
 });

+ 157 - 62
static/app/views/relocation/relocation.tsx

@@ -1,14 +1,16 @@
 import {useCallback, useEffect, useRef, useState} from 'react';
-import {RouteComponentProps} from 'react-router';
+import {browserHistory, 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 LoadingError from 'sentry/components/loadingError';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
 import LogoSentry from 'sentry/components/logoSentry';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {IconArrow} from 'sentry/icons';
 import {t} from 'sentry/locale';
+import ConfigStore from 'sentry/stores/configStore';
 import {space} from 'sentry/styles/space';
 import Redirect from 'sentry/utils/redirect';
 import testableTransition from 'sentry/utils/testableTransition';
@@ -20,6 +22,7 @@ import {RelocationOnboardingContextProvider} from 'sentry/views/relocation/reloc
 
 import EncryptBackup from './encryptBackup';
 import GetStarted from './getStarted';
+import InProgress from './inProgress';
 import PublicKey from './publicKey';
 import {StepDescriptor} from './types';
 import UploadBackup from './uploadBackup';
@@ -56,49 +59,103 @@ function getRelocationOnboardingSteps(): StepDescriptor[] {
       Component: UploadBackup,
       cornerVariant: 'top-left',
     },
+    {
+      id: 'in-progress',
+      title: t('Your relocation is in progress'),
+      Component: InProgress,
+      cornerVariant: 'top-left',
+    },
   ];
 }
 
+enum LoadingState {
+  FETCHED,
+  FETCHING,
+  ERROR,
+}
+
 function RelocationOnboarding(props: Props) {
-  const [hasPublicKeyError, setHasError] = useState(false);
+  const {
+    params: {step: stepId},
+  } = props;
+  const onboardingSteps = getRelocationOnboardingSteps();
+  const stepObj = onboardingSteps.find(({id}) => stepId === id);
+  const stepIndex = onboardingSteps.findIndex(({id}) => stepId === id);
+  const api = useApi();
+  const regions = ConfigStore.get('regions');
+
+  const [existingRelocationState, setExistingRelocationState] = useState(
+    LoadingState.FETCHING
+  );
+  const [existingRelocation, setExistingRelocation] = useState('');
 
   // TODO(getsentry/team-ospo#214): We should use sessionStorage to track this, since it should not
   // change during a single run through this workflow.
   const [publicKey, setPublicKey] = useState('');
+  const [publicKeyState, setPublicKeyState] = useState(LoadingState.FETCHING);
+
+  const fetchExistingRelocation = useCallback(() => {
+    setExistingRelocationState(LoadingState.FETCHING);
+    return Promise.all(
+      regions.map(region =>
+        api.requestPromise(`/relocations/`, {
+          method: 'GET',
+          host: region.url,
+        })
+      )
+    )
+      .then(responses => {
+        const response = responses.flat(1);
+        response.sort((a, b) => {
+          return (
+            new Date(a.dateAdded || 0).getMilliseconds() -
+            new Date(b.dateAdded || 0).getMilliseconds()
+          );
+        });
+        const existingRelocationUUID =
+          response.find(
+            candidate =>
+              candidate.status === 'IN_PROGRESS' || candidate.status === 'PAUSE'
+          )?.uuid || '';
+
+        setExistingRelocation(existingRelocationUUID);
+        setExistingRelocationState(LoadingState.FETCHED);
+        if (existingRelocationUUID !== '' && stepId !== 'in-progress') {
+          browserHistory.push('/relocation/in-progress/');
+        }
+        if (existingRelocationUUID === '' && stepId === 'in-progress') {
+          browserHistory.push('/relocation/get-started/');
+        }
+      })
+      .catch(_error => {
+        setExistingRelocation('');
+        setExistingRelocationState(LoadingState.ERROR);
+      });
+  }, [api, regions, stepId]);
+  useEffect(() => {
+    fetchExistingRelocation();
+  }, [fetchExistingRelocation]);
 
-  const api = useApi();
-  const fetchData = useCallback(() => {
+  const fetchPublicKey = useCallback(() => {
     const endpoint = `/publickeys/relocations/`;
+    setPublicKeyState(LoadingState.FETCHING);
+
     return api
       .requestPromise(endpoint)
       .then(response => {
         setPublicKey(response.public_key);
-        setHasError(false);
+        setPublicKeyState(LoadingState.FETCHED);
       })
       .catch(_error => {
         setPublicKey('');
-        setHasError(true);
+        setPublicKeyState(LoadingState.ERROR);
       });
   }, [api]);
-
   useEffect(() => {
-    fetchData();
-  }, [fetchData]);
-
-  const loadingError = (
-    <LoadingError message={t('Failed to load your public key.')} onRetry={fetchData} />
-  );
-
-  const {
-    params: {step: stepId},
-  } = props;
-
-  const onboardingSteps = getRelocationOnboardingSteps();
-  const stepObj = onboardingSteps.find(({id}) => stepId === id);
-  const stepIndex = onboardingSteps.findIndex(({id}) => stepId === id);
+    fetchPublicKey();
+  }, [fetchPublicKey]);
 
   const cornerVariantTimeoutRed = useRef<number | undefined>(undefined);
-
   useEffect(() => {
     return () => {
       window.clearTimeout(cornerVariantTimeoutRed.current);
@@ -153,52 +210,90 @@ function RelocationOnboarding(props: Props) {
     return <Redirect to={normalizeUrl(`/relocation/${onboardingSteps[0].id}/`)} />;
   }
 
+  const headerView =
+    stepId === 'in-progress' ? null : (
+      <Header>
+        <LogoSvg />
+        {stepIndex !== -1 && (
+          <StyledStepper
+            numSteps={onboardingSteps.length}
+            currentStepIndex={stepIndex}
+            onClick={i => {
+              goToStep(onboardingSteps[i]);
+            }}
+          />
+        )}
+      </Header>
+    );
+
+  const backButtonView =
+    stepId === 'in-progress' ? null : (
+      <Back
+        onClick={() => goToStep(onboardingSteps[stepIndex - 1])}
+        animate={stepIndex > 0 ? 'visible' : 'hidden'}
+      />
+    );
+
+  const isLoading =
+    existingRelocationState !== LoadingState.FETCHED ||
+    publicKeyState !== LoadingState.FETCHED;
+  const contentView = isLoading ? (
+    <LoadingIndicator />
+  ) : (
+    <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}`}
+            existingRelocationUUID={existingRelocation}
+            stepIndex={stepIndex}
+            onComplete={(uuid?) => {
+              if (uuid) {
+                setExistingRelocation(uuid);
+              }
+              if (stepObj) {
+                goNextStep(stepObj);
+              }
+            }}
+            publicKey={publicKey}
+            route={props.route}
+            router={props.router}
+            location={props.location}
+          />
+        )}
+      </OnboardingStep>
+    </AnimatePresence>
+  );
+
+  const hasErr =
+    existingRelocationState === LoadingState.ERROR ||
+    publicKeyState === LoadingState.ERROR;
+  const errView = hasErr ? (
+    <LoadingError
+      data-test-id="loading-error"
+      message={t('Failed to load information from server - check your connection?')}
+      onRetry={() => {
+        if (existingRelocationState) {
+          fetchExistingRelocation();
+        }
+        if (publicKeyState) {
+          fetchPublicKey();
+        }
+      }}
+    />
+  ) : null;
+
   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>
+        {headerView}
         <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);
-                    }
-                  }}
-                  publicKey={publicKey}
-                  route={props.route}
-                  router={props.router}
-                  location={props.location}
-                />
-              )}
-            </OnboardingStep>
-          </AnimatePresence>
+          {backButtonView}
+          {contentView}
           <AdaptivePageCorners animateVariant={cornerVariantControl} />
-          {stepObj.id === 'public-key' && hasPublicKeyError ? loadingError : null}
+          {errView}
         </Container>
       </RelocationOnboardingContextProvider>
     </OnboardingWrapper>

+ 2 - 1
static/app/views/relocation/types.ts

@@ -5,7 +5,8 @@ export type StepProps = Pick<
   'router' | 'route' | 'location'
 > & {
   active: boolean;
-  onComplete: () => void;
+  existingRelocationUUID: string;
+  onComplete: (uuid?: string) => void;
   publicKey: string;
   stepIndex: number;
 };

+ 4 - 3
static/app/views/relocation/uploadBackup.tsx

@@ -37,7 +37,7 @@ const THROTTLED_RELOCATION_ERROR_MSG = t(
 );
 const SESSION_EXPIRED_ERROR_MSG = t('Your session has expired.');
 
-export function UploadBackup(__props: StepProps) {
+export function UploadBackup({onComplete}: StepProps) {
   const api = useApi({
     api: new Client({headers: {Accept: 'application/json; charset=utf-8'}}),
   });
@@ -89,7 +89,7 @@ export function UploadBackup(__props: StepProps) {
     formData.set('file', file);
     formData.set('owner', user.username);
     try {
-      await api.requestPromise(`/relocations/`, {
+      const result = await api.requestPromise(`/relocations/`, {
         method: 'POST',
         host: regionUrl,
         data: formData,
@@ -100,6 +100,7 @@ export function UploadBackup(__props: StepProps) {
           "Your relocation has started - we'll email you with updates as soon as we have 'em!"
         )
       );
+      onComplete(result.uuid);
     } catch (error) {
       if (error.status === 409) {
         addErrorMessage(IN_PROGRESS_RELOCATION_ERROR_MSG);
@@ -114,7 +115,7 @@ export function UploadBackup(__props: StepProps) {
   };
 
   return (
-    <Wrapper>
+    <Wrapper data-test-id="upload-backup">
       <StepHeading step={4}>
         {t('Upload Tarball to begin the relocation process')}
       </StepHeading>