diff options
author | Winnie Hellmann <winnie@gitlab.com> | 2017-12-20 22:21:41 +0100 |
---|---|---|
committer | Winnie Hellmann <winnie@gitlab.com> | 2018-02-06 16:07:16 +0100 |
commit | f6ad8735b222726d1e96aad29c7b5e4df7c75ba6 (patch) | |
tree | 40fd7660ea8297211c9ad01b341043ba5ac10af5 | |
parent | 41b5bb5748c3afc3bb58209aad42ccd1cb89a46c (diff) | |
download | gitlab-ce-winh-tag-deletion-modal.tar.gz |
Add modal for deleting tagswinh-tag-deletion-modal
-rw-r--r-- | app/assets/javascripts/dispatcher.js | 6 | ||||
-rw-r--r-- | app/assets/javascripts/pages/projects/tags/shared/components/delete_tag_modal.vue | 95 | ||||
-rw-r--r-- | app/assets/javascripts/pages/projects/tags/shared/event_hub.js | 3 | ||||
-rw-r--r-- | app/assets/javascripts/pages/projects/tags/shared/index.js | 81 | ||||
-rw-r--r-- | app/controllers/projects/tags_controller.rb | 26 | ||||
-rw-r--r-- | app/helpers/tags_helper.rb | 2 | ||||
-rw-r--r-- | app/views/projects/buttons/_delete_tag.html.haml | 11 | ||||
-rw-r--r-- | app/views/projects/tags/_tag.html.haml | 3 | ||||
-rw-r--r-- | app/views/projects/tags/index.html.haml | 2 | ||||
-rw-r--r-- | app/views/projects/tags/show.html.haml | 5 | ||||
-rw-r--r-- | changelogs/unreleased/winh-tag-deletion-modal.yml | 5 | ||||
-rw-r--r-- | config/routes/repository.rb | 2 | ||||
-rw-r--r-- | lib/api/tags.rb | 30 | ||||
-rw-r--r-- | spec/features/tags/master_deletes_tag_spec.rb | 21 | ||||
-rw-r--r-- | spec/javascripts/pages/projects/tags/shared/components/delete_tag_modal_spec.js | 95 | ||||
-rw-r--r-- | spec/routing/project_routing_spec.rb | 6 |
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 |