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>
/* 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>

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: {
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,
},
});
},

View file

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

View file

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

View file

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

View file

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

View file

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

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