summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorWinnie Hellmann <winnie@gitlab.com>2017-12-20 22:21:41 +0100
committerWinnie Hellmann <winnie@gitlab.com>2018-02-06 16:07:16 +0100
commitf6ad8735b222726d1e96aad29c7b5e4df7c75ba6 (patch)
tree40fd7660ea8297211c9ad01b341043ba5ac10af5
parent41b5bb5748c3afc3bb58209aad42ccd1cb89a46c (diff)
downloadgitlab-ce-winh-tag-deletion-modal.tar.gz
Add modal for deleting tagswinh-tag-deletion-modal
-rw-r--r--app/assets/javascripts/dispatcher.js6
-rw-r--r--app/assets/javascripts/pages/projects/tags/shared/components/delete_tag_modal.vue95
-rw-r--r--app/assets/javascripts/pages/projects/tags/shared/event_hub.js3
-rw-r--r--app/assets/javascripts/pages/projects/tags/shared/index.js81
-rw-r--r--app/controllers/projects/tags_controller.rb26
-rw-r--r--app/helpers/tags_helper.rb2
-rw-r--r--app/views/projects/buttons/_delete_tag.html.haml11
-rw-r--r--app/views/projects/tags/_tag.html.haml3
-rw-r--r--app/views/projects/tags/index.html.haml2
-rw-r--r--app/views/projects/tags/show.html.haml5
-rw-r--r--changelogs/unreleased/winh-tag-deletion-modal.yml5
-rw-r--r--config/routes/repository.rb2
-rw-r--r--lib/api/tags.rb30
-rw-r--r--spec/features/tags/master_deletes_tag_spec.rb21
-rw-r--r--spec/javascripts/pages/projects/tags/shared/components/delete_tag_modal_spec.js95
-rw-r--r--spec/routing/project_routing_spec.rb6
16 files changed, 334 insertions, 59 deletions
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index ab28b7d8d44..5d3487e99f9 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -199,6 +199,12 @@ var Dispatcher;
.catch(fail);
shortcut_handler = true;
break;
+ case 'projects:tags:index':
+ case 'projects:tags:show':
+ import('./pages/projects/tags/shared')
+ .then(callDefault)
+ .catch(fail);
+ break;
case 'projects:tags:new':
import('./pages/projects/tags/new')
.then(callDefault)
diff --git a/app/assets/javascripts/pages/projects/tags/shared/components/delete_tag_modal.vue b/app/assets/javascripts/pages/projects/tags/shared/components/delete_tag_modal.vue
new file mode 100644
index 00000000000..fa30877c39b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/tags/shared/components/delete_tag_modal.vue
@@ -0,0 +1,95 @@
+<script>
+ import axios from '~/lib/utils/axios_utils';
+
+ import Flash from '~/flash';
+ import { s__, sprintf } from '~/locale';
+ import modal from '~/vue_shared/components/modal.vue';
+ import confirmationInput from '~/vue_shared/components/confirmation_input.vue';
+ import { redirectTo } from '~/lib/utils/url_utility';
+
+ import eventHub from '../event_hub';
+
+ export default {
+ components: {
+ confirmationInput,
+ modal,
+ },
+ props: {
+ id: {
+ type: String,
+ required: false,
+ default: 'delete-tag-modal',
+ },
+ tagName: {
+ type: String,
+ required: true,
+ },
+ url: {
+ type: String,
+ required: true,
+ },
+ redirectUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ text() {
+ return sprintf(s__(`TagsPage|You're about to permanently delete the tag '%{tagName}'.
+Once deleted, it cannot be undone or recovered.`), { tagName: this.tagName });
+ },
+ title() {
+ return sprintf(s__("TagsPage|Delete tag '%{tagName}'?"), { tagName: this.tagName });
+ },
+ },
+ methods: {
+ canSubmit() {
+ return this.$refs.confirmation && this.$refs.confirmation.hasCorrectValue();
+ },
+ onSubmit() {
+ eventHub.$emit('deleteTagModal.requestStarted', this.ur);
+ return axios.delete(this.url)
+ .then(() => {
+ eventHub.$emit('deleteTagModal.requestFinished', { url: this.url, successful: true });
+ redirectTo(this.redirectUrl);
+ })
+ .catch((error) => {
+ eventHub.$emit('deleteTagModal.requestFinished', { url: this.url, successful: false });
+ if (error.response.status === 404) {
+ Flash(sprintf(s__("TagsPage|Tag '%{tagName}' was not found"), { tagName: this.tagName }));
+ } else {
+ const errorMessage = error.response.data.message;
+ Flash(sprintf(s__("TagsPage|Deleting tag '%{tagName}' failed (%{errorMessage})"), { tagName: this.tagName, errorMessage }));
+ }
+ throw error;
+ });
+ },
+ },
+ };
+</script>
+
+<template>
+ <modal
+ :id="id"
+ :title="title"
+ :text="text"
+ kind="danger"
+ :primary-button-label="s__('TagsPage|Delete tag')"
+ @submit="onSubmit"
+ :submit-disabled="!canSubmit()">
+
+ <template
+ slot="body"
+ slot-scope="props">
+ <p v-html="props.text"></p>
+
+ <confirmationInput
+ ref="confirmation"
+ :input-id="`${id}-input`"
+ confirmation-key="tag-name"
+ :confirmation-value="tagName"
+ />
+ </template>
+
+ </modal>
+</template>
diff --git a/app/assets/javascripts/pages/projects/tags/shared/event_hub.js b/app/assets/javascripts/pages/projects/tags/shared/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/tags/shared/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/pages/projects/tags/shared/index.js b/app/assets/javascripts/pages/projects/tags/shared/index.js
new file mode 100644
index 00000000000..ff6fabf1da8
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/tags/shared/index.js
@@ -0,0 +1,81 @@
+
+import Vue from 'vue';
+
+import Translate from '~/vue_shared/translate';
+
+import deleteTagModal from './components/delete_tag_modal.vue';
+import eventHub from './event_hub';
+
+export default () => {
+ Vue.use(Translate);
+
+ const onRequestFinished = ({ url, successful }) => {
+ const button = document.querySelector(`.js-delete-tag-button[data-url="${url}"]`);
+
+ if (!successful) {
+ button.removeAttribute('disabled');
+ }
+
+ button.querySelector('.js-loading-icon').classList.add('hidden');
+ };
+
+ const onRequestStarted = (url) => {
+ const button = document.querySelector(`.js-delete-tag-button[data-url="${url}"]`);
+ button.setAttribute('disabled', '');
+ button.querySelector('.js-loading-icon').classList.remove('hidden');
+ eventHub.$once('deleteTagModal.requestFinished', onRequestFinished);
+ };
+
+ const onDeleteButtonClick = (event) => {
+ const button = event.currentTarget;
+ const modalProps = {
+ tagName: button.dataset.tagName,
+ url: button.dataset.url,
+ redirectUrl: button.dataset.redirectUrl,
+ };
+ eventHub.$once('deleteTagModal.requestStarted', onRequestStarted);
+ eventHub.$emit('deleteTagModal.props', modalProps);
+ };
+
+ const deleteTagButtons = document.querySelectorAll('.js-delete-tag-button:not([data-protected])');
+ deleteTagButtons.forEach((button) => {
+ button.addEventListener('click', onDeleteButtonClick);
+ });
+
+ eventHub.$once('deleteTagModal.mounted', () => {
+ deleteTagButtons.forEach((button) => {
+ button.removeAttribute('disabled');
+ });
+ });
+
+ return new Vue({
+ el: '#delete-tag-modal',
+ components: {
+ deleteTagModal,
+ },
+ data() {
+ return {
+ tagName: '',
+ url: '',
+ redirectUrl: '',
+ };
+ },
+ mounted() {
+ eventHub.$on('deleteTagModal.props', this.setModalProps);
+ eventHub.$emit('deleteTagModal.mounted');
+ },
+ beforeDestroy() {
+ eventHub.$off('deleteTagModal.props', this.setModalProps);
+ },
+ methods: {
+ setModalProps(modalProps) {
+ this.modalProps = modalProps;
+ },
+ },
+ render(createElement) {
+ return createElement(deleteTagModal, {
+ props: this.modalProps,
+ });
+ },
+ });
+};
diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb
index b62d7d9b7c5..f703fd22fc0 100644
--- a/app/controllers/projects/tags_controller.rb
+++ b/app/controllers/projects/tags_controller.rb
@@ -5,7 +5,6 @@ class Projects::TagsController < Projects::ApplicationController
before_action :require_non_empty_project
before_action :authorize_download_code!
before_action :authorize_push_code!, only: [:new, :create]
- before_action :authorize_admin_project!, only: [:destroy]
def index
params[:sort] = params[:sort].presence || sort_value_recently_updated
@@ -43,29 +42,4 @@ class Projects::TagsController < Projects::ApplicationController
render action: 'new'
end
end
-
- def destroy
- result = Tags::DestroyService.new(project, current_user).execute(params[:id])
-
- respond_to do |format|
- if result[:status] == :success
- format.html do
- redirect_to project_tags_path(@project), status: 303
- end
-
- format.js
- else
- @error = result[:message]
-
- format.html do
- redirect_to project_tags_path(@project),
- alert: @error, status: 303
- end
-
- format.js do
- render status: :unprocessable_entity
- end
- end
- end
- end
end
diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb
index d000d6b1c0a..37e3a6e18d6 100644
--- a/app/helpers/tags_helper.rb
+++ b/app/helpers/tags_helper.rb
@@ -1,4 +1,6 @@
module TagsHelper
+ include ::API::Helpers::RelatedResourcesHelpers
+
def tag_path(tag)
"/tags/#{tag}"
end
diff --git a/app/views/projects/buttons/_delete_tag.html.haml b/app/views/projects/buttons/_delete_tag.html.haml
new file mode 100644
index 00000000000..fc8ad91da69
--- /dev/null
+++ b/app/views/projects/buttons/_delete_tag.html.haml
@@ -0,0 +1,11 @@
+
+%button.js-delete-tag-button.btn.btn-remove.remove-row.has-tooltip.prepend-left-10.disabled{ type: 'button',
+ title: s_('TagsPage|Delete tag'),
+ data: { toggle: 'modal',
+ target: '#delete-tag-modal',
+ protected: protected_tag?(project, tag),
+ tag_name: tag.name,
+ url: api_v4_projects_repository_tags_path(id: project.id, tag_name: tag.name),
+ redirect_url: project_tags_path(project),
+ container: 'body' } }
+ = sprite_icon('remove', size: 16)
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index 3d5f92f9aaa..9f9e10f674e 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -32,5 +32,4 @@
= icon("pencil")
- if can?(current_user, :admin_project, @project)
- = link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip prepend-left-10 #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag.name }, container: 'body' }, remote: true do
- = icon("trash-o")
+ = render 'projects/buttons/delete_tag', project: @project, tag: tag
diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml
index da364b58e36..c8cf22927f7 100644
--- a/app/views/projects/tags/index.html.haml
+++ b/app/views/projects/tags/index.html.haml
@@ -29,6 +29,8 @@
.tags
- if @tags.any?
+ #delete-tag-modal
+
%ul.flex-list.content-list
= render partial: 'tag', collection: @tags
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index dfe2c37ed8e..d72358cdfc8 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -4,6 +4,8 @@
- page_title @tag.name, s_('TagsPage|Tags')
%div{ class: container_class }
+ #delete-tag-modal
+
.top-area.multi-line
.nav-text
.title
@@ -30,8 +32,7 @@
= render 'projects/buttons/download', project: @project, ref: @tag.name
- if can?(current_user, :admin_project, @project)
.btn-container.controls-item-full
- = link_to project_tag_path(@project, @tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, @tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: @tag.name } } do
- %i.fa.fa-trash-o
+ = render 'projects/buttons/delete_tag', project: @project, tag: @tag
- if @tag.message.present?
%pre.wrap
diff --git a/changelogs/unreleased/winh-tag-deletion-modal.yml b/changelogs/unreleased/winh-tag-deletion-modal.yml
new file mode 100644
index 00000000000..f13a494c163
--- /dev/null
+++ b/changelogs/unreleased/winh-tag-deletion-modal.yml
@@ -0,0 +1,5 @@
+---
+title: Add modal for tag deletion confirmation
+merge_request: 16062
+author:
+type: changed
diff --git a/config/routes/repository.rb b/config/routes/repository.rb
index 9ffdebbcff1..72afd298b51 100644
--- a/config/routes/repository.rb
+++ b/config/routes/repository.rb
@@ -51,7 +51,7 @@ scope format: false do
resources :branches, only: [:index, :new, :create, :destroy]
delete :merged_branches, controller: 'branches', action: :destroy_all_merged
- resources :tags, only: [:index, :show, :new, :create, :destroy] do
+ resources :tags, only: [:index, :show, :new, :create] do
resource :release, only: [:edit, :update]
end
diff --git a/lib/api/tags.rb b/lib/api/tags.rb
index 5e0afc6a7e4..f368c4b98ca 100644
--- a/lib/api/tags.rb
+++ b/lib/api/tags.rb
@@ -10,6 +10,19 @@ module API
requires :id, type: String, desc: 'The ID of a project'
end
resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
+ desc 'Get a single repository tag' do
+ success Entities::Tag
+ end
+ params do
+ requires :tag_name, type: String, desc: 'The name of the tag'
+ end
+ get ':id/repository/tags/:tag_name', requirements: TAG_ENDPOINT_REQUIREMENTS do
+ tag = user_project.repository.find_tag(params[:tag_name])
+ not_found!('Tag') unless tag
+
+ present tag, with: Entities::Tag, project: user_project
+ end
+
desc 'Get a project repository tags' do
success Entities::Tag
end
@@ -26,19 +39,6 @@ module API
present paginate(tags), with: Entities::Tag, project: user_project
end
- desc 'Get a single repository tag' do
- success Entities::Tag
- end
- params do
- requires :tag_name, type: String, desc: 'The name of the tag'
- end
- get ':id/repository/tags/:tag_name', requirements: TAG_ENDPOINT_REQUIREMENTS do
- tag = user_project.repository.find_tag(params[:tag_name])
- not_found!('Tag') unless tag
-
- present tag, with: Entities::Tag, project: user_project
- end
-
desc 'Create a new repository tag' do
success Entities::Tag
end
@@ -79,7 +79,9 @@ module API
result = ::Tags::DestroyService.new(user_project, current_user)
.execute(params[:tag_name])
- if result[:status] != :success
+ if result[:status] == :success
+ body false
+ else
render_api_error!(result[:message], result[:return_code])
end
end
diff --git a/spec/features/tags/master_deletes_tag_spec.rb b/spec/features/tags/master_deletes_tag_spec.rb
index c0b4fa52526..e9bad46f2c2 100644
--- a/spec/features/tags/master_deletes_tag_spec.rb
+++ b/spec/features/tags/master_deletes_tag_spec.rb
@@ -14,20 +14,21 @@ feature 'Master deletes tag' do
scenario 'deletes the tag' do
expect(page).to have_content 'v1.1.0'
- delete_first_tag
+ delete_tag 'v1.1.0'
expect(page).not_to have_content 'v1.1.0'
end
end
- context 'from a specific tag page' do
+ context 'from a specific tag page', :js do
scenario 'deletes the tag' do
click_on 'v1.0.0'
expect(current_path).to eq(
project_tag_path(project, 'v1.0.0'))
- click_on 'Delete tag'
+ delete_tag 'v1.0.0'
+ expect(page).to have_content 'v1.1.0'
expect(current_path).to eq(
project_tags_path(project))
expect(page).not_to have_content 'v1.0.0'
@@ -42,7 +43,7 @@ feature 'Master deletes tag' do
end
scenario 'shows the error message' do
- delete_first_tag
+ delete_tag first('.ref-name').text
expect(page).to have_content('Do not delete tags')
end
@@ -55,16 +56,20 @@ feature 'Master deletes tag' do
end
scenario 'shows the error message' do
- delete_first_tag
+ delete_tag first('.ref-name').text
expect(page).to have_content('Do not delete tags')
end
end
end
- def delete_first_tag
- page.within('.content') do
- accept_confirm { first('.btn-remove').click }
+ def delete_tag(tag_name)
+ find('#delete-tag-modal.modal', visible: false) # wait for Vue component to be loaded
+ find(".js-delete-tag-button[data-tag-name=\"#{tag_name}\"]" % { tag_name: tag_name }).click
+
+ page.within '#delete-tag-modal' do
+ fill_in 'delete-tag-modal-input', with: tag_name
+ click_on 'Delete tag'
end
end
end
diff --git a/spec/javascripts/pages/projects/tags/shared/components/delete_tag_modal_spec.js b/spec/javascripts/pages/projects/tags/shared/components/delete_tag_modal_spec.js
new file mode 100644
index 00000000000..db2c5edb8bf
--- /dev/null
+++ b/spec/javascripts/pages/projects/tags/shared/components/delete_tag_modal_spec.js
@@ -0,0 +1,95 @@
+import axios from '~/lib/utils/axios_utils';
+import Vue from 'vue';
+import MockAdapter from 'axios-mock-adapter';
+
+import * as urlUtility from '~/lib/utils/url_utility';
+import deleteTagModal from '~/pages/projects/tags/shared/components/delete_tag_modal.vue';
+import eventHub from '~/pages/projects/tags/shared/event_hub';
+
+import mountComponent from '../../../../../helpers/vue_mount_component_helper';
+
+describe('delete_tag_modal.vue', () => {
+ const Component = Vue.extend(deleteTagModal);
+ const props = {
+ tagName: 'samstag',
+ url: `${gl.TEST_HOST}/delete_tag_modal.vue/tag`,
+ redirectUrl: `${gl.TEST_HOST}/delete_tag_modal.vue/redirect`,
+ };
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('methods', () => {
+ describe('canSubmit', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, props);
+ });
+
+ it('returns true if confirmation input has correct value', () => {
+ const confirmationInput = vm.$refs.confirmation;
+ spyOn(confirmationInput, 'hasCorrectValue').and.returnValue(true);
+
+ expect(vm.canSubmit()).toBe(true);
+ });
+
+ it('returns false if confirmation input has correct value', () => {
+ const confirmationInput = vm.$refs.confirmation;
+ spyOn(confirmationInput, 'hasCorrectValue').and.returnValue(false);
+
+ expect(vm.canSubmit()).toBe(false);
+ });
+ });
+
+ describe('onSubmit', () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ spyOn(eventHub, '$emit');
+ axiosMock = new MockAdapter(axios);
+ vm = mountComponent(Component, props);
+ });
+
+ afterEach(() => {
+ axiosMock.restore();
+ });
+
+ it('deletes tag and redirects to overview page', (done) => {
+ axiosMock.onDelete(props.ur).replyOnce(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('deleteTagModal.requestStarted', props.ur);
+ eventHub.$emit.calls.reset();
+ return [204];
+ });
+ const redirectSpy = spyOn(urlUtility, 'redirectTo');
+
+ vm.onSubmit()
+ .then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('deleteTagModal.requestFinished', { url: props.url, successful: true });
+ expect(redirectSpy).toHaveBeenCalledWith(props.redirectUrl);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('displays error if deleting tag failed', (done) => {
+ const dummyError = { message: 'deleting milestone failed' };
+ axiosMock.onDelete(props.ur).replyOnce(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('deleteTagModal.requestStarted', props.ur);
+ eventHub.$emit.calls.reset();
+ return [418, dummyError];
+ });
+ const redirectSpy = spyOn(urlUtility, 'redirectTo');
+
+ vm.onSubmit()
+ .catch((error) => {
+ expect(error.response.data).toEqual(dummyError);
+ expect(eventHub.$emit).toHaveBeenCalledWith('deleteTagModal.requestFinished', { url: props.url, successful: false });
+ expect(redirectSpy).not.toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+ });
+});
diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb
index fb1281a6b42..a88934b6a7e 100644
--- a/spec/routing/project_routing_spec.rb
+++ b/spec/routing/project_routing_spec.rb
@@ -196,12 +196,6 @@ describe 'project routing' do
describe Projects::TagsController, 'routing' do
it 'to #tags' do
expect(get('/gitlab/gitlabhq/tags')).to route_to('projects/tags#index', namespace_id: 'gitlab', project_id: 'gitlabhq')
- expect(delete('/gitlab/gitlabhq/tags/feature%2345')).to route_to('projects/tags#destroy', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'feature#45')
- expect(delete('/gitlab/gitlabhq/tags/feature%2B45')).to route_to('projects/tags#destroy', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'feature+45')
- expect(delete('/gitlab/gitlabhq/tags/feature@45')).to route_to('projects/tags#destroy', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'feature@45')
- expect(delete('/gitlab/gitlabhq/tags/feature%2345/foo/bar/baz')).to route_to('projects/tags#destroy', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'feature#45/foo/bar/baz')
- expect(delete('/gitlab/gitlabhq/tags/feature%2B45/foo/bar/baz')).to route_to('projects/tags#destroy', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'feature+45/foo/bar/baz')
- expect(delete('/gitlab/gitlabhq/tags/feature@45/foo/bar/baz')).to route_to('projects/tags#destroy', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'feature@45/foo/bar/baz')
end
end