diff --git a/app/assets/javascripts/vue_shared/components/import/import_history_table.stories.js b/app/assets/javascripts/vue_shared/components/import/import_history_table.stories.js
new file mode 100644
index 0000000000000000000000000000000000000000..b29865b3fd991b8780055e587049ccb1e168c18a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/import/import_history_table.stories.js
@@ -0,0 +1,21 @@
+import { basic } from 'jest/vue_shared/components/import/history_mock_data';
+import ImportHistoryTable from './import_history_table.vue';
+
+export default {
+  component: ImportHistoryTable,
+  title: 'vue_shared/import/import_history_table',
+};
+
+const defaultProps = basic;
+
+const Template = (args, { argTypes }) => ({
+  components: { ImportHistoryTable },
+  data() {
+    return {};
+  },
+  props: Object.keys(argTypes),
+  template: `<import-history-table v-bind="$props" />`,
+});
+
+export const Default = Template.bind({});
+Default.args = defaultProps;
diff --git a/app/assets/javascripts/vue_shared/components/import/import_history_table.vue b/app/assets/javascripts/vue_shared/components/import/import_history_table.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b5ce549f31c8a37c16bb4fa29ea55ec74db1b6e6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/import/import_history_table.vue
@@ -0,0 +1,163 @@
+<script>
+import { GlAvatarLabeled } from '@gitlab/ui';
+import TimeAgoTooltip from '../time_ago_tooltip.vue';
+
+import ImportHistoryTableHeader from './import_history_table_header.vue';
+import ImportHistoryTableRow from './import_history_table_row.vue';
+import ImportHistoryTableSource from './import_history_table_source.vue';
+import ImportHistoryTableRowDestination from './import_history_table_row_destination.vue';
+import ImportHistoryTableRowStats from './import_history_table_row_stats.vue';
+import ImportHistoryTableRowErrors from './import_history_table_row_errors.vue';
+import ImportHistoryStatusBadge from './import_history_status_badge.vue';
+
+/**
+ * A flexible arrangement of import items, used for import history.
+ *
+ * **Note**: semantically this is *not* a table, as there are nested elements and disclosures.
+ */
+export default {
+  name: 'ImportHistoryTable',
+  components: {
+    GlAvatarLabeled,
+    ImportHistoryStatusBadge,
+    TimeAgoTooltip,
+    ImportHistoryTableRowDestination,
+    ImportHistoryTableHeader,
+    ImportHistoryTableSource,
+    ImportHistoryTableRow,
+    ImportHistoryTableRowStats,
+    ImportHistoryTableRowErrors,
+  },
+  props: {
+    /**
+     * This should be able to accept the data that comes from the BulkImport API
+     *
+     * @typedef {Object} ImportItem
+     * @property {Object} source - Source project details
+     * @property {Object} destination - Target namespace details
+     * @property {Object} stats - Import statistics
+     * @property {number} stats.imported - Successfully imported count
+     * @property {number} stats.fetched - Total fetched count
+     * @property {Array<Object>} failures - Error details
+     * @property {string} failures[].correlation_id_value - Error correlation ID
+     * @property {string} failures[].exception_message - Human-readable error message
+     * @property {string} [failures[].link_text] - Optional link text for error details
+     * @property {string} [failures[].raw] - Raw error output
+     *
+     * @type {Array<ImportItem>}
+     */
+    items: {
+      type: Array,
+      default: () => [],
+      required: true,
+    },
+    /** Path for links to help docs on errors. Can be injected in parent. */
+    detailsPath: {
+      type: String,
+      required: false,
+      default: null,
+    },
+  },
+  methods: {
+    hasStats(item) {
+      return Boolean(item.stats && Object.keys(item.stats).length);
+    },
+    hasFailures(item) {
+      return item.has_failures;
+    },
+    showToggle(item) {
+      return this.hasStats(item) || this.hasFailures(item) || Boolean(item.nestedRow);
+    },
+  },
+  gridClasses: 'gl-grid-cols-[repeat(2,1fr),200px,150px]',
+};
+</script>
+
+<template>
+  <div>
+    <import-history-table-header :grid-classes="$options.gridClasses">
+      <template #column-1>
+        {{ __('Source name') }}
+      </template>
+      <template #column-2>{{ __('Destination') }}</template>
+      <template #column-3>{{ __('Start date') }}</template>
+      <template #column-4>{{ __('Status') }}</template>
+    </import-history-table-header>
+    <import-history-table-row
+      v-for="item in items"
+      :key="item.id"
+      data-testid="import-history-table-row"
+      :show-toggle="showToggle(item)"
+      :grid-classes="$options.gridClasses"
+    >
+      <template #column-1>
+        <import-history-table-source :item="item" />
+      </template>
+      <template #column-2>
+        <import-history-table-row-destination :item="item" />
+      </template>
+      <template #column-3>
+        <div class="gl-flex gl-flex-col gl-gap-2">
+          <gl-avatar-labeled v-if="item.userAvatarProps" v-bind="item.userAvatarProps" :size="16" />
+          <time-ago-tooltip :time="item.created_at" />
+        </div>
+      </template>
+      <template #column-4>
+        <import-history-status-badge v-if="item.status_name" :status="item.status_name" />
+      </template>
+      <template v-if="item.nestedRow" #nested-row>
+        <import-history-table-row
+          data-testid="import-history-table-row-nested"
+          :is-nested="true"
+          :show-toggle="showToggle(item.nestedRow)"
+          :grid-classes="$options.gridClasses"
+        >
+          <template #column-1>
+            <import-history-table-source :item="item.nestedRow" />
+          </template>
+          <template #column-2>
+            <import-history-table-row-destination :item="item.nestedRow" />
+          </template>
+          <template #column-3>
+            <gl-avatar-labeled
+              v-if="item.nestedRow.userAvatarProps"
+              v-bind="item.nestedRow.userAvatarProps"
+              :size="16"
+            />
+            <time-ago-tooltip :time="item.nestedRow.created_at" />
+          </template>
+          <template #column-4>
+            <import-history-status-badge
+              v-if="item.nestedRow.status_name"
+              :status="item.nestedRow.status_name"
+            />
+          </template>
+          <template #expanded-content>
+            <import-history-table-row-stats
+              v-if="hasStats(item.nestedRow)"
+              :item="item.nestedRow"
+              :details-path="detailsPath"
+            />
+            <import-history-table-row-errors
+              v-else-if="hasFailures(item.nestedRow)"
+              :item="item.nestedRow"
+              :details-path="detailsPath"
+            />
+          </template>
+        </import-history-table-row>
+      </template>
+      <template #expanded-content>
+        <import-history-table-row-stats
+          v-if="hasStats(item)"
+          :item="item"
+          :details-path="detailsPath"
+        />
+        <import-history-table-row-errors
+          v-else-if="hasFailures(item)"
+          :item="item"
+          :details-path="detailsPath"
+        />
+      </template>
+    </import-history-table-row>
+  </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/import/import_history_table_row.vue b/app/assets/javascripts/vue_shared/components/import/import_history_table_row.vue
index c3615f2ab5caf3a058ac9d92dcc32b9f0316af1e..367032548f8194e80b15d4e1346f28aad41be36e 100644
--- a/app/assets/javascripts/vue_shared/components/import/import_history_table_row.vue
+++ b/app/assets/javascripts/vue_shared/components/import/import_history_table_row.vue
@@ -65,7 +65,7 @@ export default {
       :class="appliedGridClasses"
     >
       <div
-        class="gl-flex gl-items-start gl-gap-3"
+        class="gl-flex gl-min-w-0 gl-items-start gl-gap-3"
         :class="[$options.defaultClasses, isNested && 'gl-pl-7']"
       >
         <gl-button
@@ -85,12 +85,12 @@ export default {
           <!-- @slot Optionally use to provide a checkbox to show when `showToggle` is false for selecting the row -->
           <slot name="checkbox"></slot>
         </div>
-        <div class="gl-flex-grow">
+        <div class="gl-min-w-0 gl-flex-grow">
           <!-- @slot The content of the 1st column -->
           <slot name="column-1"></slot>
         </div>
       </div>
-      <div :class="$options.defaultClasses">
+      <div :class="$options.defaultClasses" class="gl-min-w-0">
         <div>
           <!-- @slot The content of the 2nd column -->
           <slot name="column-2"></slot>
diff --git a/app/assets/javascripts/vue_shared/components/import/import_history_table_row_destination.stories.js b/app/assets/javascripts/vue_shared/components/import/import_history_table_row_destination.stories.js
index 1fc15de705365c3db7aec7344a03e8d747fcbd61..d8eca3403bc755d773a69648c4c7e212f1ada115 100644
--- a/app/assets/javascripts/vue_shared/components/import/import_history_table_row_destination.stories.js
+++ b/app/assets/javascripts/vue_shared/components/import/import_history_table_row_destination.stories.js
@@ -1,4 +1,4 @@
-import { basic } from 'jest/vue_shared/components/import_history_table/mock_data';
+import { basic } from 'jest/vue_shared/components/import/history_mock_data';
 
 import ImportHistoryTableRowDestination from './import_history_table_row_destination.vue';
 
diff --git a/app/assets/javascripts/vue_shared/components/import/import_history_table_row_errors.stories.js b/app/assets/javascripts/vue_shared/components/import/import_history_table_row_errors.stories.js
index 47139c5303c354dfe7c8cb1a66fe4f8153f8c2ea..b4415cfb00a893e6ebbae6e4672d4e8c40e0f3eb 100644
--- a/app/assets/javascripts/vue_shared/components/import/import_history_table_row_errors.stories.js
+++ b/app/assets/javascripts/vue_shared/components/import/import_history_table_row_errors.stories.js
@@ -1,4 +1,4 @@
-import { basic } from 'jest/vue_shared/components/import_history_table/mock_data';
+import { basic } from 'jest/vue_shared/components/import/history_mock_data';
 
 import ImportHistoryTableRowErrors from './import_history_table_row_errors.vue';
 
diff --git a/app/assets/javascripts/vue_shared/components/import/import_history_table_row_stats.stories.js b/app/assets/javascripts/vue_shared/components/import/import_history_table_row_stats.stories.js
index a11860f32b6cf18aba00cfbd35fc83bf02c65021..4a95a86f5b57fb807f6f36155fbcbca441eef50f 100644
--- a/app/assets/javascripts/vue_shared/components/import/import_history_table_row_stats.stories.js
+++ b/app/assets/javascripts/vue_shared/components/import/import_history_table_row_stats.stories.js
@@ -1,4 +1,4 @@
-import { basic } from 'jest/vue_shared/components/import_history_table/mock_data';
+import { basic } from 'jest/vue_shared/components/import/history_mock_data';
 
 import ImportHistoryTableRowStats from './import_history_table_row_stats.vue';
 
diff --git a/app/assets/javascripts/vue_shared/components/import/import_history_table_row_stats.vue b/app/assets/javascripts/vue_shared/components/import/import_history_table_row_stats.vue
index 0c21c0880dbba16e69855bc0a9751f621181bcfd..d5259d74013d26efa1ba69db8ab55fbd67c89278 100644
--- a/app/assets/javascripts/vue_shared/components/import/import_history_table_row_stats.vue
+++ b/app/assets/javascripts/vue_shared/components/import/import_history_table_row_stats.vue
@@ -52,7 +52,10 @@ export default {
         return RELATION_STATUS_DATA.complete;
       }
 
-      if (this.item.status_name === IMPORT_HISTORY_TABLE_STATUS.complete) {
+      if (
+        this.item.status_name === IMPORT_HISTORY_TABLE_STATUS.complete ||
+        this.item.status_name === IMPORT_HISTORY_TABLE_STATUS.failed
+      ) {
         return RELATION_STATUS_DATA.failed;
       }
 
diff --git a/app/assets/javascripts/vue_shared/components/import/import_history_table_source.stories.js b/app/assets/javascripts/vue_shared/components/import/import_history_table_source.stories.js
index da7d37eb01c26e86f8c50bfff25a8c98d32f10b7..7850b7f7fc2b92f35d672ef0344b0fd9bebb91cb 100644
--- a/app/assets/javascripts/vue_shared/components/import/import_history_table_source.stories.js
+++ b/app/assets/javascripts/vue_shared/components/import/import_history_table_source.stories.js
@@ -1,4 +1,4 @@
-import { basic } from 'jest/vue_shared/components/import_history_table/mock_data';
+import { basic } from 'jest/vue_shared/components/import/history_mock_data';
 
 import ImportHistoryTableSource from './import_history_table_source.vue';
 
diff --git a/app/assets/javascripts/vue_shared/components/import/import_source_list_table.stories.js b/app/assets/javascripts/vue_shared/components/import/import_source_list_table.stories.js
new file mode 100644
index 0000000000000000000000000000000000000000..3e68bbd9706ba039b801f1729a6822f8bb784d68
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/import/import_source_list_table.stories.js
@@ -0,0 +1,127 @@
+import { GlButton, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
+import { basic } from 'jest/vue_shared/components/import/source_list_mock_data';
+import ImportSourceListTable from './import_source_list_table.vue';
+
+export default {
+  component: ImportSourceListTable,
+  title: 'vue_shared/import/import_source_list_table',
+};
+
+const defaultProps = basic;
+
+const Template = (args, { argTypes }) => ({
+  components: { ImportSourceListTable, GlButton },
+  directives: { GlTooltip: GlTooltipDirective },
+  props: Object.keys(argTypes),
+  placeholderClasses:
+    'gl-flex gl-border gl-border-dashed gl-p-4 gl-text-subtle gl-rounded-base gl-text-sm gl-bg-subtle',
+  template: `<div>
+  <import-source-list-table v-bind="$props">
+    <template #select-all-checkbox="{ items }">
+      <div :class="$options.placeholderClasses" class="!gl-p-3" v-gl-tooltip title="CHECKBOX SLOT"></div>
+    </template>
+    <template #row-checkbox="{ item }">
+      <div :class="$options.placeholderClasses" class="!gl-p-3" v-gl-tooltip title="CHECKBOX SLOT"></div>
+    </template>
+    <template #destination-input="{ item }">
+      <div :class="$options.placeholderClasses">DESTINATION INPUT GOES HERE</div>
+    </template>
+    <template #action="{ item }">
+      <div :class="$options.placeholderClasses">ACTION GOES HERE</div>
+    </template>
+  </import-source-list-table>
+</div>`,
+});
+
+export const Default = Template.bind({});
+Default.args = defaultProps;
+Default.parameters = {
+  docs: {
+    description: {
+      story: `This is a bare-bones example of the import source list table component.`,
+    },
+  },
+};
+
+const FunctionalExample = (args, { argTypes }) => ({
+  components: { ImportSourceListTable, GlButton, GlFormCheckbox },
+  data() {
+    return {
+      selectedItems: [],
+    };
+  },
+  props: Object.keys(argTypes),
+  computed: {
+    totalSelectable() {
+      return this.items.filter((i) => !i.full_path).length;
+    },
+    selectAllIndeterminate() {
+      return this.selectedItems.length > 0 && this.selectedItems.length < this.totalSelectable;
+    },
+    numberToImport() {
+      if (this.selectedItems.length === 0) {
+        return this.totalSelectable;
+      }
+
+      return this.selectedItems.length;
+    },
+    allSelected() {
+      return this.selectedItems.length === this.totalSelectable;
+    },
+  },
+  methods: {
+    isSelected(id) {
+      return this.selectedItems.includes(id);
+    },
+    toggleSelect(id) {
+      if (this.isSelected(id)) {
+        this.selectedItems = this.selectedItems.filter((i) => i !== id);
+      } else {
+        this.selectedItems.push(id);
+      }
+    },
+    selectAll(checked) {
+      if (checked) {
+        this.selectedItems = this.items.filter((i) => !i.full_path).map((i) => i.id);
+      } else {
+        this.selectedItems = [];
+      }
+    },
+  },
+  placeholderClasses:
+    'gl-flex gl-border gl-border-dashed gl-p-4 gl-text-subtle gl-rounded-base gl-text-sm gl-bg-subtle',
+  template: `<div>
+  <div class="gl-flex gl-bg-subtle gl-justify-end gl-p-5">
+    <gl-button variant="confirm">Import {{ numberToImport }} repositor{{ numberToImport === 1 ? 'y' : 'ies' }}</gl-button>
+  </div>
+  <import-source-list-table v-bind="$props">
+    <template #select-all-checkbox="{ items }">
+      <gl-form-checkbox class="gl-min-h-5 gl-w-0" @change="selectAll" :indeterminate="selectAllIndeterminate" :checked="allSelected" />
+    </template>
+    <template #row-checkbox="{ item }">
+      <gl-form-checkbox class="gl-min-h-5 gl-w-0" :checked="isSelected(item.id)" @change="toggleSelect(item.id)" />
+    </template>
+    <template #destination-input="{ item }">
+      <div :class="$options.placeholderClasses">DESTINATION INPUT GOES HERE</div>
+    </template>
+    <template #action="{ item }">
+      <gl-button v-if="item.action" v-bind="item.action.buttonProps">{{
+        item.action.label
+      }}</gl-button>
+    </template>
+  </import-source-list-table>
+</div>`,
+});
+
+export const Functional = FunctionalExample.bind({});
+Functional.args = defaultProps;
+
+Functional.parameters = {
+  docs: {
+    description: {
+      story: `This example shows an import source table that includes functional multi-select and examples of the import action buttons.
+      
+Note that the top bar with an Import button was added separately and is not part of the table.`,
+    },
+  },
+};
diff --git a/app/assets/javascripts/vue_shared/components/import/import_source_list_table.vue b/app/assets/javascripts/vue_shared/components/import/import_source_list_table.vue
new file mode 100644
index 0000000000000000000000000000000000000000..f480bd02e9da9b0884996dc251f85c5f5681c79d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/import/import_source_list_table.vue
@@ -0,0 +1,179 @@
+<script>
+import ImportHistoryTableHeader from './import_history_table_header.vue';
+import ImportHistoryTableRow from './import_history_table_row.vue';
+import ImportHistoryTableSource from './import_history_table_source.vue';
+import ImportHistoryTableRowDestination from './import_history_table_row_destination.vue';
+import ImportHistoryTableRowStats from './import_history_table_row_stats.vue';
+import ImportHistoryTableRowErrors from './import_history_table_row_errors.vue';
+import ImportHistoryStatusBadge from './import_history_status_badge.vue';
+
+/**
+ * A flexible arrangement of import items, used for displaying projects when importing from 3rd parties.
+ *
+ * **Note**: semantically this is *not* a table, as there are nested elements and disclosures.
+ */
+export default {
+  name: 'ImportSourceListTable',
+  components: {
+    ImportHistoryStatusBadge,
+    ImportHistoryTableRowDestination,
+    ImportHistoryTableHeader,
+    ImportHistoryTableSource,
+    ImportHistoryTableRow,
+    ImportHistoryTableRowStats,
+    ImportHistoryTableRowErrors,
+  },
+  props: {
+    /**
+     * This should be able to accept the data that comes from the BulkImport API.
+     * An additional `action` key is accepted to define the label and buttonProps
+     * for the button that can appear in the rightmost column.
+     *
+     * @typedef {Object} ImportItem
+     * @property {Object} source - Source project details
+     * @property {Object} destination - Target namespace details
+     * @property {Object} stats - Import statistics
+     * @property {number} stats.imported - Successfully imported count
+     * @property {number} stats.fetched - Total fetched count
+     * @property {Array<Object>} failures - Error details
+     * @property {string} failures[].correlation_id_value - Error correlation ID
+     * @property {string} failures[].exception_message - Human-readable error message
+     * @property {string} [failures[].link_text] - Optional link text for error details
+     * @property {string} [failures[].raw] - Raw error output
+     * @property {Object} [action] - Action details
+     * @property {string} [action.label] - Button label
+     * @property {Object} [action.buttonProps] - Props passed to the action button
+     *
+     * @type {Array<ImportItem>}
+     */
+    items: {
+      type: Array,
+      default: () => [],
+      required: true,
+    },
+    /** Path for links to help docs on errors. Can be injected in parent. */
+    detailsPath: {
+      type: String,
+      required: false,
+      default: null,
+    },
+  },
+  methods: {
+    hasStats(item) {
+      return item.stats && Object.keys(item.stats).length;
+    },
+    hasFailures(item) {
+      return item.has_failures;
+    },
+    showToggle(item) {
+      return Boolean(this.hasStats(item)) || this.hasFailures(item) || Boolean(item.nestedRow);
+    },
+  },
+  gridClasses: 'gl-grid-cols-[repeat(2,1fr),150px,250px]',
+};
+</script>
+
+<template>
+  <div>
+    <import-history-table-header :grid-classes="$options.gridClasses">
+      <template #checkbox>
+        <!-- 
+          @slot Slot for passing a checkbox to select all selectable items.
+          @binding items All the items 
+        -->
+        <slot :items="items" name="select-all-checkbox"></slot>
+      </template>
+      <template #column-1>
+        {{ __('Source name') }}
+      </template>
+      <template #column-2>{{ __('Destination path') }}</template>
+      <template #column-3>{{ __('Status') }}</template>
+    </import-history-table-header>
+
+    <import-history-table-row
+      v-for="item in items"
+      :key="item.id"
+      :show-toggle="showToggle(item)"
+      :grid-classes="$options.gridClasses"
+    >
+      <template #checkbox>
+        <!-- 
+          @slot Slot for passing a checkbox for each row that can be used to select it. Only displays if the row does not have a destination defined. Renders in place of the toggle button.
+          @binding item The item for this row
+        -->
+        <slot name="row-checkbox" :item="item"></slot>
+      </template>
+      <template #column-1>
+        <import-history-table-source :item="item" />
+      </template>
+      <template #column-2>
+        <import-history-table-row-destination v-if="item.destination_slug" :item="item" />
+        <!-- 
+          @slot Slot for passing a destination input for items that do not have a destination defined.
+          @binding item The item for this row
+        -->
+        <slot v-else :item="item" name="destination-input"></slot>
+      </template>
+      <template #column-3>
+        <import-history-status-badge v-if="item.status_name" :status="item.status_name" />
+      </template>
+      <template #column-4>
+        <!-- 
+          @slot Slot for passing an action for the item. This will typically be a button that imports or re-imports.
+          @binding item The item for this row
+        -->
+        <slot :item="item" name="action"></slot>
+      </template>
+      <template v-if="item.nestedRow" #nested-row>
+        <import-history-table-row
+          data-testid="import-history-table-row-nested"
+          :is-nested="true"
+          :show-toggle="showToggle(item.nestedRow)"
+          :grid-classes="$options.gridClasses"
+        >
+          <template #column-1>
+            <import-history-table-source :item="item.nestedRow" />
+          </template>
+          <template #column-2>
+            <import-history-table-row-destination
+              v-if="item.destination_slug"
+              :item="item.nestedRow"
+            />
+            <slot v-else :item="item" name="nested-destination-input"></slot>
+          </template>
+          <template #column-3>
+            <import-history-status-badge
+              v-if="item.nestedRow.status_name"
+              :status="item.nestedRow.status_name"
+            />
+          </template>
+          <template #column-4> </template>
+          <template #expanded-content>
+            <import-history-table-row-stats
+              v-if="hasStats(item.nestedRow)"
+              :item="item.nestedRow"
+              :details-path="detailsPath"
+            />
+            <import-history-table-row-errors
+              v-else-if="hasFailures(item.nestedRow)"
+              :item="item.nestedRow"
+              :details-path="detailsPath"
+            />
+          </template>
+        </import-history-table-row>
+      </template>
+      <template #expanded-content>
+        <import-history-table-row-stats
+          v-if="hasStats(item)"
+          :item="item"
+          :details-path="detailsPath"
+        />
+        <import-history-table-row-errors
+          v-else-if="hasFailures(item)"
+          :item="item"
+          :details-path="detailsPath"
+        />
+      </template>
+    </import-history-table-row>
+  </div>
+</template>
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 55a774bce3b031986e13c7ca5625f30aaf717795..fcfd2b7f51a4a93ad6a3cda5b339e26ddf51a3bb 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -20898,6 +20898,12 @@ msgstr ""
 msgid "Designs"
 msgstr ""
 
+msgid "Destination"
+msgstr ""
+
+msgid "Destination path"
+msgstr ""
+
 msgid "Destroy"
 msgstr ""
 
@@ -56112,6 +56118,9 @@ msgstr ""
 msgid "Source is not available"
 msgstr ""
 
+msgid "Source name"
+msgstr ""
+
 msgid "Source name is required"
 msgstr ""
 
diff --git a/spec/frontend/vue_shared/components/import_history_table/mock_data.js b/spec/frontend/vue_shared/components/import/history_mock_data.js
similarity index 100%
rename from spec/frontend/vue_shared/components/import_history_table/mock_data.js
rename to spec/frontend/vue_shared/components/import/history_mock_data.js
diff --git a/spec/frontend/vue_shared/components/import/import_history_table.spec.js b/spec/frontend/vue_shared/components/import/import_history_table.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..39f4f18cd5eb902eeb76667a1767178478c0a56f
--- /dev/null
+++ b/spec/frontend/vue_shared/components/import/import_history_table.spec.js
@@ -0,0 +1,89 @@
+import { GlAvatarLabeled } from '@gitlab/ui';
+
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
+import ImportHistoryTable from '~/vue_shared/components/import/import_history_table.vue';
+import ImportHistoryTableRow from '~/vue_shared/components/import/import_history_table_row.vue';
+import ImportHistoryTableRowDestination from '~/vue_shared/components/import/import_history_table_row_destination.vue';
+import ImportHistoryTableSource from '~/vue_shared/components/import/import_history_table_source.vue';
+import ImportHistoryTableRowStats from '~/vue_shared/components/import/import_history_table_row_stats.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import ImportHistoryStatusBadge from '~/vue_shared/components/import/import_history_status_badge.vue';
+import ImportHistoryTableRowErrors from '~/vue_shared/components/import/import_history_table_row_errors.vue';
+
+import { apiItems } from './history_mock_data';
+
+import { countItemsAndNested } from './utils';
+
+describe('ImportHistoryTableRowStats component', () => {
+  let wrapper;
+
+  const findAllDestinations = () => wrapper.findAllComponents(ImportHistoryTableRowDestination);
+  const findAllErrors = () => wrapper.findAllComponents(ImportHistoryTableRowErrors);
+  const findAllGlAvatarLabeled = () => wrapper.findAllComponents(GlAvatarLabeled);
+  const findAllNestedRows = () => wrapper.findAllByTestId('import-history-table-row-nested');
+  const findAllSources = () => wrapper.findAllComponents(ImportHistoryTableSource);
+  const findAllStats = () => wrapper.findAllComponents(ImportHistoryTableRowStats);
+  const findallStatusBadges = () => wrapper.findAllComponents(ImportHistoryStatusBadge);
+  const findAllTableRows = () => wrapper.findAllComponents(ImportHistoryTableRow);
+  const findAllTimeago = () => wrapper.findAllComponents(TimeAgoTooltip);
+  const findAllTopLevelRows = () => wrapper.findAllByTestId('import-history-table-row');
+
+  const createComponent = (props = {}) => {
+    wrapper = shallowMountExtended(ImportHistoryTable, {
+      propsData: {
+        items: apiItems,
+        ...props,
+      },
+    });
+  };
+
+  const topLevelItems = apiItems.length;
+  const nestedItems = apiItems.filter((i) => i.nestedRow).length;
+  const totalDataRows = topLevelItems + nestedItems;
+
+  describe('renders', () => {
+    beforeEach(() => {
+      createComponent();
+    });
+    it('renders a row for each item and each nested item', () => {
+      expect(findAllTableRows().length).toBe(totalDataRows);
+
+      expect(findAllTopLevelRows().length).toBe(apiItems.length);
+
+      const itemsWithNestedRows = countItemsAndNested(apiItems, (i) => i.nestedRow);
+      expect(findAllNestedRows().length).toBe(itemsWithNestedRows);
+    });
+    it('renders correct number of sources', () => {
+      expect(findAllSources().length).toBe(totalDataRows);
+    });
+    it('renders correct number of destinations', () => {
+      expect(findAllDestinations().length).toBe(totalDataRows);
+    });
+    it('renders a GlAvatarLabeled for each item where userAvatarProps is defined', () => {
+      const itemsWithAvatarProps = countItemsAndNested(apiItems, (i) => i.userAvatarProps);
+      expect(findAllGlAvatarLabeled().length).toBe(itemsWithAvatarProps);
+    });
+    it('renders a TimeAgoTooltip for each row', () => {
+      expect(findAllTimeago().length).toBe(totalDataRows);
+    });
+    it('renders status icon for each row that has status_name defined', () => {
+      const itemsWithStatus = countItemsAndNested(apiItems, (i) => i.status_name);
+      expect(findallStatusBadges().length).toBe(itemsWithStatus);
+    });
+    it('renders stats for all items that have at least 1 stat', () => {
+      const itemsWithStats = countItemsAndNested(
+        apiItems,
+        (i) => i.stats && Object.keys(i.stats).length,
+      );
+      expect(findAllStats().length).toBe(itemsWithStats);
+    });
+    it('renders errors for all items that have at least 1 error but no stats', () => {
+      const itemsWithErrors = countItemsAndNested(
+        apiItems,
+        (i) => i.has_failures && !(i.stats && Object.keys(i.stats).length),
+      );
+      expect(findAllErrors().length).toBe(itemsWithErrors);
+    });
+  });
+});
diff --git a/spec/frontend/vue_shared/components/import_history_table/import_history_table_row_errors.spec.js b/spec/frontend/vue_shared/components/import/import_history_table_row_errors.spec.js
similarity index 97%
rename from spec/frontend/vue_shared/components/import_history_table/import_history_table_row_errors.spec.js
rename to spec/frontend/vue_shared/components/import/import_history_table_row_errors.spec.js
index e76e4a2ebbf4e8d5e5fc1537a13bcec9e85ecad4..fdd5b7bfc22a6bfa443a264e06b201ac6e9daab6 100644
--- a/spec/frontend/vue_shared/components/import_history_table/import_history_table_row_errors.spec.js
+++ b/spec/frontend/vue_shared/components/import/import_history_table_row_errors.spec.js
@@ -2,7 +2,7 @@ import { GlAlert, GlLink } from '@gitlab/ui';
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
 
 import ImportHistoryTableRowErrors from '~/vue_shared/components/import/import_history_table_row_errors.vue';
-import { apiItems } from './mock_data';
+import { apiItems } from './history_mock_data';
 
 describe('ImportHistoryTableRowStats component', () => {
   let wrapper;
diff --git a/spec/frontend/vue_shared/components/import_history_table/import_history_table_row_stats.spec.js b/spec/frontend/vue_shared/components/import/import_history_table_row_stats.spec.js
similarity index 97%
rename from spec/frontend/vue_shared/components/import_history_table/import_history_table_row_stats.spec.js
rename to spec/frontend/vue_shared/components/import/import_history_table_row_stats.spec.js
index 280660585ce4202ebaa03128fd996b3f1257e62f..769d4c235e6b11e90df5d8663b2afa1f61091b3d 100644
--- a/spec/frontend/vue_shared/components/import_history_table/import_history_table_row_stats.spec.js
+++ b/spec/frontend/vue_shared/components/import/import_history_table_row_stats.spec.js
@@ -4,7 +4,7 @@ import { nextTick } from 'vue';
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
 
 import ImportHistoryTableRowStats from '~/vue_shared/components/import/import_history_table_row_stats.vue';
-import { apiItems } from './mock_data';
+import { apiItems } from './history_mock_data';
 
 describe('ImportHistoryTableRowStats component', () => {
   let wrapper;
diff --git a/spec/frontend/vue_shared/components/import/import_source_list_table.spec.js b/spec/frontend/vue_shared/components/import/import_source_list_table.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..4e6d0b3ce9d8fb40a03da8d4264a2482a47537c6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/import/import_source_list_table.spec.js
@@ -0,0 +1,71 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
+import ImportSourceListTable from '~/vue_shared/components/import/import_source_list_table.vue';
+import ImportHistoryTableRow from '~/vue_shared/components/import/import_history_table_row.vue';
+import ImportHistoryTableRowDestination from '~/vue_shared/components/import/import_history_table_row_destination.vue';
+import ImportHistoryTableSource from '~/vue_shared/components/import/import_history_table_source.vue';
+import ImportHistoryTableRowStats from '~/vue_shared/components/import/import_history_table_row_stats.vue';
+import ImportHistoryStatusBadge from '~/vue_shared/components/import/import_history_status_badge.vue';
+import ImportHistoryTableRowErrors from '~/vue_shared/components/import/import_history_table_row_errors.vue';
+
+import { apiItems } from './source_list_mock_data';
+
+import { countItemsAndNested } from './utils';
+
+describe('ImportHistoryTableRowStats component', () => {
+  let wrapper;
+
+  const findAllDestinations = () => wrapper.findAllComponents(ImportHistoryTableRowDestination);
+  const findAllErrors = () => wrapper.findAllComponents(ImportHistoryTableRowErrors);
+  const findAllSources = () => wrapper.findAllComponents(ImportHistoryTableSource);
+  const findAllStats = () => wrapper.findAllComponents(ImportHistoryTableRowStats);
+  const findallStatusBadges = () => wrapper.findAllComponents(ImportHistoryStatusBadge);
+  const findAllTableRows = () => wrapper.findAllComponents(ImportHistoryTableRow);
+
+  const createComponent = (props = {}) => {
+    wrapper = shallowMountExtended(ImportSourceListTable, {
+      propsData: {
+        items: apiItems,
+        ...props,
+      },
+    });
+  };
+
+  const topLevelItems = apiItems.length;
+  const nestedItems = apiItems.filter((i) => i.nestedRow).length;
+  const totalDataRows = topLevelItems + nestedItems;
+
+  describe('renders', () => {
+    beforeEach(() => {
+      createComponent();
+    });
+    it('renders a row for each item and each nested item', () => {
+      expect(findAllTableRows().length).toBe(totalDataRows);
+    });
+    it('renders correct number of sources', () => {
+      expect(findAllSources().length).toBe(totalDataRows);
+    });
+    it('renders destination for rows that have destination_slug defined', () => {
+      const itemsWithDestinations = countItemsAndNested(apiItems, (i) => i.destination_slug);
+      expect(findAllDestinations().length).toBe(itemsWithDestinations);
+    });
+    it('renders status icon for each row that has status_name defined', () => {
+      const itemsWithStatus = countItemsAndNested(apiItems, (i) => i.status_name);
+      expect(findallStatusBadges().length).toBe(itemsWithStatus);
+    });
+    it('renders stats for all items that have at least 1 stat', () => {
+      const itemsWithStats = countItemsAndNested(
+        apiItems,
+        (i) => i.stats && Object.keys(i.stats).length,
+      );
+      expect(findAllStats().length).toBe(itemsWithStats);
+    });
+    it('renders errors for all items that have at least 1 error but no stats', () => {
+      const itemsWithErrors = countItemsAndNested(
+        apiItems,
+        (i) => i.has_failures && !(i.stats && Object.keys(i.stats).length),
+      );
+      expect(findAllErrors().length).toBe(itemsWithErrors);
+    });
+  });
+});
diff --git a/spec/frontend/vue_shared/components/import/source_list_mock_data.js b/spec/frontend/vue_shared/components/import/source_list_mock_data.js
new file mode 100644
index 0000000000000000000000000000000000000000..bd8d9cc93c68fb9706596dce41c98ef5c0619bbb
--- /dev/null
+++ b/spec/frontend/vue_shared/components/import/source_list_mock_data.js
@@ -0,0 +1,169 @@
+import { IMPORT_HISTORY_TABLE_STATUS } from '~/vue_shared/components/import/constants';
+
+const sixMonthsAgo = () => new Date(new Date().getTime() - 190 * 24 * 60 * 60 * 1000);
+const fiveMinutesAgo = () => new Date(new Date().getTime() - 5 * 60 * 1000);
+
+export const apiItems = [
+  {
+    id: 1,
+    bulk_import_id: 0,
+    entity_type: 'group',
+    source_full_path: 'https://github.com/name/',
+    created_at: fiveMinutesAgo(),
+    failures: [],
+    migrate_projects: true,
+    migrate_memberships: true,
+    has_failures: false,
+    stats: {},
+    action: {
+      label: 'Import',
+      buttonProps: {},
+    },
+  },
+  {
+    id: 2,
+    bulk_import_id: 0,
+    entity_type: 'group',
+    source_full_path: 'https://github.com/somegroup',
+    parent_id: null,
+    namespace_id: 1,
+    project_id: null,
+    created_at: fiveMinutesAgo(),
+    updated_at: fiveMinutesAgo(),
+    failures: [],
+    migrate_projects: true,
+    migrate_memberships: true,
+    has_failures: false,
+    action: {
+      label: 'Import',
+      buttonProps: {},
+    },
+  },
+  {
+    id: 0,
+    bulk_import_id: 0,
+    status_name: IMPORT_HISTORY_TABLE_STATUS.inProgress,
+    entity_type: 'group',
+    source_full_path: 'https://github.com/coolworld/',
+    full_path: 'my-group/coolworld',
+    destination_name: 'coolworld',
+    destination_slug: 'coolworld',
+    destination_namespace: 'my-group',
+    parent_id: 0,
+    namespace_id: 2,
+    project_id: 0,
+    created_at: fiveMinutesAgo(),
+    updated_at: fiveMinutesAgo(),
+    migrate_projects: true,
+    migrate_memberships: true,
+    has_failures: true,
+    nestedRow: {
+      id: 214,
+      bulk_import_id: 0,
+      status_name: IMPORT_HISTORY_TABLE_STATUS.failed,
+      entity_type: 'project',
+      source_full_path: 'https://github.com/name/project.git',
+      full_path: 'my-group/coolworld/project',
+      destination_name: 'coolworld/project',
+      destination_slug: 'coolworld/project',
+      destination_namespace: 'my-group/coolworld',
+      parent_id: 0,
+      namespace_id: 2,
+      project_id: 0,
+      created_at: fiveMinutesAgo(),
+      updated_at: fiveMinutesAgo(),
+      failures: [
+        {
+          relation: 'design',
+          exception_message: 'custom error message',
+          exception_class: 'Exception',
+          correlation_id_value: 'dfcf583058ed4508e4c7c617bd7f0edd',
+          source_url: 'https://github.com/name/project.git',
+          source_title: 'some title',
+        },
+      ],
+      migrate_projects: true,
+      migrate_memberships: true,
+      has_failures: true,
+      stats: {
+        milestones: {
+          source: 10,
+          fetched: 10,
+          imported: 10,
+        },
+        labels: {
+          source: 10,
+          fetched: 10,
+          imported: 3,
+        },
+        design: {
+          source: 10,
+          fetched: 10,
+          imported: 3,
+        },
+      },
+    },
+    action: {
+      label: 'Re-import as a new group',
+      buttonProps: {},
+    },
+  },
+  {
+    id: 4,
+    bulk_import_id: 0,
+    status_name: IMPORT_HISTORY_TABLE_STATUS.complete,
+    entity_type: 'group',
+    source_full_path: 'https://github.com/name',
+    full_path: 'my-group/name',
+    destination_name: 'name',
+    destination_slug: 'name',
+    destination_namespace: 'my-group',
+    parent_id: 0,
+    namespace_id: 2,
+    project_id: 0,
+    created_at: sixMonthsAgo(),
+    updated_at: sixMonthsAgo(),
+    migrate_projects: true,
+    migrate_memberships: true,
+    has_failures: true,
+    nestedRow: {
+      id: 214,
+      bulk_import_id: 0,
+      status_name: IMPORT_HISTORY_TABLE_STATUS.complete,
+      entity_type: 'project',
+      source_full_path: 'https://github.com/name/project.git',
+      full_path: 'my-group/project',
+      destination_name: 'project',
+      destination_slug: 'project',
+      destination_namespace: 'my-group',
+      parent_id: 0,
+      namespace_id: 2,
+      project_id: 0,
+      created_at: sixMonthsAgo(),
+      updated_at: sixMonthsAgo(),
+      migrate_projects: true,
+      migrate_memberships: true,
+      has_failures: true,
+      stats: {
+        labels: {
+          source: 10,
+          fetched: 10,
+          imported: 10,
+        },
+        milestones: {
+          source: 10,
+          fetched: 10,
+          imported: 10,
+        },
+      },
+    },
+    action: {
+      label: 'Re-import as a new group',
+      buttonProps: {},
+    },
+  },
+];
+
+export const basic = {
+  items: apiItems,
+};
diff --git a/spec/frontend/vue_shared/components/import/utils.js b/spec/frontend/vue_shared/components/import/utils.js
new file mode 100644
index 0000000000000000000000000000000000000000..11614840ba71e47896512089dd97998ca4aca3b3
--- /dev/null
+++ b/spec/frontend/vue_shared/components/import/utils.js
@@ -0,0 +1,14 @@
+/**
+ * Convenience function for searching for matches in top-level items and nested items in the Items data for import tables
+ * @param {Array} items Items to count
+ * @param {Function} filter Filter function to return matches
+ * @returns number
+ */
+export const countItemsAndNested = (items, filter) => {
+  const topLevelMatches = items.filter(filter);
+  const nestedMatches = items
+    .filter((i) => i.nestedRow)
+    .map((i) => i.nestedRow)
+    .filter(filter);
+  return topLevelMatches.length + nestedMatches.length;
+};