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); + }); + }); });