diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 18462cc24c7b265ca984381b98fddeae2b76d5b7..a259118003901146072ae6e088605d41325e5777 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -483,6 +483,16 @@ export const historyPushState = newUrl => { window.history.pushState({}, document.title, newUrl); }; +/** + * Based on the current location and the string parameters provided + * overwrites the current entry in the history without reloading the page. + * + * @param {String} param + */ +export const historyReplaceState = newUrl => { + window.history.replaceState({}, document.title, newUrl); +}; + /** * Returns true for a String value of "true" and false otherwise. * This is the opposite of Boolean(...).toString(). diff --git a/app/assets/javascripts/pages/projects/pipelines/index/index.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js index fd72d2ddbe06296cfed33eed34740a833e778e63..4b4a274794dc46b6f691b1c1ed3a515f043daa6f 100644 --- a/app/assets/javascripts/pages/projects/pipelines/index/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js @@ -1,10 +1,18 @@ import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; +import { doesHashExistInUrl } from '~/lib/utils/url_utility'; +import { + parseBoolean, + historyReplaceState, + buildUrlWithCurrentLocation, +} from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; import PipelinesStore from '../../../../pipelines/stores/pipelines_store'; import pipelinesComponent from '../../../../pipelines/components/pipelines.vue'; import Translate from '../../../../vue_shared/translate'; -import { parseBoolean } from '../../../../lib/utils/common_utils'; Vue.use(Translate); +Vue.use(GlToast); document.addEventListener( 'DOMContentLoaded', @@ -21,6 +29,11 @@ document.addEventListener( }, created() { this.dataset = document.querySelector(this.$options.el).dataset; + + if (doesHashExistInUrl('delete_success')) { + this.$toast.show(__('The pipeline has been deleted')); + historyReplaceState(buildUrlWithCurrentLocation()); + } }, render(createElement) { return createElement('pipelines-component', { diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 39afa87afc37cf1ee24823972d93fff2f9813e75..726bba7f9f471ae7675022f41dafb136c09b43b2 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -1,14 +1,17 @@ @@ -88,8 +119,21 @@ export default { :user="pipeline.user" :actions="actions" item-name="Pipeline" - @actionClicked="postAction" + @actionClicked="onActionClicked" /> + + + + + {{ deleteModalConfirmationText }} + + diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index d8dbc3c2454d6ef7f8322d299f58132832fbd6bf..c874c4c6fdd1060f51ece92cc32a59a12f7522a1 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import Flash from '~/flash'; import Translate from '~/vue_shared/translate'; import { __ } from '~/locale'; +import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; import pipelineGraph from './components/graph/graph_component.vue'; import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import PipelinesMediator from './pipeline_details_mediator'; @@ -62,9 +63,11 @@ export default () => { }, created() { eventHub.$on('headerPostAction', this.postAction); + eventHub.$on('headerDeleteAction', this.deleteAction); }, beforeDestroy() { eventHub.$off('headerPostAction', this.postAction); + eventHub.$off('headerDeleteAction', this.deleteAction); }, methods: { postAction(action) { @@ -73,6 +76,13 @@ export default () => { .then(() => this.mediator.refreshPipeline()) .catch(() => Flash(__('An error occurred while making the request.'))); }, + deleteAction(action) { + this.mediator.stopPipelinePoll(); + this.mediator.service + .deleteAction(action.path) + .then(({ request }) => redirectTo(setUrlFragment(request.responseURL, 'delete_success'))) + .catch(() => Flash(__('An error occurred while deleting the pipeline.'))); + }, }, render(createElement) { return createElement('pipeline-header', { diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js index bf021a0b447e70bf5ca558aa8a6fdb2573b5a85a..f3387f00fc1fff8392e627321d28d3d385ffd854 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js +++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js @@ -35,7 +35,7 @@ export default class pipelinesMediator { if (!Visibility.hidden()) { this.poll.restart(); } else { - this.poll.stop(); + this.stopPipelinePoll(); } }); } @@ -51,7 +51,7 @@ export default class pipelinesMediator { } refreshPipeline() { - this.poll.stop(); + this.stopPipelinePoll(); return this.service .getPipeline() @@ -64,6 +64,10 @@ export default class pipelinesMediator { ); } + stopPipelinePoll() { + this.poll.stop(); + } + /** * Backend expects paramets in the following format: `expanded[]=id&expanded[]=id` */ diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js index e44eb9cdfd11201a8b99b6fdf412c67488af2c9d..ba2830ec59614748bc48c9d22678a67a1f520eaf 100644 --- a/app/assets/javascripts/pipelines/services/pipeline_service.js +++ b/app/assets/javascripts/pipelines/services/pipeline_service.js @@ -9,6 +9,11 @@ export default class PipelineService { return axios.get(this.pipeline, { params }); } + // eslint-disable-next-line class-methods-use-this + deleteAction(endpoint) { + return axios.delete(`${endpoint}.json`); + } + // eslint-disable-next-line class-methods-use-this postAction(endpoint) { return axios.post(`${endpoint}.json`); diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index c652a684d7ce8dff7b210913ef902e6b6d371349..dba4a9231a1704b5fa4e6c7aa1055939f53668a6 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -117,28 +117,7 @@ export default { - - {{ action.label }} - - - - {{ action.label }} - - (*) { can_delete? } do |pipeline| + project_pipeline_path(pipeline.project, pipeline) + end + expose :failed_builds, if: -> (*) { can_retry? }, using: JobEntity do |pipeline| pipeline.failed_builds end @@ -95,6 +99,10 @@ def can_cancel? pipeline.cancelable? end + def can_delete? + can?(request.current_user, :destroy_pipeline, pipeline) + end + def has_presentable_merge_request? pipeline.triggered_by_merge_request? && can?(request.current_user, :read_merge_request, pipeline.merge_request) diff --git a/changelogs/unreleased/feat-pipeline-ui-deletion.yml b/changelogs/unreleased/feat-pipeline-ui-deletion.yml new file mode 100644 index 0000000000000000000000000000000000000000..630b0057fa9a71621482904a42cd0a4a3f16f415 --- /dev/null +++ b/changelogs/unreleased/feat-pipeline-ui-deletion.yml @@ -0,0 +1,5 @@ +--- +title: Add pipeline deletion button to pipeline details page +merge_request: 21365 +author: Fabio Huser +type: added diff --git a/config/routes/project.rb b/config/routes/project.rb index df1b6cd5032257557e509ca4e6972c06a8e93585..f339be7d0f55ae7cb49c806024dfdb37b85690c7 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -343,7 +343,7 @@ draw :merge_requests end - resources :pipelines, only: [:index, :new, :create, :show] do + resources :pipelines, only: [:index, :new, :create, :show, :destroy] do collection do resource :pipelines_settings, path: 'settings', only: [:show, :update] get :charts diff --git a/doc/ci/img/pipeline-delete.png b/doc/ci/img/pipeline-delete.png new file mode 100644 index 0000000000000000000000000000000000000000..65b42100099258812eb6c2505842cd85b50ae403 Binary files /dev/null and b/doc/ci/img/pipeline-delete.png differ diff --git a/doc/ci/pipelines.md b/doc/ci/pipelines.md index d1e50039417a7f7c4016fea0eacfa8b37ec71eb1..4d942ea3d54d8551fbdc98c20e3134e0005fc3ef 100644 --- a/doc/ci/pipelines.md +++ b/doc/ci/pipelines.md @@ -305,12 +305,14 @@ For example, the query string ### Accessing pipelines You can find the current and historical pipeline runs under your project's -**CI/CD > Pipelines** page. Clicking on a pipeline will show the jobs that were run for -that pipeline. +**CI/CD > Pipelines** page. You can also access pipelines for a merge request by navigating +to its **Pipelines** tab.  -You can also access pipelines for a merge request by navigating to its **Pipelines** tab. +Clicking on a pipeline will bring you to the **Pipeline Details** page and show +the jobs that were run for that pipeline. From here you can cancel a running pipeline, +retry jobs on a failed pipeline, or [delete a pipeline](#deleting-a-single-pipeline). ### Accessing individual jobs @@ -410,6 +412,20 @@ This functionality is only available: - For users with at least Developer access. - If the the stage contains [manual actions](#manual-actions-from-pipeline-graphs). +### Deleting a single pipeline + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/24851) in GitLab 12.7. + +Users with [owner permissions](../user/permissions.md) in a project can delete a pipeline +by clicking on the pipeline in the **CI/CD > Pipelines** to get to the **Pipeline Details** +page, then using the **Delete** button. + + + +CAUTION: **Warning:** +Deleting a pipeline will expire all pipeline caches, and delete all related objects, +such as builds, logs, artifacts, and triggers. **This action cannot be undone.** + ## Most Recent Pipeline > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/50499) in GitLab 12.3. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 8967ce8b994d0715346691ac5afe105809187a46..0c355da26e669794b17ae9b9425cc14f10039ba4 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1631,6 +1631,9 @@ msgstr "" msgid "An error occurred while deleting the comment" msgstr "" +msgid "An error occurred while deleting the pipeline." +msgstr "" + msgid "An error occurred while detecting host keys" msgstr "" @@ -2092,6 +2095,9 @@ msgstr "" msgid "Are you sure you want to delete this pipeline schedule?" msgstr "" +msgid "Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone." +msgstr "" + msgid "Are you sure you want to erase this build?" msgstr "" @@ -5732,6 +5738,9 @@ msgstr "" msgid "Delete list" msgstr "" +msgid "Delete pipeline" +msgstr "" + msgid "Delete snippet" msgstr "" @@ -18077,6 +18086,9 @@ msgstr "" msgid "The phase of the development lifecycle." msgstr "" +msgid "The pipeline has been deleted" +msgstr "" + msgid "The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user." msgstr "" diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index 902a84a843b7fb4c84ea161fb63ca890f0813bfd..4cc5b3cba7c7dcec1b5a4be7184f8ae47f638094 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -740,4 +740,51 @@ def get_stage_ajax(name) expect(response).to have_gitlab_http_status(404) end end + + describe 'DELETE #destroy' do + let!(:project) { create(:project, :private, :repository) } + let!(:pipeline) { create(:ci_pipeline, :failed, project: project) } + let!(:build) { create(:ci_build, :failed, pipeline: pipeline) } + + context 'when user has ability to delete pipeline' do + before do + sign_in(project.owner) + end + + it 'deletes pipeline and redirects' do + delete_pipeline + + expect(response).to have_gitlab_http_status(303) + + expect(Ci::Build.exists?(build.id)).to be_falsy + expect(Ci::Pipeline.exists?(pipeline.id)).to be_falsy + end + + context 'and builds are disabled' do + let(:feature) { ProjectFeature::DISABLED } + + it 'fails to delete pipeline' do + delete_pipeline + + expect(response).to have_gitlab_http_status(404) + end + end + end + + context 'when user has no privileges' do + it 'fails to delete pipeline' do + delete_pipeline + + expect(response).to have_gitlab_http_status(403) + end + end + + def delete_pipeline + delete :destroy, params: { + namespace_id: project.namespace, + project_id: project, + id: pipeline.id + } + end + end end diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index 4b97c58d920d4a7dbd8bc3fb570171227ea30b22..198af65c3619571b5e47cfec8b73394057423bc1 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -59,7 +59,8 @@ describe 'GET /:project/pipelines/:id' do include_context 'pipeline builds' - let(:project) { create(:project, :repository) } + let(:group) { create(:group) } + let(:project) { create(:project, :repository, group: group) } let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id, user: user) } subject(:visit_pipeline) { visit project_pipeline_path(project, pipeline) } @@ -329,6 +330,32 @@ end end + context 'deleting pipeline' do + context 'when user can not delete' do + before do + visit_pipeline + end + + it { expect(page).not_to have_button('Delete') } + end + + context 'when deleting' do + before do + group.add_owner(user) + + visit_pipeline + + click_button 'Delete' + click_button 'Delete pipeline' + end + + it 'redirects to pipeline overview page', :sidekiq_might_not_need_inline do + expect(page).to have_content('The pipeline has been deleted') + expect(current_path).to eq(project_pipelines_path(project)) + end + end + end + context 'when pipeline ref does not exist in repository anymore' do let(:pipeline) do create(:ci_empty_pipeline, project: project, diff --git a/spec/javascripts/pipelines/header_component_spec.js b/spec/javascripts/pipelines/header_component_spec.js index 556a0976b293be3a724ec3207751af762ef51b00..8c033447ce441bb7d060f66cf9d81ba75eddba31 100644 --- a/spec/javascripts/pipelines/header_component_spec.js +++ b/spec/javascripts/pipelines/header_component_spec.js @@ -34,6 +34,7 @@ describe('Pipeline details header', () => { avatar_url: 'link', }, retry_path: 'path', + delete_path: 'path', }, isLoading: false, }; @@ -55,12 +56,22 @@ describe('Pipeline details header', () => { }); describe('action buttons', () => { - it('should call postAction when button action is clicked', () => { + it('should call postAction when retry button action is clicked', done => { eventHub.$on('headerPostAction', action => { expect(action.path).toEqual('path'); + done(); }); - vm.$el.querySelector('button').click(); + vm.$el.querySelector('.js-retry-button').click(); + }); + + it('should fire modal event when delete button action is clicked', done => { + vm.$root.$on('bv::modal::show', action => { + expect(action.componentId).toEqual('pipeline-delete-modal'); + done(); + }); + + vm.$el.querySelector('.js-btn-delete-pipeline').click(); }); }); }); diff --git a/spec/javascripts/vue_shared/components/header_ci_component_spec.js b/spec/javascripts/vue_shared/components/header_ci_component_spec.js index 7bd5e5a64b1ab8886c4a9b4c1a4c97351753280a..ea2eed2886afb4caea9f298b2ab8a767a8154c12 100644 --- a/spec/javascripts/vue_shared/components/header_ci_component_spec.js +++ b/spec/javascripts/vue_shared/components/header_ci_component_spec.js @@ -31,17 +31,9 @@ describe('Header CI Component', () => { { label: 'Retry', path: 'path', - type: 'button', cssClass: 'btn', isLoading: false, }, - { - label: 'Go', - path: 'path', - type: 'link', - cssClass: 'link', - isLoading: false, - }, ], hasSidebarButton: true, }; @@ -77,11 +69,10 @@ describe('Header CI Component', () => { }); it('should render provided actions', () => { - expect(vm.$el.querySelector('.btn').tagName).toEqual('BUTTON'); - expect(vm.$el.querySelector('.btn').textContent.trim()).toEqual(props.actions[0].label); - expect(vm.$el.querySelector('.link').tagName).toEqual('A'); - expect(vm.$el.querySelector('.link').textContent.trim()).toEqual(props.actions[1].label); - expect(vm.$el.querySelector('.link').getAttribute('href')).toEqual(props.actions[0].path); + const btn = vm.$el.querySelector('.btn'); + + expect(btn.tagName).toEqual('BUTTON'); + expect(btn.textContent.trim()).toEqual(props.actions[0].label); }); it('should show loading icon', done => { diff --git a/spec/serializers/pipeline_entity_spec.rb b/spec/serializers/pipeline_entity_spec.rb index d95aaf3d104539ec6cc22315f7df6537bc950b64..75f3bdfcc9eb9f211d4cf91762954b3caf81f209 100644 --- a/spec/serializers/pipeline_entity_spec.rb +++ b/spec/serializers/pipeline_entity_spec.rb @@ -123,6 +123,26 @@ end end + context 'delete path' do + context 'user has ability to delete pipeline' do + let(:project) { create(:project, namespace: user.namespace) } + let(:pipeline) { create(:ci_pipeline, project: project) } + + it 'contains delete path' do + expect(subject[:delete_path]).to be_present + end + end + + context 'user does not have ability to delete pipeline' do + let(:project) { create(:project) } + let(:pipeline) { create(:ci_pipeline, project: project) } + + it 'does not contain delete path' do + expect(subject).not_to have_key(:delete_path) + end + end + end + context 'when pipeline ref is empty' do let(:pipeline) { create(:ci_empty_pipeline) }
+ {{ deleteModalConfirmationText }} +