From 8e7bf7fed4d80abe61ea7aefc32b8464e5dc819e Mon Sep 17 00:00:00 2001
From: Mathieu Suen <mathieu.suen@sonarsource.com>
Date: Tue, 26 Oct 2021 14:09:52 +0200
Subject: SONAR-15567 Add export UI to community edition

---
 server/sonar-web/src/main/js/api/project-dump.ts   |  34 +++
 .../main/js/app/components/nav/component/Menu.tsx  |  11 +
 .../__tests__/__snapshots__/Menu-test.tsx.snap     | 135 ++++++++++-
 .../src/main/js/app/utils/startReactApp.tsx        |   2 +
 .../main/js/apps/projectDump/ProjectDumpApp.tsx    | 200 +++++++++++++++++
 .../projectDump/__tests__/ProjectDumpApp-test.tsx  |  95 ++++++++
 .../__snapshots__/ProjectDumpApp-test.tsx.snap     | 140 ++++++++++++
 .../main/js/apps/projectDump/components/Export.tsx | 188 ++++++++++++++++
 .../main/js/apps/projectDump/components/Import.tsx | 180 +++++++++++++++
 .../components/__tests__/Export-test.tsx           |  62 ++++++
 .../components/__tests__/Import-test.tsx           |  71 ++++++
 .../__tests__/__snapshots__/Export-test.tsx.snap   | 206 +++++++++++++++++
 .../__tests__/__snapshots__/Import-test.tsx.snap   | 246 +++++++++++++++++++++
 .../src/main/js/apps/projectDump/routes.ts         |  28 +++
 .../src/main/js/apps/projectDump/styles.css        |  39 ++++
 server/sonar-web/src/main/js/helpers/testMocks.ts  |  22 ++
 server/sonar-web/src/main/js/types/project-dump.ts |  34 +++
 server/sonar-web/src/main/js/types/tasks.ts        |   4 +-
 server/sonar-web/src/main/js/types/types.d.ts      |   1 +
 19 files changed, 1696 insertions(+), 2 deletions(-)
 create mode 100644 server/sonar-web/src/main/js/api/project-dump.ts
 create mode 100644 server/sonar-web/src/main/js/apps/projectDump/ProjectDumpApp.tsx
 create mode 100644 server/sonar-web/src/main/js/apps/projectDump/__tests__/ProjectDumpApp-test.tsx
 create mode 100644 server/sonar-web/src/main/js/apps/projectDump/__tests__/__snapshots__/ProjectDumpApp-test.tsx.snap
 create mode 100644 server/sonar-web/src/main/js/apps/projectDump/components/Export.tsx
 create mode 100644 server/sonar-web/src/main/js/apps/projectDump/components/Import.tsx
 create mode 100644 server/sonar-web/src/main/js/apps/projectDump/components/__tests__/Export-test.tsx
 create mode 100644 server/sonar-web/src/main/js/apps/projectDump/components/__tests__/Import-test.tsx
 create mode 100644 server/sonar-web/src/main/js/apps/projectDump/components/__tests__/__snapshots__/Export-test.tsx.snap
 create mode 100644 server/sonar-web/src/main/js/apps/projectDump/components/__tests__/__snapshots__/Import-test.tsx.snap
 create mode 100644 server/sonar-web/src/main/js/apps/projectDump/routes.ts
 create mode 100644 server/sonar-web/src/main/js/apps/projectDump/styles.css
 create mode 100644 server/sonar-web/src/main/js/types/project-dump.ts

(limited to 'server')

diff --git a/server/sonar-web/src/main/js/api/project-dump.ts b/server/sonar-web/src/main/js/api/project-dump.ts
new file mode 100644
index 00000000000..9b0d4faaa72
--- /dev/null
+++ b/server/sonar-web/src/main/js/api/project-dump.ts
@@ -0,0 +1,34 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import throwGlobalError from '../app/utils/throwGlobalError';
+import { getJSON, post } from '../helpers/request';
+import { DumpStatus } from '../types/project-dump';
+
+export function getStatus(componentKey: string): Promise<DumpStatus> {
+  return getJSON('/api/project_dump/status', { key: componentKey }).catch(throwGlobalError);
+}
+
+export function doExport(componentKey: string) {
+  return post('/api/project_dump/export', { key: componentKey }).catch(throwGlobalError);
+}
+
+export function doImport(componentKey: string) {
+  return post('/api/project_dump/import', { key: componentKey }).catch(throwGlobalError);
+}
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx b/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx
index b1ac495c980..4130d4b855a 100644
--- a/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx
+++ b/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx
@@ -293,6 +293,7 @@ export class Menu extends React.PureComponent<Props> {
       this.renderConsoleAppLink(query, isApplication),
       this.renderReportSettingsLink(query, isApplication),
       ...this.renderAdminExtensions(query, isApplication),
+      this.renderImportExportLink(query),
       this.renderProfilesLink(query),
       this.renderQualityGateLink(query),
       this.renderLinksLink(query),
@@ -403,6 +404,16 @@ export class Menu extends React.PureComponent<Props> {
     );
   };
 
+  renderImportExportLink = (query: Query) => {
+    return (
+      <li key="import-export">
+        <Link activeClassName="active" to={{ pathname: '/project/import_export', query }}>
+          {translate('project_dump.page')}
+        </Link>
+      </li>
+    );
+  };
+
   renderProfilesLink = (query: Query) => {
     if (!this.getConfiguration().showQualityProfiles) {
       return null;
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/Menu-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/Menu-test.tsx.snap
index a2426279952..79f7a7b1acc 100644
--- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/Menu-test.tsx.snap
+++ b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/Menu-test.tsx.snap
@@ -349,6 +349,24 @@ exports[`should work for a branch 1`] = `
               project_baseline.page
             </Link>
           </li>
+          <li>
+            <Link
+              activeClassName="active"
+              onlyActiveOnIndex={false}
+              style={Object {}}
+              to={
+                Object {
+                  "pathname": "/project/import_export",
+                  "query": Object {
+                    "branch": "release",
+                    "id": "foo",
+                  },
+                }
+              }
+            >
+              project_dump.page
+            </Link>
+          </li>
           <li>
             <Link
               activeClassName="active"
@@ -719,6 +737,23 @@ exports[`should work for all qualifiers 1`] = `
               project_baseline.page
             </Link>
           </li>
+          <li>
+            <Link
+              activeClassName="active"
+              onlyActiveOnIndex={false}
+              style={Object {}}
+              to={
+                Object {
+                  "pathname": "/project/import_export",
+                  "query": Object {
+                    "id": "foo",
+                  },
+                }
+              }
+            >
+              project_dump.page
+            </Link>
+          </li>
           <li>
             <Link
               activeClassName="active"
@@ -867,6 +902,23 @@ exports[`should work for all qualifiers 2`] = `
         <ul
           className="menu"
         >
+          <li>
+            <Link
+              activeClassName="active"
+              onlyActiveOnIndex={false}
+              style={Object {}}
+              to={
+                Object {
+                  "pathname": "/project/import_export",
+                  "query": Object {
+                    "id": "foo",
+                  },
+                }
+              }
+            >
+              project_dump.page
+            </Link>
+          </li>
           <li>
             <Link
               activeClassName="active"
@@ -978,7 +1030,37 @@ exports[`should work for all qualifiers 3`] = `
       />
     </li>
   </NavBarTabs>
-  <NavBarTabs />
+  <NavBarTabs>
+    <Dropdown
+      data-test="administration"
+      overlay={
+        <ul
+          className="menu"
+        >
+          <li>
+            <Link
+              activeClassName="active"
+              onlyActiveOnIndex={false}
+              style={Object {}}
+              to={
+                Object {
+                  "pathname": "/project/import_export",
+                  "query": Object {
+                    "id": "foo",
+                  },
+                }
+              }
+            >
+              project_dump.page
+            </Link>
+          </li>
+        </ul>
+      }
+      tagName="li"
+    >
+      <Component />
+    </Dropdown>
+  </NavBarTabs>
 </div>
 `;
 
@@ -1105,6 +1187,23 @@ exports[`should work for all qualifiers 4`] = `
               application_console.page
             </Link>
           </li>
+          <li>
+            <Link
+              activeClassName="active"
+              onlyActiveOnIndex={false}
+              style={Object {}}
+              to={
+                Object {
+                  "pathname": "/project/import_export",
+                  "query": Object {
+                    "id": "foo",
+                  },
+                }
+              }
+            >
+              project_dump.page
+            </Link>
+          </li>
           <li>
             <Link
               activeClassName="active"
@@ -1502,6 +1601,23 @@ exports[`should work with extensions 2`] = `
           Foo
         </Link>
       </li>
+      <li>
+        <Link
+          activeClassName="active"
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to={
+            Object {
+              "pathname": "/project/import_export",
+              "query": Object {
+                "id": "foo",
+              },
+            }
+          }
+        >
+          project_dump.page
+        </Link>
+      </li>
       <li>
         <Link
           activeClassName="active"
@@ -1689,6 +1805,23 @@ exports[`should work with multiple extensions 2`] = `
           Bar
         </Link>
       </li>
+      <li>
+        <Link
+          activeClassName="active"
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to={
+            Object {
+              "pathname": "/project/import_export",
+              "query": Object {
+                "id": "foo",
+              },
+            }
+          }
+        >
+          project_dump.page
+        </Link>
+      </li>
       <li>
         <Link
           activeClassName="active"
diff --git a/server/sonar-web/src/main/js/app/utils/startReactApp.tsx b/server/sonar-web/src/main/js/app/utils/startReactApp.tsx
index c1c7b4fdc37..6a195e67229 100644
--- a/server/sonar-web/src/main/js/app/utils/startReactApp.tsx
+++ b/server/sonar-web/src/main/js/app/utils/startReactApp.tsx
@@ -47,6 +47,7 @@ import portfolioRoutes from '../../apps/portfolio/routes';
 import projectActivityRoutes from '../../apps/projectActivity/routes';
 import projectBaselineRoutes from '../../apps/projectBaseline/routes';
 import projectBranchesRoutes from '../../apps/projectBranches/routes';
+import projectDumpRoutes from '../../apps/projectDump/routes';
 import projectQualityGateRoutes from '../../apps/projectQualityGate/routes';
 import projectQualityProfilesRoutes from '../../apps/projectQualityProfiles/routes';
 import projectsRoutes from '../../apps/projects/routes';
@@ -206,6 +207,7 @@ function renderComponentRoutes() {
         <RouteWithChildRoutes path="project/background_tasks" childRoutes={backgroundTasksRoutes} />
         <RouteWithChildRoutes path="project/baseline" childRoutes={projectBaselineRoutes} />
         <RouteWithChildRoutes path="project/branches" childRoutes={projectBranchesRoutes} />
+        <RouteWithChildRoutes path="project/import_export" childRoutes={projectDumpRoutes} />
         <RouteWithChildRoutes path="project/settings" childRoutes={settingsRoutes} />
         <RouteWithChildRoutes path="project_roles" childRoutes={projectPermissionsRoutes} />
         <RouteWithChildRoutes path="application/console" childRoutes={applicationConsoleRoutes} />
diff --git a/server/sonar-web/src/main/js/apps/projectDump/ProjectDumpApp.tsx b/server/sonar-web/src/main/js/apps/projectDump/ProjectDumpApp.tsx
new file mode 100644
index 00000000000..8414da52c12
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectDump/ProjectDumpApp.tsx
@@ -0,0 +1,200 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { getActivity } from '../../api/ce';
+import { getStatus } from '../../api/project-dump';
+import throwGlobalError from '../../app/utils/throwGlobalError';
+import { withAppState } from '../../components/hoc/withAppState';
+import { translate } from '../../helpers/l10n';
+import { DumpStatus, DumpTask } from '../../types/project-dump';
+import { TaskStatuses, TaskTypes } from '../../types/tasks';
+import Export from './components/Export';
+import Import from './components/Import';
+import './styles.css';
+
+const POLL_INTERNAL = 5000;
+
+interface Props {
+  appState: Pick<T.AppState, 'projectImportFeatureEnabled'>;
+  component: T.Component;
+}
+
+interface State {
+  lastAnalysisTask?: DumpTask;
+  lastExportTask?: DumpTask;
+  lastImportTask?: DumpTask;
+  status?: DumpStatus;
+}
+
+export class ProjectDumpApp extends React.Component<Props, State> {
+  mounted = false;
+  state: State = {};
+
+  componentDidMount() {
+    this.mounted = true;
+    this.loadStatus();
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (prevProps.component.key !== this.props.component.key) {
+      this.loadStatus();
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  getLastTask(component: string, type: TaskTypes) {
+    const data = {
+      type,
+      component,
+      onlyCurrents: true,
+      status: [
+        TaskStatuses.Pending,
+        TaskStatuses.InProgress,
+        TaskStatuses.Success,
+        TaskStatuses.Failed,
+        TaskStatuses.Canceled
+      ].join(',')
+    };
+    return getActivity(data)
+      .then(({ tasks }) => (tasks.length > 0 ? tasks[0] : undefined), throwGlobalError)
+      .catch(() => undefined);
+  }
+
+  getLastTaskOfEachType(componentKey: string) {
+    const {
+      appState: { projectImportFeatureEnabled }
+    } = this.props;
+
+    const all = projectImportFeatureEnabled
+      ? [
+          this.getLastTask(componentKey, TaskTypes.ProjectExport),
+          this.getLastTask(componentKey, TaskTypes.ProjectImport),
+          this.getLastTask(componentKey, TaskTypes.Report)
+        ]
+      : [
+          this.getLastTask(componentKey, TaskTypes.ProjectExport),
+          Promise.resolve(),
+          this.getLastTask(componentKey, TaskTypes.Report)
+        ];
+    return Promise.all(all).then(([lastExportTask, lastImportTask, lastAnalysisTask]) => ({
+      lastExportTask,
+      lastImportTask,
+      lastAnalysisTask
+    }));
+  }
+
+  loadStatus = () => {
+    const { component } = this.props;
+    return Promise.all([getStatus(component.key), this.getLastTaskOfEachType(component.key)]).then(
+      ([status, { lastExportTask, lastImportTask, lastAnalysisTask }]) => {
+        if (this.mounted) {
+          this.setState({
+            status,
+            lastExportTask,
+            lastImportTask,
+            lastAnalysisTask
+          });
+        }
+        return {
+          status,
+          lastExportTask,
+          lastImportTask,
+          lastAnalysisTask
+        };
+      }
+    );
+  };
+
+  poll = () => {
+    this.loadStatus().then(
+      ({ lastExportTask, lastImportTask }) => {
+        if (this.mounted) {
+          const progressStatus = [TaskStatuses.Pending, TaskStatuses.InProgress];
+          const exportNotFinished =
+            lastExportTask === undefined || progressStatus.includes(lastExportTask.status);
+          const importNotFinished =
+            lastImportTask === undefined || progressStatus.includes(lastImportTask.status);
+          if (exportNotFinished || importNotFinished) {
+            setTimeout(this.poll, POLL_INTERNAL);
+          } else {
+            // Since we fetch status separate from task we could not get an up to date status.
+            // even if we detect that export / import is finish.
+            // Doing a last call will make sur we get the latest status.
+            this.loadStatus();
+          }
+        }
+      },
+      () => {
+        /* no catch needed */
+      }
+    );
+  };
+
+  render() {
+    const {
+      component,
+      appState: { projectImportFeatureEnabled }
+    } = this.props;
+    const { lastAnalysisTask, lastExportTask, lastImportTask, status } = this.state;
+
+    return (
+      <div className="page page-limited" id="project-dump">
+        <header className="page-header">
+          <h1 className="page-title">{translate('project_dump.page')}</h1>
+          <div className="page-description">
+            {projectImportFeatureEnabled
+              ? translate('project_dump.page.description')
+              : translate('project_dump.page.description_without_import')}
+          </div>
+        </header>
+
+        {status === undefined ? (
+          <i className="spinner" />
+        ) : (
+          <div className="columns">
+            <div className="column-half">
+              <Export
+                componentKey={component.key}
+                loadStatus={this.poll}
+                status={status}
+                task={lastExportTask}
+              />
+            </div>
+            <div className="column-half">
+              <Import
+                importEnabled={!!projectImportFeatureEnabled}
+                analysis={lastAnalysisTask}
+                componentKey={component.key}
+                loadStatus={this.poll}
+                status={status}
+                task={lastImportTask}
+              />
+            </div>
+          </div>
+        )}
+      </div>
+    );
+  }
+}
+
+export default withAppState(ProjectDumpApp);
diff --git a/server/sonar-web/src/main/js/apps/projectDump/__tests__/ProjectDumpApp-test.tsx b/server/sonar-web/src/main/js/apps/projectDump/__tests__/ProjectDumpApp-test.tsx
new file mode 100644
index 00000000000..5c034d3231e
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectDump/__tests__/ProjectDumpApp-test.tsx
@@ -0,0 +1,95 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { shallow } from 'enzyme';
+import * as React from 'react';
+import { getActivity } from '../../../api/ce';
+import { getStatus } from '../../../api/project-dump';
+import { mockComponent } from '../../../helpers/mocks/component';
+import { mockAppState, mockDumpStatus, mockDumpTask } from '../../../helpers/testMocks';
+import { waitAndUpdate } from '../../../helpers/testUtils';
+import { TaskStatuses } from '../../../types/tasks';
+import { ProjectDumpApp } from '../ProjectDumpApp';
+
+jest.mock('../../../api/ce', () => ({
+  getActivity: jest.fn().mockResolvedValue({ tasks: [] })
+}));
+
+jest.mock('../../../api/project-dump', () => ({
+  getStatus: jest.fn().mockResolvedValue({})
+}));
+
+beforeEach(() => {
+  jest.clearAllMocks();
+});
+
+it('should render correctly', async () => {
+  (getActivity as jest.Mock)
+    .mockResolvedValueOnce({ tasks: [mockDumpTask()] })
+    .mockResolvedValueOnce({ tasks: [mockDumpTask()] })
+    .mockResolvedValueOnce({ tasks: [mockDumpTask()] });
+
+  let wrapper = shallowRender();
+  expect(wrapper).toMatchSnapshot('loading');
+
+  await waitAndUpdate(wrapper);
+
+  expect(wrapper).toMatchSnapshot('loaded');
+
+  wrapper = shallowRender({ appState: mockAppState({ projectImportFeatureEnabled: false }) });
+  await waitAndUpdate(wrapper);
+  expect(wrapper).toMatchSnapshot('loaded without import');
+});
+
+it('should poll for task status update', async () => {
+  jest.useFakeTimers();
+
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  jest.clearAllMocks();
+
+  const finalStatus = mockDumpStatus({ exportedDump: 'export-path' });
+  (getStatus as jest.Mock)
+    .mockResolvedValueOnce(mockDumpStatus())
+    .mockResolvedValueOnce(finalStatus);
+  (getActivity as jest.Mock)
+    .mockResolvedValueOnce({ tasks: [mockDumpTask({ status: TaskStatuses.Pending })] })
+    .mockResolvedValueOnce({ tasks: [mockDumpTask({ status: TaskStatuses.Success })] });
+
+  wrapper.instance().poll();
+
+  // wait for all promises
+  await waitAndUpdate(wrapper);
+  jest.runAllTimers();
+  await waitAndUpdate(wrapper);
+
+  expect(getStatus).toHaveBeenCalledTimes(2);
+  expect(wrapper.state().status).toBe(finalStatus);
+});
+
+function shallowRender(overrides: Partial<ProjectDumpApp['props']> = {}) {
+  return shallow<ProjectDumpApp>(
+    <ProjectDumpApp
+      appState={mockAppState({ projectImportFeatureEnabled: true })}
+      component={mockComponent()}
+      {...overrides}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectDump/__tests__/__snapshots__/ProjectDumpApp-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectDump/__tests__/__snapshots__/ProjectDumpApp-test.tsx.snap
new file mode 100644
index 00000000000..7282eaac0b9
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectDump/__tests__/__snapshots__/ProjectDumpApp-test.tsx.snap
@@ -0,0 +1,140 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: loaded 1`] = `
+<div
+  className="page page-limited"
+  id="project-dump"
+>
+  <header
+    className="page-header"
+  >
+    <h1
+      className="page-title"
+    >
+      project_dump.page
+    </h1>
+    <div
+      className="page-description"
+    >
+      project_dump.page.description
+    </div>
+  </header>
+  <div
+    className="columns"
+  >
+    <div
+      className="column-half"
+    >
+      <Export
+        componentKey="my-project"
+        loadStatus={[Function]}
+        status={Object {}}
+        task={
+          Object {
+            "executedAt": "2020-03-12T12:22:20Z",
+            "startedAt": "2020-03-12T12:20:20Z",
+            "status": "SUCCESS",
+            "submittedAt": "2020-03-12T12:15:20Z",
+          }
+        }
+      />
+    </div>
+    <div
+      className="column-half"
+    >
+      <Import
+        analysis={
+          Object {
+            "executedAt": "2020-03-12T12:22:20Z",
+            "startedAt": "2020-03-12T12:20:20Z",
+            "status": "SUCCESS",
+            "submittedAt": "2020-03-12T12:15:20Z",
+          }
+        }
+        componentKey="my-project"
+        importEnabled={true}
+        loadStatus={[Function]}
+        status={Object {}}
+        task={
+          Object {
+            "executedAt": "2020-03-12T12:22:20Z",
+            "startedAt": "2020-03-12T12:20:20Z",
+            "status": "SUCCESS",
+            "submittedAt": "2020-03-12T12:15:20Z",
+          }
+        }
+      />
+    </div>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: loaded without import 1`] = `
+<div
+  className="page page-limited"
+  id="project-dump"
+>
+  <header
+    className="page-header"
+  >
+    <h1
+      className="page-title"
+    >
+      project_dump.page
+    </h1>
+    <div
+      className="page-description"
+    >
+      project_dump.page.description_without_import
+    </div>
+  </header>
+  <div
+    className="columns"
+  >
+    <div
+      className="column-half"
+    >
+      <Export
+        componentKey="my-project"
+        loadStatus={[Function]}
+        status={Object {}}
+      />
+    </div>
+    <div
+      className="column-half"
+    >
+      <Import
+        componentKey="my-project"
+        importEnabled={false}
+        loadStatus={[Function]}
+        status={Object {}}
+      />
+    </div>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: loading 1`] = `
+<div
+  className="page page-limited"
+  id="project-dump"
+>
+  <header
+    className="page-header"
+  >
+    <h1
+      className="page-title"
+    >
+      project_dump.page
+    </h1>
+    <div
+      className="page-description"
+    >
+      project_dump.page.description
+    </div>
+  </header>
+  <i
+    className="spinner"
+  />
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectDump/components/Export.tsx b/server/sonar-web/src/main/js/apps/projectDump/components/Export.tsx
new file mode 100644
index 00000000000..6712e7a80d5
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectDump/components/Export.tsx
@@ -0,0 +1,188 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { doExport } from '../../../api/project-dump';
+import { Button } from '../../../components/controls/buttons';
+import DateFromNow from '../../../components/intl/DateFromNow';
+import DateTimeFormatter from '../../../components/intl/DateTimeFormatter';
+import { Alert } from '../../../components/ui/Alert';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { getBaseUrl } from '../../../helpers/system';
+import { DumpStatus, DumpTask } from '../../../types/project-dump';
+
+interface Props {
+  componentKey: string;
+  loadStatus: () => void;
+  status: DumpStatus;
+  task?: DumpTask;
+}
+
+export default class Export extends React.Component<Props> {
+  handleExport = () => {
+    doExport(this.props.componentKey).then(this.props.loadStatus, () => {
+      /* no catch needed */
+    });
+  };
+
+  renderHeader() {
+    return (
+      <div className="boxed-group-header">
+        <h2>{translate('project_dump.export')}</h2>
+      </div>
+    );
+  }
+
+  renderWhenCanNotExport() {
+    return (
+      <div className="boxed-group" id="project-export">
+        {this.renderHeader()}
+        <div className="boxed-group-inner">
+          <Alert id="export-not-possible" variant="warning">
+            {translate('project_dump.can_not_export')}
+          </Alert>
+        </div>
+      </div>
+    );
+  }
+
+  renderWhenExportPending(task: DumpTask) {
+    return (
+      <div className="boxed-group" id="project-export">
+        {this.renderHeader()}
+        <div className="boxed-group-inner" id="export-pending">
+          <i className="spinner spacer-right" />
+          <DateTimeFormatter date={task.submittedAt}>
+            {formatted => (
+              <span>{translateWithParameters('project_dump.pending_export', formatted)}</span>
+            )}
+          </DateTimeFormatter>
+        </div>
+      </div>
+    );
+  }
+
+  renderWhenExportInProgress(task: DumpTask) {
+    return (
+      <div className="boxed-group" id="project-export">
+        {this.renderHeader()}
+
+        <div className="boxed-group-inner" id="export-in-progress">
+          <i className="spinner spacer-right" />
+          {task.startedAt && (
+            <DateFromNow date={task.startedAt}>
+              {fromNow => (
+                <span>{translateWithParameters('project_dump.in_progress_export', fromNow)}</span>
+              )}
+            </DateFromNow>
+          )}
+        </div>
+      </div>
+    );
+  }
+
+  renderWhenExportFailed() {
+    const { componentKey } = this.props;
+    const detailsUrl = `${getBaseUrl()}/project/background_tasks?id=${encodeURIComponent(
+      componentKey
+    )}&status=FAILED&taskType=PROJECT_EXPORT`;
+
+    return (
+      <div className="boxed-group" id="project-export">
+        {this.renderHeader()}
+
+        <div className="boxed-group-inner">
+          <Alert id="export-in-progress" variant="error">
+            {translate('project_dump.failed_export')}
+            <a className="spacer-left" href={detailsUrl}>
+              {translate('project_dump.see_details')}
+            </a>
+          </Alert>
+
+          {this.renderExport()}
+        </div>
+      </div>
+    );
+  }
+
+  renderDump(task?: DumpTask) {
+    const { status } = this.props;
+
+    return (
+      <Alert className="export-dump" variant="success">
+        {task && task.executedAt && (
+          <DateTimeFormatter date={task.executedAt}>
+            {formatted => (
+              <div className="export-dump-message">
+                {translateWithParameters('project_dump.latest_export_available', formatted)}
+              </div>
+            )}
+          </DateTimeFormatter>
+        )}
+        {!task && (
+          <div className="export-dump-message">{translate('project_dump.export_available')}</div>
+        )}
+        <div className="export-dump-path">
+          <code tabIndex={0}>{status.exportedDump}</code>
+        </div>
+      </Alert>
+    );
+  }
+
+  renderExport() {
+    return (
+      <div>
+        <div className="spacer-bottom">{translate('project_dump.export_form_description')}</div>
+        <Button onClick={this.handleExport}>{translate('project_dump.do_export')}</Button>
+      </div>
+    );
+  }
+
+  render() {
+    const { status, task } = this.props;
+
+    if (!status.canBeExported) {
+      return this.renderWhenCanNotExport();
+    }
+
+    if (task && task.status === 'PENDING') {
+      return this.renderWhenExportPending(task);
+    }
+
+    if (task && task.status === 'IN_PROGRESS') {
+      return this.renderWhenExportInProgress(task);
+    }
+
+    if (task && task.status === 'FAILED') {
+      return this.renderWhenExportFailed();
+    }
+
+    const isDumpAvailable = Boolean(status.exportedDump);
+
+    return (
+      <div className="boxed-group" id="project-export">
+        {this.renderHeader()}
+        <div className="boxed-group-inner">
+          {isDumpAvailable && this.renderDump(task)}
+          {this.renderExport()}
+        </div>
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectDump/components/Import.tsx b/server/sonar-web/src/main/js/apps/projectDump/components/Import.tsx
new file mode 100644
index 00000000000..87acd4efea4
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectDump/components/Import.tsx
@@ -0,0 +1,180 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import classNames from 'classnames';
+import { stringify } from 'querystring';
+import * as React from 'react';
+import { doImport } from '../../../api/project-dump';
+import { Button } from '../../../components/controls/buttons';
+import DateFromNow from '../../../components/intl/DateFromNow';
+import DateTimeFormatter from '../../../components/intl/DateTimeFormatter';
+import { Alert } from '../../../components/ui/Alert';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { getBaseUrl } from '../../../helpers/system';
+import { DumpStatus, DumpTask } from '../../../types/project-dump';
+import { TaskStatuses, TaskTypes } from '../../../types/tasks';
+
+interface Props {
+  analysis?: DumpTask;
+  componentKey: string;
+  importEnabled: boolean;
+  loadStatus: () => void;
+  status: DumpStatus;
+  task?: DumpTask;
+}
+
+export default class Import extends React.Component<Props> {
+  handleImport = () => {
+    doImport(this.props.componentKey).then(this.props.loadStatus, () => {
+      /* no catch needed */
+    });
+  };
+
+  renderWhenCanNotImport() {
+    return (
+      <div className="boxed-group-inner" id="import-not-possible">
+        {translate('project_dump.can_not_import')}
+      </div>
+    );
+  }
+
+  renderWhenNoDump() {
+    return (
+      <div className="boxed-group-inner">
+        <Alert id="import-no-file" variant="warning">
+          {translate('project_dump.no_file_to_import')}
+        </Alert>
+      </div>
+    );
+  }
+
+  renderImportForm() {
+    return (
+      <div>
+        <div className="spacer-bottom">{translate('project_dump.import_form_description')}</div>
+        <Button onClick={this.handleImport}>{translate('project_dump.do_import')}</Button>
+      </div>
+    );
+  }
+
+  renderWhenImportSuccess(task: DumpTask) {
+    return (
+      <div className="boxed-group-inner">
+        {task.executedAt && (
+          <DateTimeFormatter date={task.executedAt}>
+            {formatted => (
+              <Alert variant="success">
+                {translateWithParameters('project_dump.import_success', formatted)}
+              </Alert>
+            )}
+          </DateTimeFormatter>
+        )}
+      </div>
+    );
+  }
+
+  renderWhenImportPending(task: DumpTask) {
+    return (
+      <div className="boxed-group-inner" id="import-pending">
+        <i className="spinner spacer-right" />
+        <DateTimeFormatter date={task.submittedAt}>
+          {formatted => (
+            <span>{translateWithParameters('project_dump.pending_import', formatted)}</span>
+          )}
+        </DateTimeFormatter>
+      </div>
+    );
+  }
+
+  renderWhenImportInProgress(task: DumpTask) {
+    return (
+      <div className="boxed-group-inner" id="import-in-progress">
+        <i className="spinner spacer-right" />
+        {task.startedAt && (
+          <DateFromNow date={task.startedAt}>
+            {fromNow => (
+              <span>{translateWithParameters('project_dump.in_progress_import', fromNow)}</span>
+            )}
+          </DateFromNow>
+        )}
+      </div>
+    );
+  }
+
+  renderWhenImportFailed() {
+    const { componentKey } = this.props;
+    const detailsUrl = `${getBaseUrl()}/project/background_tasks?${stringify({
+      id: encodeURIComponent(componentKey),
+      status: TaskStatuses.Failed,
+      taskType: TaskTypes.ProjectImport
+    })}`;
+
+    return (
+      <div className="boxed-group-inner">
+        <Alert id="export-in-progress" variant="error">
+          {translate('project_dump.failed_import')}
+          <a className="spacer-left" href={detailsUrl}>
+            {translate('project_dump.see_details')}
+          </a>
+        </Alert>
+
+        {this.renderImportForm()}
+      </div>
+    );
+  }
+
+  render() {
+    const { importEnabled, status, task, analysis } = this.props;
+
+    let content: React.ReactNode = null;
+    if (task && task.status === TaskStatuses.Success && !analysis) {
+      content = this.renderWhenImportSuccess(task);
+    } else if (task && task.status === TaskStatuses.Pending) {
+      content = this.renderWhenImportPending(task);
+    } else if (task && task.status === TaskStatuses.InProgress) {
+      content = this.renderWhenImportInProgress(task);
+    } else if (task && task.status === TaskStatuses.Failed) {
+      content = this.renderWhenImportFailed();
+    } else if (!status.canBeImported) {
+      content = this.renderWhenCanNotImport();
+    } else if (!status.dumpToImport) {
+      content = this.renderWhenNoDump();
+    } else {
+      content = <div className="boxed-group-inner">{this.renderImportForm()}</div>;
+    }
+    return (
+      <div
+        className={classNames('boxed-group', {
+          'import-disabled text-muted': !importEnabled
+        })}
+        id="project-import">
+        <div className="boxed-group-header">
+          <h2>{translate('project_dump.import')}</h2>
+        </div>
+        {importEnabled ? (
+          content
+        ) : (
+          <div className="boxed-group-inner">
+            {translate('project_dump.import_form_description_disabled')}
+          </div>
+        )}
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectDump/components/__tests__/Export-test.tsx b/server/sonar-web/src/main/js/apps/projectDump/components/__tests__/Export-test.tsx
new file mode 100644
index 00000000000..36bd980955a
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectDump/components/__tests__/Export-test.tsx
@@ -0,0 +1,62 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { shallow } from 'enzyme';
+import * as React from 'react';
+import { mockDumpStatus, mockDumpTask } from '../../../../helpers/testMocks';
+import { TaskStatuses } from '../../../../types/tasks';
+import Export from '../Export';
+
+jest.mock('../../../../api/project-dump', () => ({
+  doExport: jest.fn().mockResolvedValue({})
+}));
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot('no task');
+  expect(shallowRender({ status: mockDumpStatus({ canBeExported: false }) })).toMatchSnapshot(
+    'cannot export'
+  );
+  expect(shallowRender({ task: mockDumpTask({ status: TaskStatuses.Pending }) })).toMatchSnapshot(
+    'task pending'
+  );
+  expect(
+    shallowRender({ task: mockDumpTask({ status: TaskStatuses.InProgress }) })
+  ).toMatchSnapshot('task in progress');
+  expect(shallowRender({ task: mockDumpTask({ status: TaskStatuses.Failed }) })).toMatchSnapshot(
+    'task failed'
+  );
+  expect(
+    shallowRender({
+      status: mockDumpStatus({ exportedDump: 'dump-file' }),
+      task: mockDumpTask({ status: TaskStatuses.Success })
+    })
+  ).toMatchSnapshot('success');
+});
+
+function shallowRender(overrides: Partial<Export['props']> = {}) {
+  return shallow<Export>(
+    <Export
+      componentKey="key"
+      loadStatus={jest.fn()}
+      status={mockDumpStatus()}
+      task={undefined}
+      {...overrides}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectDump/components/__tests__/Import-test.tsx b/server/sonar-web/src/main/js/apps/projectDump/components/__tests__/Import-test.tsx
new file mode 100644
index 00000000000..a9479ace689
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectDump/components/__tests__/Import-test.tsx
@@ -0,0 +1,71 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { shallow } from 'enzyme';
+import * as React from 'react';
+import { mockDumpStatus, mockDumpTask } from '../../../../helpers/testMocks';
+import { TaskStatuses } from '../../../../types/tasks';
+import Import from '../Import';
+
+jest.mock('../../../../api/project-dump', () => ({
+  doImport: jest.fn().mockResolvedValue({})
+}));
+
+it('should render correctly', () => {
+  expect(
+    shallowRender({ status: mockDumpStatus({ dumpToImport: 'import-file.zip' }) })
+  ).toMatchSnapshot('import form');
+  expect(shallowRender()).toMatchSnapshot('no dump to import');
+  expect(shallowRender({ status: mockDumpStatus({ canBeImported: false }) })).toMatchSnapshot(
+    'cannot import'
+  );
+  expect(shallowRender({ task: mockDumpTask({ status: TaskStatuses.Success }) })).toMatchSnapshot(
+    'success'
+  );
+  expect(
+    shallowRender({
+      analysis: mockDumpTask(),
+      task: mockDumpTask({ status: TaskStatuses.Success })
+    })
+  ).toMatchSnapshot('success, but with analysis -> show form');
+  expect(shallowRender({ task: mockDumpTask({ status: TaskStatuses.Pending }) })).toMatchSnapshot(
+    'pending'
+  );
+  expect(
+    shallowRender({ task: mockDumpTask({ status: TaskStatuses.InProgress }) })
+  ).toMatchSnapshot('in progress');
+  expect(shallowRender({ task: mockDumpTask({ status: TaskStatuses.Failed }) })).toMatchSnapshot(
+    'failed'
+  );
+  expect(shallowRender({ importEnabled: false })).toMatchSnapshot('import disabled');
+});
+
+function shallowRender(overrides: Partial<Import['props']> = {}) {
+  return shallow<Import>(
+    <Import
+      importEnabled={true}
+      analysis={undefined}
+      componentKey="key"
+      loadStatus={jest.fn()}
+      status={mockDumpStatus()}
+      task={undefined}
+      {...overrides}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectDump/components/__tests__/__snapshots__/Export-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectDump/components/__tests__/__snapshots__/Export-test.tsx.snap
new file mode 100644
index 00000000000..788257b21d0
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectDump/components/__tests__/__snapshots__/Export-test.tsx.snap
@@ -0,0 +1,206 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: cannot export 1`] = `
+<div
+  className="boxed-group"
+  id="project-export"
+>
+  <div
+    className="boxed-group-header"
+  >
+    <h2>
+      project_dump.export
+    </h2>
+  </div>
+  <div
+    className="boxed-group-inner"
+  >
+    <Alert
+      id="export-not-possible"
+      variant="warning"
+    >
+      project_dump.can_not_export
+    </Alert>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: no task 1`] = `
+<div
+  className="boxed-group"
+  id="project-export"
+>
+  <div
+    className="boxed-group-header"
+  >
+    <h2>
+      project_dump.export
+    </h2>
+  </div>
+  <div
+    className="boxed-group-inner"
+  >
+    <div>
+      <div
+        className="spacer-bottom"
+      >
+        project_dump.export_form_description
+      </div>
+      <Button
+        onClick={[Function]}
+      >
+        project_dump.do_export
+      </Button>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: success 1`] = `
+<div
+  className="boxed-group"
+  id="project-export"
+>
+  <div
+    className="boxed-group-header"
+  >
+    <h2>
+      project_dump.export
+    </h2>
+  </div>
+  <div
+    className="boxed-group-inner"
+  >
+    <Alert
+      className="export-dump"
+      variant="success"
+    >
+      <DateTimeFormatter
+        date="2020-03-12T12:22:20Z"
+      >
+        <Component />
+      </DateTimeFormatter>
+      <div
+        className="export-dump-path"
+      >
+        <code
+          tabIndex={0}
+        >
+          dump-file
+        </code>
+      </div>
+    </Alert>
+    <div>
+      <div
+        className="spacer-bottom"
+      >
+        project_dump.export_form_description
+      </div>
+      <Button
+        onClick={[Function]}
+      >
+        project_dump.do_export
+      </Button>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: task failed 1`] = `
+<div
+  className="boxed-group"
+  id="project-export"
+>
+  <div
+    className="boxed-group-header"
+  >
+    <h2>
+      project_dump.export
+    </h2>
+  </div>
+  <div
+    className="boxed-group-inner"
+  >
+    <Alert
+      id="export-in-progress"
+      variant="error"
+    >
+      project_dump.failed_export
+      <a
+        className="spacer-left"
+        href="/project/background_tasks?id=key&status=FAILED&taskType=PROJECT_EXPORT"
+      >
+        project_dump.see_details
+      </a>
+    </Alert>
+    <div>
+      <div
+        className="spacer-bottom"
+      >
+        project_dump.export_form_description
+      </div>
+      <Button
+        onClick={[Function]}
+      >
+        project_dump.do_export
+      </Button>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: task in progress 1`] = `
+<div
+  className="boxed-group"
+  id="project-export"
+>
+  <div
+    className="boxed-group-header"
+  >
+    <h2>
+      project_dump.export
+    </h2>
+  </div>
+  <div
+    className="boxed-group-inner"
+    id="export-in-progress"
+  >
+    <i
+      className="spinner spacer-right"
+    />
+    <DateFromNow
+      date="2020-03-12T12:20:20Z"
+    >
+      <Component />
+    </DateFromNow>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: task pending 1`] = `
+<div
+  className="boxed-group"
+  id="project-export"
+>
+  <div
+    className="boxed-group-header"
+  >
+    <h2>
+      project_dump.export
+    </h2>
+  </div>
+  <div
+    className="boxed-group-inner"
+    id="export-pending"
+  >
+    <i
+      className="spinner spacer-right"
+    />
+    <DateTimeFormatter
+      date="2020-03-12T12:15:20Z"
+    >
+      <Component />
+    </DateTimeFormatter>
+  </div>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectDump/components/__tests__/__snapshots__/Import-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectDump/components/__tests__/__snapshots__/Import-test.tsx.snap
new file mode 100644
index 00000000000..c5cbbc7c15b
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectDump/components/__tests__/__snapshots__/Import-test.tsx.snap
@@ -0,0 +1,246 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: cannot import 1`] = `
+<div
+  className="boxed-group"
+  id="project-import"
+>
+  <div
+    className="boxed-group-header"
+  >
+    <h2>
+      project_dump.import
+    </h2>
+  </div>
+  <div
+    className="boxed-group-inner"
+    id="import-not-possible"
+  >
+    project_dump.can_not_import
+  </div>
+</div>
+`;
+
+exports[`should render correctly: failed 1`] = `
+<div
+  className="boxed-group"
+  id="project-import"
+>
+  <div
+    className="boxed-group-header"
+  >
+    <h2>
+      project_dump.import
+    </h2>
+  </div>
+  <div
+    className="boxed-group-inner"
+  >
+    <Alert
+      id="export-in-progress"
+      variant="error"
+    >
+      project_dump.failed_import
+      <a
+        className="spacer-left"
+        href="/project/background_tasks?id=key&status=FAILED&taskType=PROJECT_IMPORT"
+      >
+        project_dump.see_details
+      </a>
+    </Alert>
+    <div>
+      <div
+        className="spacer-bottom"
+      >
+        project_dump.import_form_description
+      </div>
+      <Button
+        onClick={[Function]}
+      >
+        project_dump.do_import
+      </Button>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: import disabled 1`] = `
+<div
+  className="boxed-group import-disabled text-muted"
+  id="project-import"
+>
+  <div
+    className="boxed-group-header"
+  >
+    <h2>
+      project_dump.import
+    </h2>
+  </div>
+  <div
+    className="boxed-group-inner"
+  >
+    project_dump.import_form_description_disabled
+  </div>
+</div>
+`;
+
+exports[`should render correctly: import form 1`] = `
+<div
+  className="boxed-group"
+  id="project-import"
+>
+  <div
+    className="boxed-group-header"
+  >
+    <h2>
+      project_dump.import
+    </h2>
+  </div>
+  <div
+    className="boxed-group-inner"
+  >
+    <div>
+      <div
+        className="spacer-bottom"
+      >
+        project_dump.import_form_description
+      </div>
+      <Button
+        onClick={[Function]}
+      >
+        project_dump.do_import
+      </Button>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: in progress 1`] = `
+<div
+  className="boxed-group"
+  id="project-import"
+>
+  <div
+    className="boxed-group-header"
+  >
+    <h2>
+      project_dump.import
+    </h2>
+  </div>
+  <div
+    className="boxed-group-inner"
+    id="import-in-progress"
+  >
+    <i
+      className="spinner spacer-right"
+    />
+    <DateFromNow
+      date="2020-03-12T12:20:20Z"
+    >
+      <Component />
+    </DateFromNow>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: no dump to import 1`] = `
+<div
+  className="boxed-group"
+  id="project-import"
+>
+  <div
+    className="boxed-group-header"
+  >
+    <h2>
+      project_dump.import
+    </h2>
+  </div>
+  <div
+    className="boxed-group-inner"
+  >
+    <Alert
+      id="import-no-file"
+      variant="warning"
+    >
+      project_dump.no_file_to_import
+    </Alert>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: pending 1`] = `
+<div
+  className="boxed-group"
+  id="project-import"
+>
+  <div
+    className="boxed-group-header"
+  >
+    <h2>
+      project_dump.import
+    </h2>
+  </div>
+  <div
+    className="boxed-group-inner"
+    id="import-pending"
+  >
+    <i
+      className="spinner spacer-right"
+    />
+    <DateTimeFormatter
+      date="2020-03-12T12:15:20Z"
+    >
+      <Component />
+    </DateTimeFormatter>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: success 1`] = `
+<div
+  className="boxed-group"
+  id="project-import"
+>
+  <div
+    className="boxed-group-header"
+  >
+    <h2>
+      project_dump.import
+    </h2>
+  </div>
+  <div
+    className="boxed-group-inner"
+  >
+    <DateTimeFormatter
+      date="2020-03-12T12:22:20Z"
+    >
+      <Component />
+    </DateTimeFormatter>
+  </div>
+</div>
+`;
+
+exports[`should render correctly: success, but with analysis -> show form 1`] = `
+<div
+  className="boxed-group"
+  id="project-import"
+>
+  <div
+    className="boxed-group-header"
+  >
+    <h2>
+      project_dump.import
+    </h2>
+  </div>
+  <div
+    className="boxed-group-inner"
+  >
+    <Alert
+      id="import-no-file"
+      variant="warning"
+    >
+      project_dump.no_file_to_import
+    </Alert>
+  </div>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectDump/routes.ts b/server/sonar-web/src/main/js/apps/projectDump/routes.ts
new file mode 100644
index 00000000000..1b02e00004a
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectDump/routes.ts
@@ -0,0 +1,28 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import ProjectDumpApp from './ProjectDumpApp';
+
+const routes = [
+  {
+    indexRoute: { component: ProjectDumpApp }
+  }
+];
+
+export default routes;
diff --git a/server/sonar-web/src/main/js/apps/projectDump/styles.css b/server/sonar-web/src/main/js/apps/projectDump/styles.css
new file mode 100644
index 00000000000..7d33912e7a0
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/projectDump/styles.css
@@ -0,0 +1,39 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+.export-dump {
+  margin-bottom: 20px;
+}
+
+.export-dump-path {
+  padding: 5px 10px 5px 0;
+  overflow: auto;
+  white-space: nowrap;
+}
+
+.project-dump-check {
+  float: right;
+  width: 60px;
+  height: 60px;
+  margin-left: 15px;
+}
+
+#project-dump .import-disabled {
+  background-color: transparent;
+}
diff --git a/server/sonar-web/src/main/js/helpers/testMocks.ts b/server/sonar-web/src/main/js/helpers/testMocks.ts
index 4a8b891a9cd..773815f499d 100644
--- a/server/sonar-web/src/main/js/helpers/testMocks.ts
+++ b/server/sonar-web/src/main/js/helpers/testMocks.ts
@@ -23,6 +23,8 @@ import { InjectedRouter } from 'react-router';
 import { createStore, Store } from 'redux';
 import { DocumentationEntry } from '../apps/documentation/utils';
 import { Exporter, Profile } from '../apps/quality-profiles/types';
+import { DumpStatus, DumpTask } from '../types/project-dump';
+import { TaskStatuses } from '../types/tasks';
 
 export function mockAlmApplication(overrides: Partial<T.AlmApplication> = {}): T.AlmApplication {
   return {
@@ -755,3 +757,23 @@ export function mockPaging(overrides: Partial<T.Paging> = {}): T.Paging {
     ...overrides
   };
 }
+
+export function mockDumpTask(props: Partial<DumpTask> = {}): DumpTask {
+  return {
+    status: TaskStatuses.Success,
+    startedAt: '2020-03-12T12:20:20Z',
+    submittedAt: '2020-03-12T12:15:20Z',
+    executedAt: '2020-03-12T12:22:20Z',
+    ...props
+  };
+}
+
+export function mockDumpStatus(props: Partial<DumpStatus> = {}): DumpStatus {
+  return {
+    canBeExported: true,
+    canBeImported: true,
+    dumpToImport: '',
+    exportedDump: '',
+    ...props
+  };
+}
diff --git a/server/sonar-web/src/main/js/types/project-dump.ts b/server/sonar-web/src/main/js/types/project-dump.ts
new file mode 100644
index 00000000000..26da42f6ba1
--- /dev/null
+++ b/server/sonar-web/src/main/js/types/project-dump.ts
@@ -0,0 +1,34 @@
+import { TaskStatuses } from './tasks';
+
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+export interface DumpStatus {
+  canBeExported: boolean;
+  canBeImported: boolean;
+  dumpToImport: string;
+  exportedDump: string;
+}
+
+export interface DumpTask {
+  executedAt?: string;
+  startedAt?: string;
+  status: TaskStatuses;
+  submittedAt: string;
+}
diff --git a/server/sonar-web/src/main/js/types/tasks.ts b/server/sonar-web/src/main/js/types/tasks.ts
index b23ae39d7f4..886bf8b0bfd 100644
--- a/server/sonar-web/src/main/js/types/tasks.ts
+++ b/server/sonar-web/src/main/js/types/tasks.ts
@@ -21,7 +21,9 @@ export enum TaskTypes {
   Report = 'REPORT',
   IssueSync = 'ISSUE_SYNC',
   AppRefresh = 'APP_REFRESH',
-  ViewRefresh = 'VIEW_REFRESH'
+  ViewRefresh = 'VIEW_REFRESH',
+  ProjectExport = 'PROJECT_EXPORT',
+  ProjectImport = 'PROJECT_IMPORT'
 }
 
 export enum TaskStatuses {
diff --git a/server/sonar-web/src/main/js/types/types.d.ts b/server/sonar-web/src/main/js/types/types.d.ts
index 3b82fbe999d..d350f26b38f 100644
--- a/server/sonar-web/src/main/js/types/types.d.ts
+++ b/server/sonar-web/src/main/js/types/types.d.ts
@@ -93,6 +93,7 @@ declare namespace T {
     canAdmin?: boolean;
     edition: 'community' | 'developer' | 'enterprise' | 'datacenter' | undefined;
     globalPages?: Extension[];
+    projectImportFeatureEnabled?: boolean;
     instanceUsesDefaultAdminCredentials?: boolean;
     multipleAlmEnabled?: boolean;
     needIssueSync?: boolean;
-- 
cgit v1.2.3