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