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:
Phil Hughes 2018-01-25 11:41:02 +00:00
commit f9b946c1c9
18 changed files with 353 additions and 24 deletions

View file

@ -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)

View file

@ -1,3 +1,3 @@
import initMilestonesShow from '~/pages/init_milestones_show';
import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show';
export default initMilestonesShow;

View file

@ -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|
Youre 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|
Youre 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>

View file

@ -0,0 +1,3 @@
import Vue from 'vue';
export default new Vue();

View 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,
});
},
});
};

View file

@ -0,0 +1,3 @@
import milestones from '~/pages/milestones/shared';
export default milestones;

View file

@ -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();
};

View file

@ -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};

View file

@ -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

View file

@ -12,6 +12,8 @@
New milestone
.milestones
#delete-milestone-modal
%ul.content-list
= render @milestones

View file

@ -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')

View file

@ -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' )

View file

@ -0,0 +1,5 @@
---
title: Add modal for deleting a milestone
merge_request: 16229
author:
type: other

View file

@ -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

View file

@ -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

View file

@ -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

View file

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