From 5d19bda456a3ed8f4a2572f2ac8fd8f775468e93 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 12 May 2017 10:04:58 +0100 Subject: Issue inline editing [ci skip] --- app/assets/javascripts/issue_show/event_hub.js | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/assets/javascripts/issue_show/event_hub.js diff --git a/app/assets/javascripts/issue_show/event_hub.js b/app/assets/javascripts/issue_show/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/issue_show/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); -- cgit v1.2.1 From 66539563c890a8207b2ec28c4a0fc8577149f8a0 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 12 May 2017 10:11:15 +0100 Subject: Added eventHub events to change the showForm value [ci skip] --- app/assets/javascripts/issue_show/index.js | 94 ++++++++++++++++++------------ app/views/projects/issues/show.html.haml | 2 +- 2 files changed, 59 insertions(+), 37 deletions(-) diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index f06e33dee60..b279ba867f9 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,42 +1,64 @@ import Vue from 'vue'; +import eventHub from './event_hub'; import issuableApp from './components/app.vue'; import '../vue_shared/vue_resource_interceptor'; -document.addEventListener('DOMContentLoaded', () => new Vue({ - el: document.getElementById('js-issuable-app'), - components: { - issuableApp, - }, - data() { - const issuableElement = this.$options.el; - const issuableTitleElement = issuableElement.querySelector('.title'); - const issuableDescriptionElement = issuableElement.querySelector('.wiki'); - const issuableDescriptionTextarea = issuableElement.querySelector('.js-task-list-field'); - const { - canUpdate, - endpoint, - issuableRef, - } = issuableElement.dataset; +document.addEventListener('DOMContentLoaded', () => { + $('.issuable-edit').on('click', (e) => { + e.preventDefault(); - return { - canUpdate: gl.utils.convertPermissionToBoolean(canUpdate), - endpoint, - issuableRef, - initialTitle: issuableTitleElement.innerHTML, - initialDescriptionHtml: issuableDescriptionElement ? issuableDescriptionElement.innerHTML : '', - initialDescriptionText: issuableDescriptionTextarea ? issuableDescriptionTextarea.textContent : '', - }; - }, - render(createElement) { - return createElement('issuable-app', { - props: { - canUpdate: this.canUpdate, - endpoint: this.endpoint, - issuableRef: this.issuableRef, - initialTitle: this.initialTitle, - initialDescriptionHtml: this.initialDescriptionHtml, - initialDescriptionText: this.initialDescriptionText, + eventHub.$emit('open.form'); + }); + + return new Vue({ + el: document.getElementById('js-issuable-app'), + components: { + issuableApp, + }, + data() { + const issuableElement = this.$options.el; + const issuableTitleElement = issuableElement.querySelector('.title'); + const issuableDescriptionElement = issuableElement.querySelector('.wiki'); + const issuableDescriptionTextarea = issuableElement.querySelector('.js-task-list-field'); + const { + canUpdate, + endpoint, + issuableRef, + } = issuableElement.dataset; + + return { + canUpdate: gl.utils.convertPermissionToBoolean(canUpdate), + endpoint, + issuableRef, + initialTitle: issuableTitleElement.innerHTML, + initialDescriptionHtml: issuableDescriptionElement ? issuableDescriptionElement.innerHTML : '', + initialDescriptionText: issuableDescriptionTextarea ? issuableDescriptionTextarea.textContent : '', + showForm: false, + }; + }, + methods: { + openForm() { + this.showForm = true; + console.log(this.showForm); }, - }); - }, -})); + }, + created() { + eventHub.$on('open.form', this.openForm); + }, + beforeDestroy() { + eventHub.$off('open.form', this.openForm); + }, + render(createElement) { + return createElement('issuable-app', { + props: { + canUpdate: this.canUpdate, + endpoint: this.endpoint, + issuableRef: this.issuableRef, + initialTitle: this.initialTitle, + initialDescriptionHtml: this.initialDescriptionHtml, + initialDescriptionText: this.initialDescriptionText, + }, + }); + }, + }); +}); diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index f66724900de..f0a05327d68 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -35,7 +35,7 @@ %li = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), data: {no_turbolink: true}, class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' %li - = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue) + = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'issuable-edit' - if @issue.submittable_as_spam_by?(current_user) %li = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam' -- cgit v1.2.1 From 86700b97d3a357b572e6eb92759a64d594aa06c5 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 12 May 2017 11:23:30 +0100 Subject: Added inline issue edit form actions [ci skip] --- .../javascripts/issue_show/components/app.vue | 55 ++++++++-- .../issue_show/components/edit_actions.vue | 66 ++++++++++++ app/assets/javascripts/issue_show/index.js | 7 +- .../javascripts/issue_show/services/index.js | 19 +++- app/assets/javascripts/issue_show/stores/index.js | 1 + app/controllers/concerns/issuable_actions.rb | 11 +- app/views/projects/issues/show.html.haml | 2 +- spec/javascripts/issue_show/components/app_spec.js | 106 ++++++++++++++++++++ .../issue_show/components/edit_actions_spec.js | 111 +++++++++++++++++++++ 9 files changed, 367 insertions(+), 11 deletions(-) create mode 100644 app/assets/javascripts/issue_show/components/edit_actions.vue create mode 100644 spec/javascripts/issue_show/components/edit_actions_spec.js diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 770a0dcd27e..27ea962c144 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -1,10 +1,13 @@ @@ -92,5 +133,7 @@ export default { :description-text="state.descriptionText" :updated-at="state.updatedAt" :task-status="state.taskStatus" /> + diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue new file mode 100644 index 00000000000..bb200c3a53c --- /dev/null +++ b/app/assets/javascripts/issue_show/components/edit_actions.vue @@ -0,0 +1,66 @@ + + + diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index b279ba867f9..5d45f1b7bf8 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -39,14 +39,18 @@ document.addEventListener('DOMContentLoaded', () => { methods: { openForm() { this.showForm = true; - console.log(this.showForm); + }, + closeForm() { + this.showForm = false; }, }, created() { eventHub.$on('open.form', this.openForm); + eventHub.$on('close.form', this.closeForm); }, beforeDestroy() { eventHub.$off('open.form', this.openForm); + eventHub.$off('close.form', this.closeForm); }, render(createElement) { return createElement('issuable-app', { @@ -57,6 +61,7 @@ document.addEventListener('DOMContentLoaded', () => { initialTitle: this.initialTitle, initialDescriptionHtml: this.initialDescriptionHtml, initialDescriptionText: this.initialDescriptionText, + showForm: this.showForm, }, }); }, diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js index 348ad8d6813..f3ffa451bba 100644 --- a/app/assets/javascripts/issue_show/services/index.js +++ b/app/assets/javascripts/issue_show/services/index.js @@ -7,10 +7,25 @@ export default class Service { constructor(endpoint) { this.endpoint = endpoint; - this.resource = Vue.resource(this.endpoint); + this.resource = Vue.resource(this.endpoint, {}, { + rendered_title: { + method: 'GET', + url: `${this.endpoint}/rendered_title`, + }, + }); } getData() { - return this.resource.get(); + return this.resource.rendered_title(); + } + + deleteIssuable() { + return this.resource.delete() + .then(res => res.json()); + } + + updateIssuable(data) { + return this.resource.update(data) + .then(res => res.json()); } } diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js index 8e89a2b7730..b2c1b9d1c6e 100644 --- a/app/assets/javascripts/issue_show/stores/index.js +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -12,6 +12,7 @@ export default class Store { taskStatus: '', updatedAt: '', }; + this.formState = {}; } updateState(data) { diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 4cf645d6341..bfd6441e928 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -14,7 +14,16 @@ module IssuableActions name = issuable.human_class_name flash[:notice] = "The #{name} was successfully deleted." - redirect_to polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class]) + index_path = polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class]) + + respond_to do |format| + format.html { redirect_to index_path } + format.json do + render json: { + path: index_path + } + end + end end def bulk_update diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index f0a05327d68..d33e2a7491d 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -51,7 +51,7 @@ .issue-details.issuable-details .detail-page-description.content-block - #js-issuable-app{ "data" => { "endpoint" => rendered_title_namespace_project_issue_path(@project.namespace, @project, @issue), + #js-issuable-app{ "data" => { "endpoint" => namespace_project_issue_path(@project.namespace, @project, @issue), "can-update" => can?(current_user, :update_issue, @issue).to_s, "issuable-ref" => @issue.to_reference, } } diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js index 09bca2c3680..9c066a5908b 100644 --- a/spec/javascripts/issue_show/components/app_spec.js +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import '~/render_math'; import '~/render_gfm'; import issuableApp from '~/issue_show/components/app.vue'; +import eventHub from '~/issue_show/event_hub'; import issueShowData from '../mock_data'; const issueShowInterceptor = data => (request, next) => { @@ -22,6 +23,8 @@ describe('Issuable output', () => { const IssuableDescriptionComponent = Vue.extend(issuableApp); Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest)); + spyOn(eventHub, '$emit'); + vm = new IssuableDescriptionComponent({ propsData: { canUpdate: true, @@ -30,6 +33,7 @@ describe('Issuable output', () => { initialTitle: '', initialDescriptionHtml: '', initialDescriptionText: '', + showForm: true, }, }).$mount(); }); @@ -57,4 +61,106 @@ describe('Issuable output', () => { }); }); }); + + describe('updateIssuable', () => { + it('correctly updates issuable data', (done) => { + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { + resolve(); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + vm.service.updateIssuable, + ).toHaveBeenCalledWith(vm.formState); + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('close.form'); + + done(); + }); + }); + + it('closes form on error', (done) => { + spyOn(window, 'Flash').and.callThrough(); + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve, reject) => { + reject(); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('close.form'); + expect( + window.Flash, + ).toHaveBeenCalledWith('Error updating issue'); + + done(); + }); + }); + }); + + describe('deleteIssuable', () => { + it('changes URL when deleted', (done) => { + spyOn(gl.utils, 'visitUrl'); + spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + path: '/test', + }); + })); + + vm.deleteIssuable(); + + setTimeout(() => { + expect( + gl.utils.visitUrl, + ).toHaveBeenCalledWith('/test'); + + done(); + }); + }); + + it('stops polling when deleteing', (done) => { + spyOn(gl.utils, 'visitUrl'); + spyOn(vm.poll, 'stop'); + spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + path: '/test', + }); + })); + + vm.deleteIssuable(); + + setTimeout(() => { + expect( + vm.poll.stop, + ).toHaveBeenCalledWith(); + + done(); + }); + }); + + it('closes form on error', (done) => { + spyOn(window, 'Flash').and.callThrough(); + spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve, reject) => { + reject(); + })); + + vm.deleteIssuable(); + + setTimeout(() => { + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('close.form'); + expect( + window.Flash, + ).toHaveBeenCalledWith('Error deleting issue'); + + done(); + }); + }); + }); }); diff --git a/spec/javascripts/issue_show/components/edit_actions_spec.js b/spec/javascripts/issue_show/components/edit_actions_spec.js new file mode 100644 index 00000000000..a0fccccc961 --- /dev/null +++ b/spec/javascripts/issue_show/components/edit_actions_spec.js @@ -0,0 +1,111 @@ +import Vue from 'vue'; +import editActions from '~/issue_show/components/edit_actions.vue'; +import eventHub from '~/issue_show/event_hub'; + +describe('Edit Actions components', () => { + let vm; + + beforeEach((done) => { + const Component = Vue.extend(editActions); + + spyOn(eventHub, '$emit'); + + vm = new Component().$mount(); + + Vue.nextTick(done); + }); + + it('renders all buttons as enabled', () => { + expect( + vm.$el.querySelectorAll('.disabled').length, + ).toBe(0); + + expect( + vm.$el.querySelectorAll('[disabled]').length, + ).toBe(0); + }); + + describe('updateIssuable', () => { + it('sends update.issauble event when clicking save button', () => { + vm.$el.querySelector('.btn-save').click(); + + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('update.issuable'); + }); + + it('shows loading icon after clicking save button', (done) => { + vm.$el.querySelector('.btn-save').click(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn-save .fa'), + ).not.toBeNull(); + + done(); + }); + }); + + it('disabled button after clicking save button', (done) => { + vm.$el.querySelector('.btn-save').click(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn-save').getAttribute('disabled'), + ).toBe('disabled'); + + done(); + }); + }); + }); + + describe('closeForm', () => { + it('emits close.form when clicking cancel', () => { + vm.$el.querySelector('.btn-default').click(); + + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('close.form'); + }); + }); + + describe('deleteIssuable', () => { + it('sends delete.issuable event when clicking save button', () => { + spyOn(window, 'confirm').and.returnValue(true); + vm.$el.querySelector('.btn-danger').click(); + + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('delete.issuable'); + }); + + it('shows loading icon after clicking delete button', (done) => { + spyOn(window, 'confirm').and.returnValue(true); + vm.$el.querySelector('.btn-danger').click(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn-danger .fa'), + ).not.toBeNull(); + + done(); + }); + }); + + it('does no actions when confirm is false', (done) => { + spyOn(window, 'confirm').and.returnValue(false); + vm.$el.querySelector('.btn-danger').click(); + + Vue.nextTick(() => { + expect( + eventHub.$emit, + ).not.toHaveBeenCalledWith('delete.issuable'); + expect( + vm.$el.querySelector('.btn-danger .fa'), + ).toBeNull(); + + done(); + }); + }); + }); +}); -- cgit v1.2.1 From 3bd37bc4a0d6b8ab6ebaabc2ee1c130201b20a21 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 12 May 2017 11:58:48 +0100 Subject: Shows delete button if permissions are correct [ci skip] --- .../javascripts/issue_show/components/app.vue | 7 +++++- .../issue_show/components/edit_actions.vue | 7 ++++++ app/assets/javascripts/issue_show/index.js | 3 +++ app/views/projects/issues/show.html.haml | 1 + spec/javascripts/issue_show/components/app_spec.js | 28 +++++++++++++++++++++- .../issue_show/components/edit_actions_spec.js | 18 +++++++++++++- 6 files changed, 61 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 27ea962c144..9b6f6d866dd 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -19,6 +19,10 @@ export default { required: true, type: Boolean, }, + canDestroy: { + required: true, + type: Boolean, + }, issuableRef: { type: String, required: true, @@ -134,6 +138,7 @@ export default { :updated-at="state.updatedAt" :task-status="state.taskStatus" /> + v-if="canUpdate && showForm" + :can-destroy="canDestroy" /> diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue index bb200c3a53c..4cefb236d32 100644 --- a/app/assets/javascripts/issue_show/components/edit_actions.vue +++ b/app/assets/javascripts/issue_show/components/edit_actions.vue @@ -2,6 +2,12 @@ import eventHub from '../event_hub'; export default { + props: { + canDestroy: { + type: Boolean, + required: true, + }, + }, data() { return { deleteLoading: false, @@ -50,6 +56,7 @@ Cancel