diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 89fc002d4ce..262ed3783fb 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -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)
diff --git a/app/assets/javascripts/pages/groups/milestones/show/index.js b/app/assets/javascripts/pages/groups/milestones/show/index.js
index 0c3ce848e3d..c9a18353f2e 100644
--- a/app/assets/javascripts/pages/groups/milestones/show/index.js
+++ b/app/assets/javascripts/pages/groups/milestones/show/index.js
@@ -1,3 +1,3 @@
-import initMilestonesShow from '~/pages/init_milestones_show';
+import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show';
export default initMilestonesShow;
diff --git a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
new file mode 100644
index 00000000000..c43e0a0490f
--- /dev/null
+++ b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/assets/javascripts/pages/milestones/shared/event_hub.js b/app/assets/javascripts/pages/milestones/shared/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/pages/milestones/shared/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/pages/milestones/shared/index.js b/app/assets/javascripts/pages/milestones/shared/index.js
new file mode 100644
index 00000000000..327e2cf569c
--- /dev/null
+++ b/app/assets/javascripts/pages/milestones/shared/index.js
@@ -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,
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/pages/init_milestones_show.js b/app/assets/javascripts/pages/milestones/shared/init_milestones_show.js
similarity index 100%
rename from app/assets/javascripts/pages/init_milestones_show.js
rename to app/assets/javascripts/pages/milestones/shared/init_milestones_show.js
diff --git a/app/assets/javascripts/pages/projects/milestones/index/index.js b/app/assets/javascripts/pages/projects/milestones/index/index.js
new file mode 100644
index 00000000000..8fb4d83d8a3
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/milestones/index/index.js
@@ -0,0 +1,3 @@
+import milestones from '~/pages/milestones/shared';
+
+export default milestones;
diff --git a/app/assets/javascripts/pages/projects/milestones/show/index.js b/app/assets/javascripts/pages/projects/milestones/show/index.js
index 0c3ce848e3d..35b5c9c2ced 100644
--- a/app/assets/javascripts/pages/projects/milestones/show/index.js
+++ b/app/assets/javascripts/pages/projects/milestones/show/index.js
@@ -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();
+};
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index 51ae09777fd..32b9894ae04 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -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};
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index 0f70efbce40..75b17d05e22 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -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
diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml
index fcbf7cb802b..6a7bc4b1888 100644
--- a/app/views/projects/milestones/index.html.haml
+++ b/app/views/projects/milestones/index.html.haml
@@ -12,6 +12,8 @@
New milestone
.milestones
+ #delete-milestone-modal
+
%ul.content-list
= render @milestones
diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml
index 5dd4d2c949c..623c42ba88e 100644
--- a/app/views/projects/milestones/show.html.haml
+++ b/app/views/projects/milestones/show.html.haml
@@ -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')
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index 50f4901a2dd..e08a49b4e59 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -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' )
diff --git a/changelogs/unreleased/winh-delete-milestone-modal.yml b/changelogs/unreleased/winh-delete-milestone-modal.yml
new file mode 100644
index 00000000000..6517fbd5f63
--- /dev/null
+++ b/changelogs/unreleased/winh-delete-milestone-modal.yml
@@ -0,0 +1,5 @@
+---
+title: Add modal for deleting a milestone
+merge_request: 16229
+author:
+type: other
diff --git a/features/project/issues/milestones.feature b/features/project/issues/milestones.feature
index 1af05b3c326..d121222308d 100644
--- a/features/project/issues/milestones.feature
+++ b/features/project/issues/milestones.feature
@@ -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
diff --git a/features/steps/project/issues/milestones.rb b/features/steps/project/issues/milestones.rb
index 33a24e8913a..4ce67aa651c 100644
--- a/features/steps/project/issues/milestones.rb
+++ b/features/steps/project/issues/milestones.rb
@@ -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
diff --git a/features/support/capybara_helpers.rb b/features/support/capybara_helpers.rb
deleted file mode 100644
index 647f8d087c3..00000000000
--- a/features/support/capybara_helpers.rb
+++ /dev/null
@@ -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
diff --git a/spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js b/spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js
new file mode 100644
index 00000000000..3cd33a3e900
--- /dev/null
+++ b/spec/javascripts/pages/milestones/shared/components/delete_milestone_modal_spec.js
@@ -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');
+ });
+ });
+});