diff options
11 files changed, 217 insertions, 31 deletions
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 47d9a27e99e..2a7a7d3900e 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -15,6 +15,10 @@ export default { required: true, type: String, }, + canMove: { + required: true, + type: Boolean, + }, canUpdate: { required: true, type: Boolean, @@ -53,6 +57,10 @@ export default { type: String, required: true, }, + projectsAutocompleteUrl: { + type: String, + required: true, + }, }, data() { const store = new Store({ @@ -85,6 +93,7 @@ export default { title: this.state.titleText, confidential: this.isConfidential, description: this.state.descriptionText, + move_to_project_id: 0, }; } }, @@ -93,11 +102,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); + } if (data.confidential !== this.isConfidential) { + gl.utils.visitUrl(location.href); } eventHub.$emit('close.form'); @@ -166,9 +176,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..701c7f0ea9b --- /dev/null +++ b/app/assets/javascripts/issue_show/components/fields/project_move.vue @@ -0,0 +1,84 @@ +<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" + style="cursor: default" + 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..8848ca4e078 100644 --- a/spec/javascripts/issue_show/components/app_spec.js +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -14,7 +14,7 @@ const issueShowInterceptor = data => (request, next) => { })); }; -describe('Issuable output', () => { +fdescribe('Issuable output', () => { document.body.innerHTML = '<span id="task_status"></span>'; let vm; @@ -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(); }); @@ -90,23 +92,30 @@ describe('Issuable output', () => { }); }); - it('does not update formState if form is already open', (done) => { - vm.openForm(); - - vm.state.titleText = 'testing 123'; + 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, + }; + }, + }); + })); - vm.openForm(); + vm.updateIssuable(); - Vue.nextTick(() => { - expect( - vm.store.formState.title, - ).not.toBe('testing 123'); + setTimeout(() => { + expect( + gl.utils.visitUrl, + ).toHaveBeenCalledWith(location.href); - done(); + done(); + }); }); - }); - describe('updateIssuable', () => { it('correctly updates issuable data', (done) => { spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { resolve(); @@ -126,13 +135,30 @@ 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(); + })); + + 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 { - confidential: true, + path: '/testing-issue-move', }; }, }); @@ -142,8 +168,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); + }); +}); |