summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFilipa Lacerda <filipa@gitlab.com>2017-05-19 09:10:16 +0000
committerFilipa Lacerda <filipa@gitlab.com>2017-05-19 09:10:16 +0000
commit3c3b17a5a4b4f85f3f81f918a7e0c3a57f469eb7 (patch)
tree666184027fa57a31338248aa1ee230704b31bb53
parenteee88bca762d531256705ffe1c16767fe5bb2a6e (diff)
parent1c463586c4b1aa39f8c55ec7de684faa13aa5d8b (diff)
downloadgitlab-ce-3c3b17a5a4b4f85f3f81f918a7e0c3a57f469eb7.tar.gz
Merge branch 'issue-edit-inline-move-project' into 'issue-edit-inline'
Added move to project in issue inline edit form See merge request !11437
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue25
-rw-r--r--app/assets/javascripts/issue_show/components/fields/project_move.vue83
-rw-r--r--app/assets/javascripts/issue_show/components/form.vue14
-rw-r--r--app/assets/javascripts/issue_show/index.js6
-rw-r--r--app/assets/javascripts/issue_show/services/index.js2
-rw-r--r--app/assets/javascripts/issue_show/stores/index.js1
-rw-r--r--app/controllers/projects/issues_controller.rb5
-rw-r--r--app/serializers/issue_entity.rb6
-rw-r--r--app/views/projects/issues/show.html.haml2
-rw-r--r--spec/javascripts/issue_show/components/app_spec.js65
-rw-r--r--spec/javascripts/issue_show/components/fields/project_move_spec.js38
11 files changed, 229 insertions, 18 deletions
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index 47d9a27e99e..c4154745bf8 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -8,6 +8,7 @@ import Store from '../stores';
import titleComponent from './title.vue';
import descriptionComponent from './description.vue';
import formComponent from './form.vue';
+import '../../lib/utils/url_utility';
export default {
props: {
@@ -15,6 +16,10 @@ export default {
required: true,
type: String,
},
+ canMove: {
+ required: true,
+ type: Boolean,
+ },
canUpdate: {
required: true,
type: Boolean,
@@ -53,6 +58,10 @@ export default {
type: String,
required: true,
},
+ projectsAutocompleteUrl: {
+ type: String,
+ required: true,
+ },
},
data() {
const store = new Store({
@@ -85,6 +94,7 @@ export default {
title: this.state.titleText,
confidential: this.isConfidential,
description: this.state.descriptionText,
+ move_to_project_id: 0,
};
}
},
@@ -93,11 +103,12 @@ export default {
},
updateIssuable() {
this.service.updateIssuable(this.store.formState)
- .then((res) => {
- const data = res.json();
-
- if (data.confidential !== this.isConfidential) {
- location.reload();
+ .then(res => res.json())
+ .then((data) => {
+ if (location.pathname !== data.path) {
+ gl.utils.visitUrl(data.path);
+ } else if (data.confidential !== this.isConfidential) {
+ gl.utils.visitUrl(location.pathname);
}
eventHub.$emit('close.form');
@@ -166,9 +177,11 @@ export default {
<form-component
v-if="canUpdate && showForm"
:form-state="formState"
+ :can-move="canMove"
:can-destroy="canDestroy"
:markdown-docs="markdownDocs"
- :markdown-preview-url="markdownPreviewUrl" />
+ :markdown-preview-url="markdownPreviewUrl"
+ :projects-autocomplete-url="projectsAutocompleteUrl" />
<div v-else>
<title-component
:issuable-ref="issuableRef"
diff --git a/app/assets/javascripts/issue_show/components/fields/project_move.vue b/app/assets/javascripts/issue_show/components/fields/project_move.vue
new file mode 100644
index 00000000000..f811fb0de24
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/project_move.vue
@@ -0,0 +1,83 @@
+<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>
diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue
index 4288c5f8d90..1b50b1dcd03 100644
--- a/app/assets/javascripts/issue_show/components/form.vue
+++ b/app/assets/javascripts/issue_show/components/form.vue
@@ -2,10 +2,15 @@
import titleField from './fields/title.vue';
import descriptionField from './fields/description.vue';
import editActions from './edit_actions.vue';
+ import projectMove from './fields/project_move.vue';
import confidentialCheckbox from './fields/confidential_checkbox.vue';
export default {
props: {
+ canMove: {
+ type: Boolean,
+ required: true,
+ },
canDestroy: {
type: Boolean,
required: true,
@@ -22,11 +27,16 @@
type: String,
required: true,
},
+ projectsAutocompleteUrl: {
+ type: String,
+ required: true,
+ },
},
components: {
titleField,
descriptionField,
editActions,
+ projectMove,
confidentialCheckbox,
},
};
@@ -42,6 +52,10 @@
:form-state="formState"
:markdown-preview-url="markdownPreviewUrl"
:markdown-docs="markdownDocs" />
+ <project-move
+ v-if="canMove"
+ :form-state="formState"
+ :projects-autocomplete-url="projectsAutocompleteUrl" />
<edit-actions
:form-state="formState"
:can-destroy="canDestroy" />
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index 3b69be05cf3..ce262a75e11 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -23,16 +23,19 @@ document.addEventListener('DOMContentLoaded', () => {
const {
canUpdate,
canDestroy,
+ canMove,
endpoint,
issuableRef,
isConfidential,
markdownPreviewUrl,
markdownDocs,
+ projectsAutocompleteUrl,
} = issuableElement.dataset;
return {
canUpdate: gl.utils.convertPermissionToBoolean(canUpdate),
canDestroy: gl.utils.convertPermissionToBoolean(canDestroy),
+ canMove: gl.utils.convertPermissionToBoolean(canMove),
endpoint,
issuableRef,
initialTitle: issuableTitleElement.innerHTML,
@@ -41,6 +44,7 @@ document.addEventListener('DOMContentLoaded', () => {
isConfidential: gl.utils.convertPermissionToBoolean(isConfidential),
markdownPreviewUrl,
markdownDocs,
+ projectsAutocompleteUrl,
};
},
render(createElement) {
@@ -48,6 +52,7 @@ document.addEventListener('DOMContentLoaded', () => {
props: {
canUpdate: this.canUpdate,
canDestroy: this.canDestroy,
+ canMove: this.canMove,
endpoint: this.endpoint,
issuableRef: this.issuableRef,
initialTitle: this.initialTitle,
@@ -56,6 +61,7 @@ document.addEventListener('DOMContentLoaded', () => {
isConfidential: this.isConfidential,
markdownPreviewUrl: this.markdownPreviewUrl,
markdownDocs: this.markdownDocs,
+ projectsAutocompleteUrl: this.projectsAutocompleteUrl,
},
});
},
diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js
index 0ceff34cf8b..6f0fd0b1768 100644
--- a/app/assets/javascripts/issue_show/services/index.js
+++ b/app/assets/javascripts/issue_show/services/index.js
@@ -7,7 +7,7 @@ export default class Service {
constructor(endpoint) {
this.endpoint = endpoint;
- this.resource = Vue.resource(this.endpoint, {}, {
+ this.resource = Vue.resource(`${this.endpoint}.json`, {}, {
realtimeChanges: {
method: 'GET',
url: `${this.endpoint}/realtime_changes`,
diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js
index d90716bef80..1135bc0bfb5 100644
--- a/app/assets/javascripts/issue_show/stores/index.js
+++ b/app/assets/javascripts/issue_show/stores/index.js
@@ -16,6 +16,7 @@ export default class Store {
title: '',
confidential: false,
description: '',
+ move_to_project_id: 0,
};
}
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 46438e68d54..9d28a7ed85a 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -148,10 +148,7 @@ class Projects::IssuesController < Projects::ApplicationController
format.json do
if @issue.valid?
- render json: @issue.to_json(methods: [:task_status, :task_status_short],
- include: { milestone: {},
- assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
- labels: { methods: :text_color } })
+ render json: IssueSerializer.new.represent(@issue)
else
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
end
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index bc4f68710b2..6bc8d0f70c3 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -1,4 +1,6 @@
class IssueEntity < IssuableEntity
+ include RequestAwareEntity
+
expose :branch_name
expose :confidential
expose :assignees, using: API::Entities::UserBasic
@@ -7,4 +9,8 @@ class IssueEntity < IssuableEntity
expose :project_id
expose :milestone, using: API::Entities::Milestone
expose :labels, using: LabelEntity
+
+ expose :path do |issue|
+ namespace_project_issue_path(issue.project.namespace, issue.project, issue)
+ end
end
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 9afffdba354..8359910c679 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -54,10 +54,12 @@
#js-issuable-app{ "data" => { "endpoint" => namespace_project_issue_path(@project.namespace, @project, @issue),
"can-update" => can?(current_user, :update_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,
"is-confidential" => @issue.confidential.to_s,
"markdown-preview-url" => preview_markdown_path(@project),
"markdown-docs" => help_page_path('user/markdown'),
+ "projects-autocomplete-url" => autocomplete_projects_path(project_id: @project.id),
} }
%h2.title= markdown_field(@issue, :title)
- if @issue.description.present?
diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js
index 646fb455d7c..91ae3cfd97c 100644
--- a/spec/javascripts/issue_show/components/app_spec.js
+++ b/spec/javascripts/issue_show/components/app_spec.js
@@ -23,20 +23,22 @@ describe('Issuable output', () => {
const IssuableDescriptionComponent = Vue.extend(issuableApp);
Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest));
- spyOn(eventHub, '$emit').and.callThrough();
+ spyOn(eventHub, '$emit');
vm = new IssuableDescriptionComponent({
propsData: {
canUpdate: true,
canDestroy: true,
+ canMove: true,
endpoint: '/gitlab-org/gitlab-shell/issues/9/realtime_changes',
issuableRef: '#1',
initialTitle: '',
initialDescriptionHtml: '',
initialDescriptionText: '',
- isConfidential: false,
markdownPreviewUrl: '/',
markdownDocs: '/',
+ projectsAutocompleteUrl: '/',
+ isConfidential: false,
},
}).$mount();
});
@@ -107,6 +109,30 @@ describe('Issuable output', () => {
});
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) => {
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
resolve();
@@ -126,13 +152,38 @@ describe('Issuable output', () => {
});
});
- it('reloads the page if the confidential status has changed', (done) => {
- spyOn(window.location, 'reload');
+ it('does not redirect if issue has not moved', (done) => {
+ spyOn(gl.utils, 'visitUrl');
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
resolve({
json() {
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', () => {
setTimeout(() => {
expect(
- window.location.reload,
- ).toHaveBeenCalled();
+ gl.utils.visitUrl,
+ ).toHaveBeenCalledWith('/testing-issue-move');
done();
});
diff --git a/spec/javascripts/issue_show/components/fields/project_move_spec.js b/spec/javascripts/issue_show/components/fields/project_move_spec.js
new file mode 100644
index 00000000000..86d35c33ff4
--- /dev/null
+++ b/spec/javascripts/issue_show/components/fields/project_move_spec.js
@@ -0,0 +1,38 @@
+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);
+ });
+});