Commit 329c4be9 authored by Nick Thomas's avatar Nick Thomas

Merge branch 'feat/pipeline-ui-deletion' into 'master'

Add pipeline deletion button to UI

Closes #24851

See merge request gitlab-org/gitlab!21365
parents 3dcfec32 00fc0fc9
......@@ -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().
......
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', {
......
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
import ciHeader from '../../vue_shared/components/header_ci_component.vue';
import eventHub from '../event_hub';
import { __ } from '~/locale';
const DELETE_MODAL_ID = 'pipeline-delete-modal';
export default {
name: 'PipelineHeaderSection',
components: {
ciHeader,
GlLoadingIcon,
GlModal,
},
props: {
pipeline: {
......@@ -33,6 +36,11 @@ export default {
shouldRenderContent() {
return !this.isLoading && Object.keys(this.pipeline).length;
},
deleteModalConfirmationText() {
return __(
'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.',
);
},
},
watch: {
......@@ -42,6 +50,13 @@ export default {
},
methods: {
onActionClicked(action) {
if (action.modal) {
this.$root.$emit('bv::show::modal', action.modal);
} else {
this.postAction(action);
}
},
postAction(action) {
const index = this.actions.indexOf(action);
......@@ -49,6 +64,13 @@ export default {
eventHub.$emit('headerPostAction', action);
},
deletePipeline() {
const index = this.actions.findIndex(action => action.modal === DELETE_MODAL_ID);
this.$set(this.actions[index], 'isLoading', true);
eventHub.$emit('headerDeleteAction', this.actions[index]);
},
getActions() {
const actions = [];
......@@ -58,7 +80,6 @@ export default {
label: __('Retry'),
path: this.pipeline.retry_path,
cssClass: 'js-retry-button btn btn-inverted-secondary',
type: 'button',
isLoading: false,
});
}
......@@ -68,7 +89,16 @@ export default {
label: __('Cancel running'),
path: this.pipeline.cancel_path,
cssClass: 'js-btn-cancel-pipeline btn btn-danger',
type: 'button',
isLoading: false,
});
}
if (this.pipeline.delete_path) {
actions.push({
label: __('Delete'),
path: this.pipeline.delete_path,
modal: DELETE_MODAL_ID,
cssClass: 'js-btn-delete-pipeline btn btn-danger btn-inverted',
isLoading: false,
});
}
......@@ -76,6 +106,7 @@ export default {
return actions;
},
},
DELETE_MODAL_ID,
};
</script>
<template>
......@@ -88,8 +119,21 @@ export default {
:user="pipeline.user"
:actions="actions"
item-name="Pipeline"
@actionClicked="postAction"
@actionClicked="onActionClicked"
/>
<gl-loading-icon v-if="isLoading" :size="2" class="prepend-top-default append-bottom-default" />
<gl-modal
:modal-id="$options.DELETE_MODAL_ID"
:title="__('Delete pipeline')"
:ok-title="__('Delete pipeline')"
ok-variant="danger"
@ok="deletePipeline()"
>
<p>
{{ deleteModalConfirmationText }}
</p>
</gl-modal>
</div>
</template>
......@@ -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', {
......
......@@ -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`
*/
......
......@@ -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`);
......
......@@ -117,28 +117,7 @@ export default {
<section v-if="actions.length" class="header-action-buttons">
<template v-for="(action, i) in actions">
<gl-link
v-if="action.type === 'link'"
:key="i"
:href="action.path"
:class="action.cssClass"
>
{{ action.label }}
</gl-link>
<gl-link
v-else-if="action.type === 'ujs-link'"
:key="i"
:href="action.path"
:class="action.cssClass"
data-method="post"
rel="nofollow"
>
{{ action.label }}
</gl-link>
<loading-button
v-else-if="action.type === 'button'"
:key="i"
:loading="action.isLoading"
:disabled="action.isLoading"
......
......@@ -80,6 +80,12 @@ class Projects::PipelinesController < Projects::ApplicationController
end
end
def destroy
::Ci::DestroyPipelineService.new(project, current_user).execute(pipeline)
redirect_to project_pipelines_path(project), status: :see_other
end
def builds
render_show
end
......
......@@ -77,6 +77,10 @@ class PipelineEntity < Grape::Entity
cancel_project_pipeline_path(pipeline.project, pipeline)
end
expose :delete_path, if: -> (*) { 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 @@ class PipelineEntity < Grape::Entity
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)
......
---
title: Add pipeline deletion button to pipeline details page
merge_request: 21365
author: Fabio Huser
type: added
......@@ -343,7 +343,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
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
......
......@@ -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.
![Pipelines index page](img/pipelines_index.png)
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.
![Pipeline Delete Button](img/pipeline-delete.png)
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.
......
......@@ -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 ""
......@@ -5765,6 +5771,9 @@ msgstr ""
msgid "Delete list"
msgstr ""
msgid "Delete pipeline"
msgstr ""
msgid "Delete snippet"
msgstr ""
......@@ -18128,6 +18137,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 ""
......
......@@ -740,4 +740,51 @@ describe Projects::PipelinesController do
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
......@@ -59,7 +59,8 @@ describe 'Pipeline', :js do
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 @@ describe 'Pipeline', :js do
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,
......
......@@ -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();
});
});
});
......@@ -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 => {
......
......@@ -123,6 +123,26 @@ describe PipelineEntity do
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) }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment