summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPhil Hughes <me@iamphill.com>2017-05-12 11:23:30 +0100
committerPhil Hughes <me@iamphill.com>2017-05-15 11:33:31 +0100
commit86700b97d3a357b572e6eb92759a64d594aa06c5 (patch)
tree3453cd8668e962d2be8c4662827e2d87fec9fe2f
parent66539563c890a8207b2ec28c4a0fc8577149f8a0 (diff)
downloadgitlab-ce-86700b97d3a357b572e6eb92759a64d594aa06c5.tar.gz
Added inline issue edit form actions
[ci skip]
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue55
-rw-r--r--app/assets/javascripts/issue_show/components/edit_actions.vue66
-rw-r--r--app/assets/javascripts/issue_show/index.js7
-rw-r--r--app/assets/javascripts/issue_show/services/index.js19
-rw-r--r--app/assets/javascripts/issue_show/stores/index.js1
-rw-r--r--app/controllers/concerns/issuable_actions.rb11
-rw-r--r--app/views/projects/issues/show.html.haml2
-rw-r--r--spec/javascripts/issue_show/components/app_spec.js106
-rw-r--r--spec/javascripts/issue_show/components/edit_actions_spec.js111
9 files changed, 367 insertions, 11 deletions
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 @@
<script>
+/* global Flash */
import Visibility from 'visibilityjs';
import Poll from '../../lib/utils/poll';
+import eventHub from '../event_hub';
import Service from '../services/index';
import Store from '../stores';
import titleComponent from './title.vue';
import descriptionComponent from './description.vue';
+import editActions from './edit_actions.vue';
export default {
props: {
@@ -34,6 +37,10 @@ export default {
required: false,
default: '',
},
+ showForm: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
const store = new Store({
@@ -45,16 +52,43 @@ export default {
return {
store,
state: store.state,
+ formState: store.formState,
};
},
components: {
descriptionComponent,
titleComponent,
+ editActions,
+ },
+ methods: {
+ updateIssuable() {
+ this.service.updateIssuable(this.formState)
+ .then(() => {
+ eventHub.$emit('close.form');
+ })
+ .catch(() => {
+ eventHub.$emit('close.form');
+ return new Flash('Error updating issue');
+ });
+ },
+ deleteIssuable() {
+ this.service.deleteIssuable()
+ .then((data) => {
+ gl.utils.visitUrl(data.path);
+
+ // Stop the poll so we don't get 404's with the issue not existing
+ this.poll.stop();
+ })
+ .catch(() => {
+ eventHub.$emit('close.form');
+ return new Flash('Error deleting issue');
+ });
+ },
},
created() {
- const resource = new Service(this.endpoint);
- const poll = new Poll({
- resource,
+ this.service = new Service(this.endpoint);
+ this.poll = new Poll({
+ resource: this.service,
method: 'getData',
successCallback: (res) => {
this.store.updateState(res.json());
@@ -65,16 +99,23 @@ export default {
});
if (!Visibility.hidden()) {
- poll.makeRequest();
+ this.poll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
- poll.restart();
+ this.poll.restart();
} else {
- poll.stop();
+ this.poll.stop();
}
});
+
+ eventHub.$on('delete.issuable', this.deleteIssuable);
+ eventHub.$on('update.issuable', this.updateIssuable);
+ },
+ beforeDestroy() {
+ eventHub.$off('delete.issuable', this.deleteIssuable);
+ eventHub.$off('update.issuable', this.updateIssuable);
},
};
</script>
@@ -92,5 +133,7 @@ export default {
:description-text="state.descriptionText"
:updated-at="state.updatedAt"
:task-status="state.taskStatus" />
+ <edit-actions
+ v-if="canUpdate && showForm" />
</div>
</template>
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 @@
+<script>
+ import eventHub from '../event_hub';
+
+ export default {
+ data() {
+ return {
+ deleteLoading: false,
+ updateLoading: false,
+ };
+ },
+ methods: {
+ updateIssuable() {
+ this.updateLoading = true;
+ eventHub.$emit('update.issuable');
+ },
+ closeForm() {
+ eventHub.$emit('close.form');
+ },
+ deleteIssuable() {
+ // eslint-disable-next-line no-alert
+ if (confirm('Issue will be removed! Are you sure?')) {
+ this.deleteLoading = true;
+
+ eventHub.$emit('delete.issuable');
+ }
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="prepend-top-default append-bottom-default clearfix">
+ <button
+ class="btn btn-save pull-left"
+ :class="{ disabled: updateLoading }"
+ type="submit"
+ :disabled="updateLoading"
+ @click="updateIssuable">
+ Save changes
+ <i
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true"
+ v-if="updateLoading">
+ </i>
+ </button>
+ <button
+ class="btn btn-default pull-right"
+ type="button"
+ @click="closeForm">
+ Cancel
+ </button>
+ <button
+ class="btn btn-danger pull-right append-right-default"
+ :class="{ disabled: deleteLoading }"
+ type="button"
+ :disabled="deleteLoading"
+ @click="deleteIssuable">
+ Delete
+ <i
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true"
+ v-if="deleteLoading">
+ </i>
+ </button>
+ </div>
+</template>
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();
+ });
+ });
+ });
+});