diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue
index 5455a034106e9d4375c137aa5297776207a76c57..bd69165f0ca5e6b8f2db123f907da58f11815f20 100644
--- a/app/assets/javascripts/import_entities/components/import_status.vue
+++ b/app/assets/javascripts/import_entities/components/import_status.vue
@@ -49,7 +49,7 @@ const STATUS_MAP = {
text: __('Timeout'),
variant: 'danger',
},
- [STATUSES.CANCELLED]: {
+ [STATUSES.CANCELED]: {
icon: 'status-stopped',
text: __('Cancelled'),
variant: 'neutral',
diff --git a/app/assets/javascripts/import_entities/constants.js b/app/assets/javascripts/import_entities/constants.js
index d0a23f88ee71af13e138337fdd80beaf2aa35e4b..48b7febca4be6703bc3c5cd045c3a3bf19064b80 100644
--- a/app/assets/javascripts/import_entities/constants.js
+++ b/app/assets/javascripts/import_entities/constants.js
@@ -9,7 +9,7 @@ export const STATUSES = {
STARTED: 'started',
NONE: 'none',
SCHEDULING: 'scheduling',
- CANCELLED: 'cancelled',
+ CANCELED: 'canceled',
TIMEOUT: 'timeout',
};
diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
index d82acfa3f053875aa9af1a532a76fb384e79e90b..63a36f1a79ffc0606adda5cb2689e9884b944d63 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
@@ -37,6 +37,11 @@ export default {
required: false,
default: false,
},
+ cancelable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
optionalStages: {
type: Array,
required: false,
@@ -97,7 +102,6 @@ export default {
},
mounted() {
- this.fetchNamespaces();
this.fetchJobs();
if (!this.paginatable) {
@@ -114,7 +118,6 @@ export default {
...mapActions([
'fetchRepos',
'fetchJobs',
- 'fetchNamespaces',
'stopJobsPolling',
'clearJobsEtagPoll',
'setFilter',
@@ -197,6 +200,7 @@ export default {
:repo="repo"
:user-namespace="defaultTargetNamespace"
:optional-stages="optionalStagesSelection"
+ :cancelable="cancelable"
/>
diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
index 371373d9f2b8ba86be14becb5ed0a78fb9d8ff90..b8faf349375510cea25d8ffa275113d9dac0f543 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
@@ -8,13 +8,15 @@ import {
GlDropdownItem,
GlDropdownDivider,
GlDropdownSectionHeader,
+ GlTooltip,
} from '@gitlab/ui';
import { mapState, mapGetters, mapActions } from 'vuex';
import { __ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
import ImportGroupDropdown from '../../components/group_dropdown.vue';
import ImportStatus from '../../components/import_status.vue';
import { STATUSES } from '../../constants';
-import { isProjectImportable, isIncompatible, getImportStatus } from '../utils';
+import { isProjectImportable, isImporting, isIncompatible, getImportStatus } from '../utils';
export default {
name: 'ProviderRepoTableRow',
@@ -29,6 +31,7 @@ export default {
GlIcon,
GlBadge,
GlLink,
+ GlTooltip,
},
props: {
repo: {
@@ -43,6 +46,11 @@ export default {
type: Object,
required: true,
},
+ cancelable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
@@ -69,6 +77,14 @@ export default {
return getImportStatus(this.repo);
},
+ isImporting() {
+ return isImporting(this.repo);
+ },
+
+ isCancelable() {
+ return this.cancelable && this.isImporting && this.importStatus !== STATUSES.SCHEDULING;
+ },
+
stats() {
return this.repo.importedProject?.stats;
},
@@ -92,7 +108,7 @@ export default {
},
methods: {
- ...mapActions(['fetchImport', 'setImportTarget']),
+ ...mapActions(['fetchImport', 'cancelImport', 'setImportTarget']),
updateImportTarget(changedValues) {
this.setImportTarget({
repoId: this.repo.importSource.id,
@@ -100,6 +116,8 @@ export default {
});
},
},
+
+ helpUrl: helpPagePath('/user/project/import/github.md'),
};
@@ -160,6 +178,26 @@ export default {
+
+
+ {{ s__('ImportProjects|Cancel import') }}
+ {{
+ s__(
+ 'ImportProjects|Imported files will be kept. You can import this repository again later.',
+ )
+ }}
+ {{ __('Learn more.') }}
+
+
+
(
});
};
+export const cancelImportFactory = (cancelImportPath) => ({ state, commit }, { repoId }) => {
+ const existingRepo = state.repositories.find((r) => r.importSource.id === repoId);
+
+ if (!existingRepo?.importedProject) {
+ throw new Error(`Attempting to cancel project which is not started: ${repoId}`);
+ }
+
+ const { id } = existingRepo.importedProject;
+
+ return axios
+ .post(cancelImportPath, {
+ project_id: id,
+ })
+ .then(() => {
+ commit(types.CANCEL_IMPORT_SUCCESS, {
+ repoId,
+ });
+ })
+ .catch((e) => {
+ const serverErrorMessage = e?.response?.data?.errors;
+ const flashMessage = serverErrorMessage
+ ? sprintf(
+ s__('ImportProjects|Cancelling project import failed: %{reason}'),
+ {
+ reason: serverErrorMessage,
+ },
+ false,
+ )
+ : s__('ImportProjects|Cancelling project import failed');
+
+ createAlert({
+ message: flashMessage,
+ });
+ });
+};
+
export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, dispatch }) => {
if (eTagPoll) {
stopJobsPolling();
@@ -211,5 +247,6 @@ export default ({ endpoints = isRequired() }) => ({
importAll,
fetchRepos: fetchReposFactory({ reposPath: endpoints.reposPath }),
fetchImport: fetchImportFactory(endpoints.importPath),
+ cancelImport: cancelImportFactory(endpoints.cancelPath),
fetchJobs: fetchJobsFactory(endpoints.jobsPath),
});
diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js b/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js
index 360582de2db19a52fca098211d91bb8ad957670c..74832a03ac1ad963c42b0841f1f83cf01206b582 100644
--- a/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js
@@ -6,6 +6,8 @@ export const REQUEST_IMPORT = 'REQUEST_IMPORT';
export const RECEIVE_IMPORT_SUCCESS = 'RECEIVE_IMPORT_SUCCESS';
export const RECEIVE_IMPORT_ERROR = 'RECEIVE_IMPORT_ERROR';
+export const CANCEL_IMPORT_SUCCESS = 'CANCEL_IMPORT_SUCCESS';
+
export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS';
export const SET_FILTER = 'SET_FILTER';
diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutations.js b/app/assets/javascripts/import_entities/import_projects/store/mutations.js
index a7cbace0725ae3e709f7c128f1ec0ce7633c05da..9da5163ffd90e4fcd6615cfd0a19446de748f682 100644
--- a/app/assets/javascripts/import_entities/import_projects/store/mutations.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/mutations.js
@@ -51,7 +51,9 @@ export default {
// https://gitlab.com/gitlab-org/gitlab/-/issues/27370#note_379034091
const newImportedProjects = processLegacyEntries({
- newRepositories: repositories.importedProjects,
+ newRepositories: repositories.importedProjects.filter(
+ (p) => p.importStatus !== STATUSES.CANCELED,
+ ),
existingRepositories: state.repositories,
factory: makeNewImportedProject,
});
@@ -122,6 +124,11 @@ export default {
});
},
+ [types.CANCEL_IMPORT_SUCCESS](state, { repoId }) {
+ const existingRepo = state.repositories.find((r) => r.importSource.id === repoId);
+ existingRepo.importedProject.importStatus = STATUSES.CANCELED;
+ },
+
[types.SET_IMPORT_TARGET](state, { repoId, importTarget }) {
const existingRepo = state.repositories.find((r) => r.importSource.id === repoId);
diff --git a/app/assets/javascripts/import_entities/import_projects/utils.js b/app/assets/javascripts/import_entities/import_projects/utils.js
index 38bd529321afe0a057741acdf7ba90058e73eb0f..c4c9e544c1e5344c1d832b0b0f65d1b7a2a5e8f2 100644
--- a/app/assets/javascripts/import_entities/import_projects/utils.js
+++ b/app/assets/javascripts/import_entities/import_projects/utils.js
@@ -9,7 +9,10 @@ export function getImportStatus(project) {
}
export function isProjectImportable(project) {
- return !isIncompatible(project) && getImportStatus(project) === STATUSES.NONE;
+ return (
+ !isIncompatible(project) &&
+ [STATUSES.NONE, STATUSES.CANCELED].includes(getImportStatus(project))
+ );
}
export function isImporting(repo) {
diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml
index 46b15de5caff72f4049f7f99c4db35150b1dabd5..4d2186a135289183e536108a25e75940c40a3d87 100644
--- a/app/views/import/_githubish_status.html.haml
+++ b/app/views/import/_githubish_status.html.haml
@@ -4,6 +4,7 @@
- filterable = local_assigns.fetch(:filterable, true)
- paginatable = local_assigns.fetch(:paginatable, false)
- default_namespace_path = (local_assigns[:default_namespace] || current_user.namespace).full_path
+- cancel_path = local_assigns.fetch(:cancel_path, nil)
- provider_title = Gitlab::ImportSources.title(local_assigns.fetch(:provider))
- optional_stages = local_assigns.fetch(:optional_stages, [])
@@ -17,6 +18,7 @@
jobs_path: url_for([:realtime_changes, :import, provider, { format: :json }]),
default_target_namespace: default_namespace_path,
import_path: url_for([:import, provider, { format: :json }]),
+ cancel_path: cancel_path,
filterable: filterable.to_s,
paginatable: paginatable.to_s,
optional_stages: optional_stages.to_json }.merge(extra_data) }
diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml
index 25afe9a7b1b8d4d7be54e0d378f6cc80324012c9..4a9f8be35c3c0476e9d79aa293970f3f26647fab 100644
--- a/app/views/import/github/status.html.haml
+++ b/app/views/import/github/status.html.haml
@@ -10,4 +10,5 @@
= render 'import/githubish_status',
provider: 'github', paginatable: paginatable,
default_namespace: @namespace,
+ cancel_path: cancel_import_github_path,
optional_stages: Gitlab::GithubImport::Settings.stages_array
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 63299750b886b753f351efab24301de7a8b2e806..fbbdac860d8f9856512935c04defcf2c93707063 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -21041,12 +21041,24 @@ msgstr ""
msgid "ImportProjects|Blocked import URL: %{message}"
msgstr ""
+msgid "ImportProjects|Cancel import"
+msgstr ""
+
+msgid "ImportProjects|Cancelling project import failed"
+msgstr ""
+
+msgid "ImportProjects|Cancelling project import failed: %{reason}"
+msgstr ""
+
msgid "ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}"
msgstr ""
msgid "ImportProjects|Import repositories"
msgstr ""
+msgid "ImportProjects|Imported files will be kept. You can import this repository again later."
+msgstr ""
+
msgid "ImportProjects|Importing the project failed"
msgstr ""
diff --git a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js
index 37cd0a609bdbc85b3569addbbb28e4bf8c914228..51f82dab381af907a13048ceeaeabd98fc1a50f9 100644
--- a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js
+++ b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js
@@ -59,7 +59,6 @@ describe('ImportProjectsTable', () => {
actions: {
fetchRepos: fetchReposFn,
fetchJobs: jest.fn(),
- fetchNamespaces: jest.fn(),
importAll: importAllFn,
stopJobsPolling: jest.fn(),
clearJobsEtagPoll: jest.fn(),
diff --git a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js
index f759e0c029a79072f1c1fdc2678a8b2c90df07d7..d686036781f90b79ff925f89de79c95346556cb7 100644
--- a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js
+++ b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js
@@ -10,6 +10,7 @@ import ProviderRepoTableRow from '~/import_entities/import_projects/components/p
describe('ProviderRepoTableRow', () => {
let wrapper;
const fetchImport = jest.fn();
+ const cancelImport = jest.fn();
const setImportTarget = jest.fn();
const fakeImportTarget = {
targetNamespace: 'target',
@@ -24,7 +25,7 @@ describe('ProviderRepoTableRow', () => {
getters: {
getImportTarget: () => () => fakeImportTarget,
},
- actions: { fetchImport, setImportTarget },
+ actions: { fetchImport, cancelImport, setImportTarget },
});
return store;
@@ -36,6 +37,14 @@ describe('ProviderRepoTableRow', () => {
return buttons.length ? buttons.at(0) : buttons;
};
+ const findCancelButton = () => {
+ const buttons = wrapper
+ .findAllComponents(GlButton)
+ .filter((node) => node.attributes('aria-label') === 'Cancel');
+
+ return buttons.length ? buttons.at(0) : buttons;
+ };
+
function mountComponent(props) {
Vue.use(Vuex);
@@ -110,6 +119,52 @@ describe('ProviderRepoTableRow', () => {
});
});
+ describe('when rendering importing project', () => {
+ const repo = {
+ importSource: {
+ id: 'remote-1',
+ fullName: 'fullName',
+ providerLink: 'providerLink',
+ },
+ importedProject: {
+ id: 1,
+ fullPath: 'fullPath',
+ importSource: 'importSource',
+ importStatus: STATUSES.STARTED,
+ },
+ };
+
+ describe('when cancelable is true', () => {
+ beforeEach(() => {
+ mountComponent({ repo, cancelable: true });
+ });
+
+ it('shows cancel button', () => {
+ expect(findCancelButton().isVisible()).toBe(true);
+ });
+
+ it('cancels import when clicking cancel button', async () => {
+ findCancelButton().vm.$emit('click');
+
+ await nextTick();
+
+ expect(cancelImport).toHaveBeenCalledWith(expect.anything(), {
+ repoId: repo.importSource.id,
+ });
+ });
+ });
+
+ describe('when cancelable is false', () => {
+ beforeEach(() => {
+ mountComponent({ repo, cancelable: false });
+ });
+
+ it('hides cancel button', () => {
+ expect(findCancelButton().isVisible()).toBe(false);
+ });
+ });
+ });
+
describe('when rendering imported project', () => {
const FAKE_STATS = {};
diff --git a/spec/frontend/import_entities/import_projects/store/actions_spec.js b/spec/frontend/import_entities/import_projects/store/actions_spec.js
index 8e2e4d7c1ac6b791dc97b2d1eb4d86e4f87bd492..4b34c21daa38a70ca38c7c1bafd376f33e95cd28 100644
--- a/spec/frontend/import_entities/import_projects/store/actions_spec.js
+++ b/spec/frontend/import_entities/import_projects/store/actions_spec.js
@@ -13,6 +13,7 @@ import {
RECEIVE_IMPORT_SUCCESS,
RECEIVE_IMPORT_ERROR,
RECEIVE_JOBS_SUCCESS,
+ CANCEL_IMPORT_SUCCESS,
SET_PAGE,
SET_FILTER,
SET_PAGE_CURSORS,
@@ -28,6 +29,7 @@ const endpoints = {
reposPath: MOCK_ENDPOINT,
importPath: MOCK_ENDPOINT,
jobsPath: MOCK_ENDPOINT,
+ cancelPath: MOCK_ENDPOINT,
};
const {
@@ -36,6 +38,7 @@ const {
importAll,
fetchRepos,
fetchImport,
+ cancelImport,
fetchJobs,
setFilter,
} = actionsFactory({
@@ -55,14 +58,17 @@ describe('import_projects store actions', () => {
...state(),
defaultTargetNamespace,
repositories: [
- { importSource: { id: importRepoId, sanitizedName }, importStatus: STATUSES.NONE },
+ {
+ importSource: { id: importRepoId, sanitizedName },
+ importedProject: { importStatus: STATUSES.NONE },
+ },
{
importSource: { id: otherImportRepoId, sanitizedName: 's2' },
- importStatus: STATUSES.NONE,
+ importedProject: { importStatus: STATUSES.NONE },
},
{
importSource: { id: 3, sanitizedName: 's3', incompatible: true },
- importStatus: STATUSES.NONE,
+ importedProject: { importStatus: STATUSES.NONE },
},
],
provider: 'provider',
@@ -417,4 +423,51 @@ describe('import_projects store actions', () => {
);
});
});
+
+ describe('cancelImport', () => {
+ let mock;
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => mock.restore());
+
+ it('commits CANCEL_IMPORT_SUCCESS on success', async () => {
+ mock.onPost(MOCK_ENDPOINT).reply(200);
+
+ await testAction(
+ cancelImport,
+ { repoId: importRepoId },
+ localState,
+ [
+ {
+ type: CANCEL_IMPORT_SUCCESS,
+ payload: { repoId: 1 },
+ },
+ ],
+ [],
+ );
+ });
+
+ it('shows generic error message on an unsuccessful request', async () => {
+ mock.onPost(MOCK_ENDPOINT).reply(500);
+
+ await testAction(cancelImport, { repoId: importRepoId }, localState, [], []);
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: 'Cancelling project import failed',
+ });
+ });
+
+ it('shows detailed error message on an unsuccessful request with errors fields in response', async () => {
+ const ERROR_MESSAGE = 'dummy';
+ mock.onPost(MOCK_ENDPOINT).reply(500, { errors: ERROR_MESSAGE });
+
+ await testAction(cancelImport, { repoId: importRepoId }, localState, [], []);
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: `Cancelling project import failed: ${ERROR_MESSAGE}`,
+ });
+ });
+ });
});
diff --git a/spec/frontend/import_entities/import_projects/store/mutations_spec.js b/spec/frontend/import_entities/import_projects/store/mutations_spec.js
index 16c6d74d1e8964ad6b1167086bd611dd5c9e771f..90fc9e5af9221b225c59f50e3836fb073b513cb9 100644
--- a/spec/frontend/import_entities/import_projects/store/mutations_spec.js
+++ b/spec/frontend/import_entities/import_projects/store/mutations_spec.js
@@ -318,4 +318,24 @@ describe('import_projects store mutations', () => {
expect(state.pageInfo).toEqual({ ...NEW_CURSORS, page: 1 });
});
});
+
+ describe(`${types.CANCEL_IMPORT_SUCCESS}`, () => {
+ const payload = { repoId: 1 };
+
+ beforeEach(() => {
+ state = {
+ repositories: [
+ {
+ importSource: { id: 1 },
+ importedProject: { importStatus: STATUSES.NONE },
+ },
+ ],
+ };
+ mutations[types.CANCEL_IMPORT_SUCCESS](state, payload);
+ });
+
+ it('updates project status', () => {
+ expect(state.repositories[0].importedProject.importStatus).toBe(STATUSES.CANCELED);
+ });
+ });
});
|