Commit 16243985 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'issue-edit-inline' into issue-edit-inline-locked-warning

[ci skip]
parents 982ab870 3c3b17a5
...@@ -8,6 +8,7 @@ import Store from '../stores'; ...@@ -8,6 +8,7 @@ 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 formComponent from './form.vue'; import formComponent from './form.vue';
import '../../lib/utils/url_utility';
export default { export default {
props: { props: {
...@@ -15,6 +16,10 @@ export default { ...@@ -15,6 +16,10 @@ export default {
required: true, required: true,
type: String, type: String,
}, },
canMove: {
required: true,
type: Boolean,
},
canUpdate: { canUpdate: {
required: true, required: true,
type: Boolean, type: Boolean,
...@@ -53,6 +58,10 @@ export default { ...@@ -53,6 +58,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
projectsAutocompleteUrl: {
type: String,
required: true,
},
}, },
data() { data() {
const store = new Store({ const store = new Store({
...@@ -85,6 +94,7 @@ export default { ...@@ -85,6 +94,7 @@ export default {
title: this.state.titleText, title: this.state.titleText,
confidential: this.isConfidential, confidential: this.isConfidential,
description: this.state.descriptionText, description: this.state.descriptionText,
move_to_project_id: 0,
}); });
} }
}, },
...@@ -93,11 +103,12 @@ export default { ...@@ -93,11 +103,12 @@ export default {
}, },
updateIssuable() { updateIssuable() {
this.service.updateIssuable(this.store.formState) this.service.updateIssuable(this.store.formState)
.then((res) => { .then(res => res.json())
const data = res.json(); .then((data) => {
if (location.pathname !== data.path) {
if (data.confidential !== this.isConfidential) { gl.utils.visitUrl(data.path);
location.reload(); } else if (data.confidential !== this.isConfidential) {
gl.utils.visitUrl(location.pathname);
} }
eventHub.$emit('close.form'); eventHub.$emit('close.form');
...@@ -173,9 +184,11 @@ export default { ...@@ -173,9 +184,11 @@ export default {
<form-component <form-component
v-if="canUpdate && showForm" v-if="canUpdate && showForm"
:form-state="formState" :form-state="formState"
:can-move="canMove"
:can-destroy="canDestroy" :can-destroy="canDestroy"
:markdown-docs="markdownDocs" :markdown-docs="markdownDocs"
:markdown-preview-url="markdownPreviewUrl" /> :markdown-preview-url="markdownPreviewUrl"
:projects-autocomplete-url="projectsAutocompleteUrl" />
<div v-else> <div v-else>
<title-component <title-component
:issuable-ref="issuableRef" :issuable-ref="issuableRef"
......
<script>
import tooltipMixin from '../../../vue_shared/mixins/tooltip';
export default {
mixins: [
tooltipMixin,
],
props: {
formState: {
type: Object,
required: true,
},
projectsAutocompleteUrl: {
type: String,
required: true,
},
},
mounted() {
const $moveDropdown = $(this.$refs['move-dropdown']);
$moveDropdown.select2({
ajax: {
url: this.projectsAutocompleteUrl,
quietMillis: 125,
data(term, page, context) {
return {
search: term,
offset_id: context,
};
},
results(data) {
const more = data.length >= 50;
const context = data[data.length - 1] ? data[data.length - 1].id : null;
return {
results: data,
more,
context,
};
},
},
formatResult(project) {
return project.name_with_namespace;
},
formatSelection(project) {
return project.name_with_namespace;
},
})
.on('change', (e) => {
this.formState.move_to_project_id = parseInt(e.target.value, 10);
});
},
beforeDestroy() {
$(this.$refs['move-dropdown']).select2('destroy');
},
};
</script>
<template>
<fieldset>
<label
for="issuable-move"
class="sr-only">
Move
</label>
<div class="issuable-form-select-holder append-right-5">
<input
ref="move-dropdown"
type="hidden"
id="issuable-move"
data-placeholder="Move to a different project" />
</div>
<span
data-placement="auto top"
title="Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location."
ref="tooltip">
<i
class="fa fa-question-circle"
aria-hidden="true">
</i>
</span>
</fieldset>
</template>
...@@ -3,10 +3,15 @@ ...@@ -3,10 +3,15 @@
import titleField from './fields/title.vue'; import titleField from './fields/title.vue';
import descriptionField from './fields/description.vue'; import descriptionField from './fields/description.vue';
import editActions from './edit_actions.vue'; import editActions from './edit_actions.vue';
import projectMove from './fields/project_move.vue';
import confidentialCheckbox from './fields/confidential_checkbox.vue'; import confidentialCheckbox from './fields/confidential_checkbox.vue';
export default { export default {
props: { props: {
canMove: {
type: Boolean,
required: true,
},
canDestroy: { canDestroy: {
type: Boolean, type: Boolean,
required: true, required: true,
...@@ -23,12 +28,17 @@ ...@@ -23,12 +28,17 @@
type: String, type: String,
required: true, required: true,
}, },
projectsAutocompleteUrl: {
type: String,
required: true,
},
}, },
components: { components: {
lockedWarning, lockedWarning,
titleField, titleField,
descriptionField, descriptionField,
editActions, editActions,
projectMove,
confidentialCheckbox, confidentialCheckbox,
}, },
}; };
...@@ -45,6 +55,10 @@ ...@@ -45,6 +55,10 @@
:form-state="formState" :form-state="formState"
:markdown-preview-url="markdownPreviewUrl" :markdown-preview-url="markdownPreviewUrl"
:markdown-docs="markdownDocs" /> :markdown-docs="markdownDocs" />
<project-move
v-if="canMove"
:form-state="formState"
:projects-autocomplete-url="projectsAutocompleteUrl" />
<edit-actions <edit-actions
:form-state="formState" :form-state="formState"
:can-destroy="canDestroy" /> :can-destroy="canDestroy" />
......
...@@ -23,16 +23,19 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -23,16 +23,19 @@ document.addEventListener('DOMContentLoaded', () => {
const { const {
canUpdate, canUpdate,
canDestroy, canDestroy,
canMove,
endpoint, endpoint,
issuableRef, issuableRef,
isConfidential, isConfidential,
markdownPreviewUrl, markdownPreviewUrl,
markdownDocs, markdownDocs,
projectsAutocompleteUrl,
} = issuableElement.dataset; } = issuableElement.dataset;
return { return {
canUpdate: gl.utils.convertPermissionToBoolean(canUpdate), canUpdate: gl.utils.convertPermissionToBoolean(canUpdate),
canDestroy: gl.utils.convertPermissionToBoolean(canDestroy), canDestroy: gl.utils.convertPermissionToBoolean(canDestroy),
canMove: gl.utils.convertPermissionToBoolean(canMove),
endpoint, endpoint,
issuableRef, issuableRef,
initialTitle: issuableTitleElement.innerHTML, initialTitle: issuableTitleElement.innerHTML,
...@@ -41,6 +44,7 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -41,6 +44,7 @@ document.addEventListener('DOMContentLoaded', () => {
isConfidential: gl.utils.convertPermissionToBoolean(isConfidential), isConfidential: gl.utils.convertPermissionToBoolean(isConfidential),
markdownPreviewUrl, markdownPreviewUrl,
markdownDocs, markdownDocs,
projectsAutocompleteUrl,
}; };
}, },
render(createElement) { render(createElement) {
...@@ -48,6 +52,7 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -48,6 +52,7 @@ document.addEventListener('DOMContentLoaded', () => {
props: { props: {
canUpdate: this.canUpdate, canUpdate: this.canUpdate,
canDestroy: this.canDestroy, canDestroy: this.canDestroy,
canMove: this.canMove,
endpoint: this.endpoint, endpoint: this.endpoint,
issuableRef: this.issuableRef, issuableRef: this.issuableRef,
initialTitle: this.initialTitle, initialTitle: this.initialTitle,
...@@ -56,6 +61,7 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -56,6 +61,7 @@ document.addEventListener('DOMContentLoaded', () => {
isConfidential: this.isConfidential, isConfidential: this.isConfidential,
markdownPreviewUrl: this.markdownPreviewUrl, markdownPreviewUrl: this.markdownPreviewUrl,
markdownDocs: this.markdownDocs, markdownDocs: this.markdownDocs,
projectsAutocompleteUrl: this.projectsAutocompleteUrl,
}, },
}); });
}, },
......
...@@ -7,7 +7,7 @@ export default class Service { ...@@ -7,7 +7,7 @@ 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}.json`, {}, {
realtimeChanges: { realtimeChanges: {
method: 'GET', method: 'GET',
url: `${this.endpoint}/realtime_changes`, url: `${this.endpoint}/realtime_changes`,
......
...@@ -17,6 +17,7 @@ export default class Store { ...@@ -17,6 +17,7 @@ export default class Store {
confidential: false, confidential: false,
description: '', description: '',
lockedWarningVisible: false, lockedWarningVisible: false,
move_to_project_id: 0,
}; };
} }
......
...@@ -148,10 +148,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -148,10 +148,7 @@ class Projects::IssuesController < Projects::ApplicationController
format.json do format.json do
if @issue.valid? if @issue.valid?
render json: @issue.to_json(methods: [:task_status, :task_status_short], render json: IssueSerializer.new.represent(@issue)
include: { milestone: {},
assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
labels: { methods: :text_color } })
else else
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
end end
......
class IssueEntity < IssuableEntity class IssueEntity < IssuableEntity
include RequestAwareEntity
expose :branch_name expose :branch_name
expose :confidential expose :confidential
expose :assignees, using: API::Entities::UserBasic expose :assignees, using: API::Entities::UserBasic
...@@ -7,4 +9,8 @@ class IssueEntity < IssuableEntity ...@@ -7,4 +9,8 @@ class IssueEntity < IssuableEntity
expose :project_id expose :project_id
expose :milestone, using: API::Entities::Milestone expose :milestone, using: API::Entities::Milestone
expose :labels, using: LabelEntity expose :labels, using: LabelEntity
expose :path do |issue|
namespace_project_issue_path(issue.project.namespace, issue.project, issue)
end
end end
...@@ -54,10 +54,12 @@ ...@@ -54,10 +54,12 @@
#js-issuable-app{ "data" => { "endpoint" => 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,
"can-destroy" => can?(current_user, :destroy_issue, @issue).to_s, "can-destroy" => can?(current_user, :destroy_issue, @issue).to_s,
"can-move" => @issue.can_move?(current_user).to_s,
"issuable-ref" => @issue.to_reference, "issuable-ref" => @issue.to_reference,
"is-confidential" => @issue.confidential.to_s, "is-confidential" => @issue.confidential.to_s,
"markdown-preview-url" => preview_markdown_path(@project), "markdown-preview-url" => preview_markdown_path(@project),
"markdown-docs" => help_page_path('user/markdown'), "markdown-docs" => help_page_path('user/markdown'),
"projects-autocomplete-url" => autocomplete_projects_path(project_id: @project.id),
} } } }
%h2.title= markdown_field(@issue, :title) %h2.title= markdown_field(@issue, :title)
- if @issue.description.present? - if @issue.description.present?
......
...@@ -23,20 +23,22 @@ describe('Issuable output', () => { ...@@ -23,20 +23,22 @@ 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').and.callThrough(); spyOn(eventHub, '$emit');
vm = new IssuableDescriptionComponent({ vm = new IssuableDescriptionComponent({
propsData: { propsData: {
canUpdate: true, canUpdate: true,
canDestroy: true, canDestroy: true,
canMove: true,
endpoint: '/gitlab-org/gitlab-shell/issues/9/realtime_changes', endpoint: '/gitlab-org/gitlab-shell/issues/9/realtime_changes',
issuableRef: '#1', issuableRef: '#1',
initialTitle: '', initialTitle: '',
initialDescriptionHtml: '', initialDescriptionHtml: '',
initialDescriptionText: '', initialDescriptionText: '',
isConfidential: false,
markdownPreviewUrl: '/', markdownPreviewUrl: '/',
markdownDocs: '/', markdownDocs: '/',
projectsAutocompleteUrl: '/',
isConfidential: false,
}, },
}).$mount(); }).$mount();
}); });
...@@ -107,6 +109,30 @@ describe('Issuable output', () => { ...@@ -107,6 +109,30 @@ describe('Issuable output', () => {
}); });
describe('updateIssuable', () => { describe('updateIssuable', () => {
it('reloads the page if the confidential status has changed', (done) => {
spyOn(gl.utils, 'visitUrl');
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
resolve({
json() {
return {
confidential: true,
path: location.pathname,
};
},
});
}));
vm.updateIssuable();
setTimeout(() => {
expect(
gl.utils.visitUrl,
).toHaveBeenCalledWith(location.pathname);
done();
});
});
it('correctly updates issuable data', (done) => { it('correctly updates issuable data', (done) => {
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
resolve(); resolve();
...@@ -126,13 +152,38 @@ describe('Issuable output', () => { ...@@ -126,13 +152,38 @@ describe('Issuable output', () => {
}); });
}); });
it('reloads the page if the confidential status has changed', (done) => { it('does not redirect if issue has not moved', (done) => {
spyOn(window.location, 'reload'); spyOn(gl.utils, 'visitUrl');
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
resolve({ resolve({
json() { json() {
return { return {
confidential: true, path: location.pathname,
confidential: vm.isConfidential,
};
},
});
}));
vm.updateIssuable();
setTimeout(() => {
expect(
gl.utils.visitUrl,
).not.toHaveBeenCalled();
done();
});
});
it('redirects if issue is moved', (done) => {
spyOn(gl.utils, 'visitUrl');
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
resolve({
json() {
return {
path: '/testing-issue-move',
confidential: vm.isConfidential,
}; };
}, },
}); });
...@@ -142,8 +193,8 @@ describe('Issuable output', () => { ...@@ -142,8 +193,8 @@ describe('Issuable output', () => {
setTimeout(() => { setTimeout(() => {
expect( expect(
window.location.reload, gl.utils.visitUrl,
).toHaveBeenCalled(); ).toHaveBeenCalledWith('/testing-issue-move');
done(); done();
}); });
......
import Vue from 'vue'; import Vue from 'vue';
import Store from '~/issue_show/stores';
import descriptionField from '~/issue_show/components/fields/description.vue'; import descriptionField from '~/issue_show/components/fields/description.vue';
describe('Description field component', () => { describe('Description field component', () => {
let vm; let vm;
let store;
beforeEach((done) => { beforeEach((done) => {
const Component = Vue.extend(descriptionField); const Component = Vue.extend(descriptionField);
// Needs an el in the DOM to be able to test the element is focused
const el = document.createElement('div'); const el = document.createElement('div');
store = new Store({
titleHtml: '',
descriptionHtml: '',
issuableRef: '',
});
store.formState.description = 'test';
document.body.appendChild(el); document.body.appendChild(el);
vm = new Component({ vm = new Component({
el, el,
propsData: { propsData: {
formState: {
description: '',
},
markdownDocs: '/',
markdownPreviewUrl: '/', markdownPreviewUrl: '/',
markdownDocs: '/',
formState: store.formState,
}, },
}).$mount(); }).$mount();
Vue.nextTick(done); Vue.nextTick(done);
}); });
it('renders markdown field with description', () => {
expect(
vm.$el.querySelector('.md-area textarea').value,
).toBe('test');
});
it('renders markdown field with a markdown description', (done) => {
store.formState.description = '**test**';
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.md-area textarea').value,
).toBe('**test**');
done();
});
});
it('focuses field when mounted', () => { it('focuses field when mounted', () => {
expect( expect(
document.activeElement, document.activeElement,
......
import Vue from 'vue';
import projectMove from '~/issue_show/components/fields/project_move.vue';
describe('Project move field component', () => {
let vm;
let formState;
beforeEach((done) => {
const Component = Vue.extend(projectMove);
formState = {
move_to_project_id: 0,
};
vm = new Component({
propsData: {
formState,
projectsAutocompleteUrl: '/autocomplete',
},
}).$mount();
Vue.nextTick(done);
});
it('mounts select2 element', () => {
expect(
vm.$el.querySelector('.select2-container'),
).not.toBeNull();
});
it('updates formState on change', () => {
$(vm.$refs['move-dropdown']).val(2).trigger('change');
expect(
formState.move_to_project_id,
).toBe(2);
});
});
import Vue from 'vue';
import fieldComponent from '~/vue_shared/components/markdown/field.vue';
describe('Markdown field component', () => {
let vm;
beforeEach(() => {
vm = new Vue({
render(createElement) {
return createElement(
fieldComponent,
{
props: {
markdownPreviewUrl: '/preview',
markdownDocs: '/docs',
},
},
[
createElement('textarea', {
slot: 'textarea',
}),
],
);
},
});
});
it('creates a new instance of GL form', (done) => {
spyOn(gl, 'GLForm');
vm.$mount();
Vue.nextTick(() => {
expect(
gl.GLForm,
).toHaveBeenCalled();
done();
});
});
describe('mounted', () => {
beforeEach((done) => {
vm.$mount();
Vue.nextTick(done);
});
it('renders textarea inside backdrop', () => {
expect(
vm.$el.querySelector('.zen-backdrop textarea'),
).not.toBeNull();
});
describe('markdown preview', () => {
let previewLink;
beforeEach(() => {
spyOn(Vue.http, 'post').and.callFake(() => new Promise((resolve) => {
resolve({
json() {
return {
body: '<p>markdown preview</p>',
};
},
});
}));
previewLink = vm.$el.querySelector('.nav-links li:nth-child(2) a');
});
it('sets preview link as active', (done) => {
previewLink.click();
Vue.nextTick(() => {
expect(
previewLink.parentNode.classList.contains('active'),
).toBeTruthy();
done();
});
});
it('shows preview loading text', (done) => {
previewLink.click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.md-preview').textContent.trim(),
).toContain('Loading...');
done();
});
});
it('renders markdown preview', (done) => {
previewLink.click();
setTimeout(() => {
expect(
vm.$el.querySelector('.md-preview').innerHTML,
).toContain('<p>markdown preview</p>');
done();
});
});
it('renders GFM with jQuery', (done) => {
spyOn($.fn, 'renderGFM');
previewLink.click();
setTimeout(() => {
expect(
$.fn.renderGFM,
).toHaveBeenCalled();
done();
});
});
});
});
});
import Vue from 'vue';
import headerComponent from '~/vue_shared/components/markdown/header.vue';
describe('Markdown field header component', () => {
let vm;
beforeEach((done) => {
const Component = Vue.extend(headerComponent);
vm = new Component({
propsData: {
previewMarkdown: false,
},
}).$mount();
Vue.nextTick(done);
});
it('renders markdown buttons', () => {
expect(
vm.$el.querySelectorAll('.js-md').length,
).toBe(7);
});
it('renders `write` link as active when previewMarkdown is false', () => {
expect(
vm.$el.querySelector('li:nth-child(1)').classList.contains('active'),
).toBeTruthy();
});
it('renders `preview` link as active when previewMarkdown is true', (done) => {
vm.previewMarkdown = true;
Vue.nextTick(() => {
expect(
vm.$el.querySelector('li:nth-child(2)').classList.contains('active'),
).toBeTruthy();
done();
});
});
it('emits toggle markdown event when clicking preview', () => {
spyOn(vm, '$emit');
vm.$el.querySelector('li:nth-child(2) a').click();
expect(
vm.$emit,
).toHaveBeenCalledWith('toggle-markdown');
});
it('blurs preview link after click', (done) => {
const link = vm.$el.querySelector('li:nth-child(2) a');
spyOn(HTMLElement.prototype, 'blur');
link.click();
setTimeout(() => {
expect(
link.blur,
).toHaveBeenCalled();
done();
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment