Merge branch 'winh-delete-milestone-modal' into 'master'
Add modal for deleting a milestone Closes #41314 See merge request gitlab-org/gitlab-ce!16229
This commit is contained in:
commit
f9b946c1c9
18 changed files with 353 additions and 24 deletions
|
@ -89,6 +89,11 @@ import SearchAutocomplete from './search_autocomplete';
|
|||
.then(callDefault)
|
||||
.catch(fail);
|
||||
break;
|
||||
case 'projects:milestones:index':
|
||||
import('./pages/projects/milestones/index')
|
||||
.then(callDefault)
|
||||
.catch(fail);
|
||||
break;
|
||||
case 'projects:milestones:show':
|
||||
import('./pages/projects/milestones/show')
|
||||
.then(callDefault)
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
import initMilestonesShow from '~/pages/init_milestones_show';
|
||||
import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show';
|
||||
|
||||
export default initMilestonesShow;
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
<script>
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
|
||||
import Flash from '~/flash';
|
||||
import modal from '~/vue_shared/components/modal.vue';
|
||||
import { n__, s__, sprintf } from '~/locale';
|
||||
import { redirectTo } from '~/lib/utils/url_utility';
|
||||
import eventHub from '../event_hub';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
modal,
|
||||
},
|
||||
props: {
|
||||
issueCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
mergeRequestCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
milestoneId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
milestoneTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
milestoneUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
text() {
|
||||
const milestoneTitle = sprintf('<strong>%{milestoneTitle}</strong>', { milestoneTitle: this.milestoneTitle });
|
||||
|
||||
if (this.issueCount === 0 && this.mergeRequestCount === 0) {
|
||||
return sprintf(
|
||||
s__(`Milestones|
|
||||
You’re about to permanently delete the milestone %{milestoneTitle} from this project.
|
||||
%{milestoneTitle} is not currently used in any issues or merge requests.`),
|
||||
{
|
||||
milestoneTitle,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
s__(`Milestones|
|
||||
You’re about to permanently delete the milestone %{milestoneTitle} from this project and remove it from %{issuesWithCount} and %{mergeRequestsWithCount}.
|
||||
Once deleted, it cannot be undone or recovered.`),
|
||||
{
|
||||
milestoneTitle,
|
||||
issuesWithCount: n__('%d issue', '%d issues', this.issueCount),
|
||||
mergeRequestsWithCount: n__('%d merge request', '%d merge requests', this.mergeRequestCount),
|
||||
},
|
||||
false,
|
||||
);
|
||||
},
|
||||
title() {
|
||||
return sprintf(s__('Milestones|Delete milestone %{milestoneTitle}?'), { milestoneTitle: this.milestoneTitle });
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onSubmit() {
|
||||
eventHub.$emit('deleteMilestoneModal.requestStarted', this.milestoneUrl);
|
||||
|
||||
return axios.delete(this.milestoneUrl)
|
||||
.then((response) => {
|
||||
eventHub.$emit('deleteMilestoneModal.requestFinished', { milestoneUrl: this.milestoneUrl, successful: true });
|
||||
|
||||
// follow the rediect to milestones overview page
|
||||
redirectTo(response.request.responseURL);
|
||||
})
|
||||
.catch((error) => {
|
||||
eventHub.$emit('deleteMilestoneModal.requestFinished', { milestoneUrl: this.milestoneUrl, successful: false });
|
||||
|
||||
if (error.response && error.response.status === 404) {
|
||||
Flash(sprintf(s__('Milestones|Milestone %{milestoneTitle} was not found'), { milestoneTitle: this.milestoneTitle }));
|
||||
} else {
|
||||
Flash(sprintf(s__('Milestones|Failed to delete milestone %{milestoneTitle}'), { milestoneTitle: this.milestoneTitle }));
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<modal
|
||||
id="delete-milestone-modal"
|
||||
:title="title"
|
||||
:text="text"
|
||||
kind="danger"
|
||||
:primary-button-label="s__('Milestones|Delete milestone')"
|
||||
@submit="onSubmit">
|
||||
|
||||
<template
|
||||
slot="body"
|
||||
slot-scope="props">
|
||||
<p v-html="props.text"></p>
|
||||
</template>
|
||||
|
||||
</modal>
|
||||
</template>
|
|
@ -0,0 +1,3 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
export default new Vue();
|
88
app/assets/javascripts/pages/milestones/shared/index.js
Normal file
88
app/assets/javascripts/pages/milestones/shared/index.js
Normal file
|
@ -0,0 +1,88 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import Translate from '~/vue_shared/translate';
|
||||
|
||||
import deleteMilestoneModal from './components/delete_milestone_modal.vue';
|
||||
import eventHub from './event_hub';
|
||||
|
||||
export default () => {
|
||||
Vue.use(Translate);
|
||||
|
||||
const onRequestFinished = ({ milestoneUrl, successful }) => {
|
||||
const button = document.querySelector(`.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`);
|
||||
|
||||
if (!successful) {
|
||||
button.removeAttribute('disabled');
|
||||
}
|
||||
|
||||
button.querySelector('.js-loading-icon').classList.add('hidden');
|
||||
};
|
||||
|
||||
const onRequestStarted = (milestoneUrl) => {
|
||||
const button = document.querySelector(`.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`);
|
||||
button.setAttribute('disabled', '');
|
||||
button.querySelector('.js-loading-icon').classList.remove('hidden');
|
||||
eventHub.$once('deleteMilestoneModal.requestFinished', onRequestFinished);
|
||||
};
|
||||
|
||||
const onDeleteButtonClick = (event) => {
|
||||
const button = event.currentTarget;
|
||||
const modalProps = {
|
||||
milestoneId: parseInt(button.dataset.milestoneId, 10),
|
||||
milestoneTitle: button.dataset.milestoneTitle,
|
||||
milestoneUrl: button.dataset.milestoneUrl,
|
||||
issueCount: parseInt(button.dataset.milestoneIssueCount, 10),
|
||||
mergeRequestCount: parseInt(button.dataset.milestoneMergeRequestCount, 10),
|
||||
};
|
||||
eventHub.$once('deleteMilestoneModal.requestStarted', onRequestStarted);
|
||||
eventHub.$emit('deleteMilestoneModal.props', modalProps);
|
||||
};
|
||||
|
||||
const deleteMilestoneButtons = document.querySelectorAll('.js-delete-milestone-button');
|
||||
for (let i = 0; i < deleteMilestoneButtons.length; i += 1) {
|
||||
const button = deleteMilestoneButtons[i];
|
||||
button.addEventListener('click', onDeleteButtonClick);
|
||||
}
|
||||
|
||||
eventHub.$once('deleteMilestoneModal.mounted', () => {
|
||||
for (let i = 0; i < deleteMilestoneButtons.length; i += 1) {
|
||||
const button = deleteMilestoneButtons[i];
|
||||
button.removeAttribute('disabled');
|
||||
}
|
||||
});
|
||||
|
||||
return new Vue({
|
||||
el: '#delete-milestone-modal',
|
||||
components: {
|
||||
deleteMilestoneModal,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
modalProps: {
|
||||
milestoneId: -1,
|
||||
milestoneTitle: '',
|
||||
milestoneUrl: '',
|
||||
issueCount: -1,
|
||||
mergeRequestCount: -1,
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
eventHub.$on('deleteMilestoneModal.props', this.setModalProps);
|
||||
eventHub.$emit('deleteMilestoneModal.mounted');
|
||||
},
|
||||
beforeDestroy() {
|
||||
eventHub.$off('deleteMilestoneModal.props', this.setModalProps);
|
||||
},
|
||||
methods: {
|
||||
setModalProps(modalProps) {
|
||||
this.modalProps = modalProps;
|
||||
},
|
||||
},
|
||||
render(createElement) {
|
||||
return createElement(deleteMilestoneModal, {
|
||||
props: this.modalProps,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
import milestones from '~/pages/milestones/shared';
|
||||
|
||||
export default milestones;
|
|
@ -1,3 +1,7 @@
|
|||
import initMilestonesShow from '~/pages/init_milestones_show';
|
||||
import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show';
|
||||
import milestones from '~/pages/milestones/shared';
|
||||
|
||||
export default initMilestonesShow;
|
||||
export default () => {
|
||||
initMilestonesShow();
|
||||
milestones();
|
||||
};
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
.modal-body {
|
||||
background-color: $modal-body-bg;
|
||||
line-height: $line-height-base;
|
||||
min-height: $modal-body-height;
|
||||
position: relative;
|
||||
padding: #{3 * $grid-size} #{2 * $grid-size};
|
||||
|
|
|
@ -83,7 +83,7 @@ class Projects::MilestonesController < Projects::ApplicationController
|
|||
Milestones::DestroyService.new(project, current_user).execute(milestone)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to namespace_project_milestones_path, status: 302 }
|
||||
format.html { redirect_to namespace_project_milestones_path, status: 303 }
|
||||
format.js { head :ok }
|
||||
end
|
||||
end
|
||||
|
|
|
@ -12,6 +12,8 @@
|
|||
New milestone
|
||||
|
||||
.milestones
|
||||
#delete-milestone-modal
|
||||
|
||||
%ul.content-list
|
||||
= render @milestones
|
||||
|
||||
|
|
|
@ -35,8 +35,18 @@
|
|||
- else
|
||||
= link_to 'Reopen milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped"
|
||||
|
||||
= link_to project_milestone_path(@project, @milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-grouped btn-danger" do
|
||||
Delete
|
||||
%button.js-delete-milestone-button.btn.btn-grouped.btn-danger{ data: { toggle: 'modal',
|
||||
target: '#delete-milestone-modal',
|
||||
milestone_id: @milestone.id,
|
||||
milestone_title: markdown_field(@milestone, :title),
|
||||
milestone_url: project_milestone_path(@project, @milestone),
|
||||
milestone_issue_count: @milestone.issues.count,
|
||||
milestone_merge_request_count: @milestone.merge_requests.count },
|
||||
disabled: true }
|
||||
= _('Delete')
|
||||
= icon('spin spinner', class: 'js-loading-icon hidden' )
|
||||
|
||||
#delete-milestone-modal
|
||||
|
||||
%a.btn.btn-default.btn-grouped.pull-right.visible-xs-block.js-sidebar-toggle{ href: "#" }
|
||||
= icon('angle-double-left')
|
||||
|
|
|
@ -56,6 +56,13 @@
|
|||
|
||||
= link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-xs btn-close btn-grouped"
|
||||
|
||||
= link_to project_milestone_path(milestone.project, milestone), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-xs btn-remove btn-grouped" do
|
||||
Delete
|
||||
|
||||
%button.js-delete-milestone-button.btn.btn-xs.btn-grouped.btn-danger{ data: { toggle: 'modal',
|
||||
target: '#delete-milestone-modal',
|
||||
milestone_id: milestone.id,
|
||||
milestone_title: markdown_field(milestone, :title),
|
||||
milestone_url: project_milestone_path(milestone.project, milestone),
|
||||
milestone_issue_count: milestone.issues.count,
|
||||
milestone_merge_request_count: milestone.merge_requests.count },
|
||||
disabled: true }
|
||||
= _('Delete')
|
||||
= icon('spin spinner', class: 'js-loading-icon hidden' )
|
||||
|
|
5
changelogs/unreleased/winh-delete-milestone-modal.yml
Normal file
5
changelogs/unreleased/winh-delete-milestone-modal.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add modal for deleting a milestone
|
||||
merge_request: 16229
|
||||
author:
|
||||
type: other
|
|
@ -18,12 +18,15 @@ Feature: Project Issues Milestones
|
|||
Given I click link "New Milestone"
|
||||
And I submit new milestone "v2.3"
|
||||
Then I should see milestone "v2.3"
|
||||
Given I click link to remove milestone
|
||||
Given I click button to remove milestone
|
||||
And I confirm in modal
|
||||
When I visit project "Shop" activity page
|
||||
Then I should see deleted milestone activity
|
||||
|
||||
@javascript
|
||||
Scenario: I delete new milestone
|
||||
Given I click link to remove milestone
|
||||
Given I click button to remove milestone
|
||||
And I confirm in modal
|
||||
And I should see no milestones
|
||||
|
||||
@javascript
|
||||
|
|
|
@ -3,7 +3,6 @@ class Spinach::Features::ProjectIssuesMilestones < Spinach::FeatureSteps
|
|||
include SharedProject
|
||||
include SharedPaths
|
||||
include SharedMarkdown
|
||||
include CapybaraHelpers
|
||||
|
||||
step 'I should see milestone "v2.2"' do
|
||||
milestone = @project.milestones.find_by(title: "v2.2")
|
||||
|
@ -65,8 +64,12 @@ class Spinach::Features::ProjectIssuesMilestones < Spinach::FeatureSteps
|
|||
expect(page).to have_selector('#tab-issues li.issuable-row', count: 4)
|
||||
end
|
||||
|
||||
step 'I click link to remove milestone' do
|
||||
confirm_modal_if_present { click_link 'Delete' }
|
||||
step 'I click button to remove milestone' do
|
||||
click_button 'Delete'
|
||||
end
|
||||
|
||||
step 'I confirm in modal' do
|
||||
click_button 'Delete milestone'
|
||||
end
|
||||
|
||||
step 'I should see no milestones' do
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
module CapybaraHelpers
|
||||
def confirm_modal_if_present
|
||||
if Capybara.current_driver == Capybara.javascript_driver
|
||||
accept_confirm { yield }
|
||||
return
|
||||
end
|
||||
|
||||
yield
|
||||
end
|
||||
end
|
|
@ -0,0 +1,95 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import deleteMilestoneModal from '~/pages/milestones/shared/components/delete_milestone_modal.vue';
|
||||
import eventHub from '~/pages/milestones/shared/event_hub';
|
||||
import * as urlUtility from '~/lib/utils/url_utility';
|
||||
|
||||
import mountComponent from '../../../../helpers/vue_mount_component_helper';
|
||||
|
||||
describe('delete_milestone_modal.vue', () => {
|
||||
const Component = Vue.extend(deleteMilestoneModal);
|
||||
const props = {
|
||||
issueCount: 1,
|
||||
mergeRequestCount: 2,
|
||||
milestoneId: 3,
|
||||
milestoneTitle: 'my milestone title',
|
||||
milestoneUrl: `${gl.TEST_HOST}/delete_milestone_modal.vue/milestone`,
|
||||
};
|
||||
let vm;
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
describe('onSubmit', () => {
|
||||
beforeEach(() => {
|
||||
vm = mountComponent(Component, props);
|
||||
spyOn(eventHub, '$emit');
|
||||
});
|
||||
|
||||
it('deletes milestone and redirects to overview page', (done) => {
|
||||
const responseURL = `${gl.TEST_HOST}/delete_milestone_modal.vue/milestoneOverview`;
|
||||
spyOn(axios, 'delete').and.callFake((url) => {
|
||||
expect(url).toBe(props.milestoneUrl);
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestStarted', props.milestoneUrl);
|
||||
eventHub.$emit.calls.reset();
|
||||
return Promise.resolve({
|
||||
request: {
|
||||
responseURL,
|
||||
},
|
||||
});
|
||||
});
|
||||
const redirectSpy = spyOn(urlUtility, 'redirectTo');
|
||||
|
||||
vm.onSubmit()
|
||||
.then(() => {
|
||||
expect(redirectSpy).toHaveBeenCalledWith(responseURL);
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', { milestoneUrl: props.milestoneUrl, successful: true });
|
||||
})
|
||||
.then(done)
|
||||
.catch(done.fail);
|
||||
});
|
||||
|
||||
it('displays error if deleting milestone failed', (done) => {
|
||||
const dummyError = new Error('deleting milestone failed');
|
||||
dummyError.response = { status: 418 };
|
||||
spyOn(axios, 'delete').and.callFake((url) => {
|
||||
expect(url).toBe(props.milestoneUrl);
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestStarted', props.milestoneUrl);
|
||||
eventHub.$emit.calls.reset();
|
||||
return Promise.reject(dummyError);
|
||||
});
|
||||
const redirectSpy = spyOn(urlUtility, 'redirectTo');
|
||||
|
||||
vm.onSubmit()
|
||||
.catch((error) => {
|
||||
expect(error).toBe(dummyError);
|
||||
expect(redirectSpy).not.toHaveBeenCalled();
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', { milestoneUrl: props.milestoneUrl, successful: false });
|
||||
})
|
||||
.then(done)
|
||||
.catch(done.fail);
|
||||
});
|
||||
});
|
||||
|
||||
describe('text', () => {
|
||||
it('contains the issue and milestone count', () => {
|
||||
vm = mountComponent(Component, props);
|
||||
const value = vm.text;
|
||||
|
||||
expect(value).toContain('remove it from 1 issue and 2 merge requests');
|
||||
});
|
||||
|
||||
it('contains neither issue nor milestone count', () => {
|
||||
vm = mountComponent(Component, { ...props,
|
||||
issueCount: 0,
|
||||
mergeRequestCount: 0,
|
||||
});
|
||||
|
||||
const value = vm.text;
|
||||
|
||||
expect(value).toContain('is not currently used');
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue