Added inline issue edit form actions

[ci skip]
This commit is contained in:
Phil Hughes 2017-05-12 11:23:30 +01:00
parent 66539563c8
commit 86700b97d3
9 changed files with 367 additions and 11 deletions

View file

@ -1,10 +1,13 @@
<script> <script>
/* global Flash */
import Visibility from 'visibilityjs'; import Visibility from 'visibilityjs';
import Poll from '../../lib/utils/poll'; import Poll from '../../lib/utils/poll';
import eventHub from '../event_hub';
import Service from '../services/index'; import Service from '../services/index';
import Store from '../stores'; import Store from '../stores';
import titleComponent from './title.vue'; import titleComponent from './title.vue';
import descriptionComponent from './description.vue'; import descriptionComponent from './description.vue';
import editActions from './edit_actions.vue';
export default { export default {
props: { props: {
@ -34,6 +37,10 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
showForm: {
type: Boolean,
required: true,
},
}, },
data() { data() {
const store = new Store({ const store = new Store({
@ -45,16 +52,43 @@ export default {
return { return {
store, store,
state: store.state, state: store.state,
formState: store.formState,
}; };
}, },
components: { components: {
descriptionComponent, descriptionComponent,
titleComponent, 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() { created() {
const resource = new Service(this.endpoint); this.service = new Service(this.endpoint);
const poll = new Poll({ this.poll = new Poll({
resource, resource: this.service,
method: 'getData', method: 'getData',
successCallback: (res) => { successCallback: (res) => {
this.store.updateState(res.json()); this.store.updateState(res.json());
@ -65,16 +99,23 @@ export default {
}); });
if (!Visibility.hidden()) { if (!Visibility.hidden()) {
poll.makeRequest(); this.poll.makeRequest();
} }
Visibility.change(() => { Visibility.change(() => {
if (!Visibility.hidden()) { if (!Visibility.hidden()) {
poll.restart(); this.poll.restart();
} else { } 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> </script>
@ -92,5 +133,7 @@ export default {
:description-text="state.descriptionText" :description-text="state.descriptionText"
:updated-at="state.updatedAt" :updated-at="state.updatedAt"
:task-status="state.taskStatus" /> :task-status="state.taskStatus" />
<edit-actions
v-if="canUpdate && showForm" />
</div> </div>
</template> </template>

View file

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

View file

@ -39,14 +39,18 @@ document.addEventListener('DOMContentLoaded', () => {
methods: { methods: {
openForm() { openForm() {
this.showForm = true; this.showForm = true;
console.log(this.showForm); },
closeForm() {
this.showForm = false;
}, },
}, },
created() { created() {
eventHub.$on('open.form', this.openForm); eventHub.$on('open.form', this.openForm);
eventHub.$on('close.form', this.closeForm);
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off('open.form', this.openForm); eventHub.$off('open.form', this.openForm);
eventHub.$off('close.form', this.closeForm);
}, },
render(createElement) { render(createElement) {
return createElement('issuable-app', { return createElement('issuable-app', {
@ -57,6 +61,7 @@ document.addEventListener('DOMContentLoaded', () => {
initialTitle: this.initialTitle, initialTitle: this.initialTitle,
initialDescriptionHtml: this.initialDescriptionHtml, initialDescriptionHtml: this.initialDescriptionHtml,
initialDescriptionText: this.initialDescriptionText, initialDescriptionText: this.initialDescriptionText,
showForm: this.showForm,
}, },
}); });
}, },

View file

@ -7,10 +7,25 @@ export default class Service {
constructor(endpoint) { constructor(endpoint) {
this.endpoint = 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() { 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());
} }
} }

View file

@ -12,6 +12,7 @@ export default class Store {
taskStatus: '', taskStatus: '',
updatedAt: '', updatedAt: '',
}; };
this.formState = {};
} }
updateState(data) { updateState(data) {

View file

@ -14,7 +14,16 @@ module IssuableActions
name = issuable.human_class_name name = issuable.human_class_name
flash[:notice] = "The #{name} was successfully deleted." 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 end
def bulk_update def bulk_update

View file

@ -51,7 +51,7 @@
.issue-details.issuable-details .issue-details.issuable-details
.detail-page-description.content-block .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, "can-update" => can?(current_user, :update_issue, @issue).to_s,
"issuable-ref" => @issue.to_reference, "issuable-ref" => @issue.to_reference,
} } } }

View file

@ -2,6 +2,7 @@ import Vue from 'vue';
import '~/render_math'; import '~/render_math';
import '~/render_gfm'; import '~/render_gfm';
import issuableApp from '~/issue_show/components/app.vue'; import issuableApp from '~/issue_show/components/app.vue';
import eventHub from '~/issue_show/event_hub';
import issueShowData from '../mock_data'; import issueShowData from '../mock_data';
const issueShowInterceptor = data => (request, next) => { const issueShowInterceptor = data => (request, next) => {
@ -22,6 +23,8 @@ describe('Issuable output', () => {
const IssuableDescriptionComponent = Vue.extend(issuableApp); const IssuableDescriptionComponent = Vue.extend(issuableApp);
Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest)); Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest));
spyOn(eventHub, '$emit');
vm = new IssuableDescriptionComponent({ vm = new IssuableDescriptionComponent({
propsData: { propsData: {
canUpdate: true, canUpdate: true,
@ -30,6 +33,7 @@ describe('Issuable output', () => {
initialTitle: '', initialTitle: '',
initialDescriptionHtml: '', initialDescriptionHtml: '',
initialDescriptionText: '', initialDescriptionText: '',
showForm: true,
}, },
}).$mount(); }).$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();
});
});
});
}); });

View file

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