Commit 90c60138 authored by Eric Eastwood's avatar Eric Eastwood

Move "Move to different project" to sidebar

Fix https://gitlab.com/gitlab-org/gitlab-ce/issues/34261
parent a3af6830
...@@ -486,7 +486,7 @@ GitLabDropdown = (function() { ...@@ -486,7 +486,7 @@ GitLabDropdown = (function() {
GitLabDropdown.prototype.shouldPropagate = function(e) { GitLabDropdown.prototype.shouldPropagate = function(e) {
var $target; var $target;
if (this.options.multiSelect) { if (this.options.multiSelect || this.options.shouldPropagate === false) {
$target = $(e.target); $target = $(e.target);
if ($target && !$target.hasClass('dropdown-menu-close') && if ($target && !$target.hasClass('dropdown-menu-close') &&
!$target.hasClass('dropdown-menu-close-icon') && !$target.hasClass('dropdown-menu-close-icon') &&
...@@ -546,10 +546,10 @@ GitLabDropdown = (function() { ...@@ -546,10 +546,10 @@ GitLabDropdown = (function() {
}; };
GitLabDropdown.prototype.positionMenuAbove = function() { GitLabDropdown.prototype.positionMenuAbove = function() {
var $button = $(this.el);
var $menu = this.dropdown.find('.dropdown-menu'); var $menu = this.dropdown.find('.dropdown-menu');
$menu.css('top', ($button.height() + $menu.height()) * -1); $menu.css('top', 'initial');
$menu.css('bottom', '100%');
}; };
GitLabDropdown.prototype.hidden = function(e) { GitLabDropdown.prototype.hidden = function(e) {
...@@ -698,7 +698,7 @@ GitLabDropdown = (function() { ...@@ -698,7 +698,7 @@ GitLabDropdown = (function() {
GitLabDropdown.prototype.noResults = function() { GitLabDropdown.prototype.noResults = function() {
var html; var html;
return html = "<li class='dropdown-menu-empty-link'> <a href='#' class='is-focused'> No matching results. </a> </li>"; return html = '<li class="dropdown-menu-empty-link"><a href="#" class="is-focused">No matching results</a></li>';
}; };
GitLabDropdown.prototype.rowClicked = function(el) { GitLabDropdown.prototype.rowClicked = function(el) {
......
...@@ -10,8 +10,6 @@ import ZenMode from './zen_mode'; ...@@ -10,8 +10,6 @@ import ZenMode from './zen_mode';
(function() { (function() {
this.IssuableForm = (function() { this.IssuableForm = (function() {
IssuableForm.prototype.issueMoveConfirmMsg = 'Are you sure you want to move this issue to another project?';
IssuableForm.prototype.wipRegex = /^\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i; IssuableForm.prototype.wipRegex = /^\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i;
function IssuableForm(form) { function IssuableForm(form) {
...@@ -26,7 +24,6 @@ import ZenMode from './zen_mode'; ...@@ -26,7 +24,6 @@ import ZenMode from './zen_mode';
new ZenMode(); new ZenMode();
this.titleField = this.form.find("input[name*='[title]']"); this.titleField = this.form.find("input[name*='[title]']");
this.descriptionField = this.form.find("textarea[name*='[description]']"); this.descriptionField = this.form.find("textarea[name*='[description]']");
this.issueMoveField = this.form.find("#move_to_project_id");
if (!(this.titleField.length && this.descriptionField.length)) { if (!(this.titleField.length && this.descriptionField.length)) {
return; return;
} }
...@@ -34,7 +31,6 @@ import ZenMode from './zen_mode'; ...@@ -34,7 +31,6 @@ import ZenMode from './zen_mode';
this.form.on("submit", this.handleSubmit); this.form.on("submit", this.handleSubmit);
this.form.on("click", ".btn-cancel", this.resetAutosave); this.form.on("click", ".btn-cancel", this.resetAutosave);
this.initWip(); this.initWip();
this.initMoveDropdown();
$issuableDueDate = $('#issuable-due-date'); $issuableDueDate = $('#issuable-due-date');
if ($issuableDueDate.length) { if ($issuableDueDate.length) {
calendar = new Pikaday({ calendar = new Pikaday({
...@@ -56,12 +52,6 @@ import ZenMode from './zen_mode'; ...@@ -56,12 +52,6 @@ import ZenMode from './zen_mode';
}; };
IssuableForm.prototype.handleSubmit = function() { IssuableForm.prototype.handleSubmit = function() {
var fieldId = (this.issueMoveField != null) ? this.issueMoveField.val() : null;
if ((parseInt(fieldId, 10) || 0) > 0) {
if (!confirm(this.issueMoveConfirmMsg)) {
return false;
}
}
return this.resetAutosave(); return this.resetAutosave();
}; };
...@@ -113,48 +103,6 @@ import ZenMode from './zen_mode'; ...@@ -113,48 +103,6 @@ import ZenMode from './zen_mode';
return this.titleField.val("WIP: " + (this.titleField.val())); return this.titleField.val("WIP: " + (this.titleField.val()));
}; };
IssuableForm.prototype.initMoveDropdown = function() {
var $moveDropdown, pageSize;
$moveDropdown = $('.js-move-dropdown');
if ($moveDropdown.length) {
pageSize = $moveDropdown.data('page-size');
return $('.js-move-dropdown').select2({
ajax: {
url: $moveDropdown.data('projects-url'),
quietMillis: 125,
data: function(term, page, context) {
return {
search: term,
offset_id: context
};
},
results: function(data) {
var context,
more;
if (data.length >= pageSize)
more = true;
if (data[data.length - 1])
context = data[data.length - 1].id;
return {
results: data,
more: more,
context: context
};
}
},
formatResult: function(project) {
return project.name_with_namespace;
},
formatSelection: function(project) {
return project.name_with_namespace;
}
});
}
};
return IssuableForm; return IssuableForm;
})(); })();
}).call(window); }).call(window);
...@@ -17,10 +17,6 @@ export default { ...@@ -17,10 +17,6 @@ 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,
...@@ -96,10 +92,6 @@ export default { ...@@ -96,10 +92,6 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
projectsAutocompletePath: {
type: String,
required: true,
},
}, },
data() { data() {
const store = new Store({ const store = new Store({
...@@ -142,7 +134,6 @@ export default { ...@@ -142,7 +134,6 @@ export default {
confidential: this.isConfidential, confidential: this.isConfidential,
description: this.state.descriptionText, description: this.state.descriptionText,
lockedWarningVisible: false, lockedWarningVisible: false,
move_to_project_id: 0,
updateLoading: false, updateLoading: false,
}); });
} }
...@@ -151,16 +142,6 @@ export default { ...@@ -151,16 +142,6 @@ export default {
this.showForm = false; this.showForm = false;
}, },
updateIssuable() { updateIssuable() {
const canPostUpdate = this.store.formState.move_to_project_id !== 0 ?
confirm('Are you sure you want to move this issue to another project?') : true; // eslint-disable-line no-alert
if (!canPostUpdate) {
this.store.setFormState({
updateLoading: false,
});
return;
}
this.service.updateIssuable(this.store.formState) this.service.updateIssuable(this.store.formState)
.then(res => res.json()) .then(res => res.json())
.then((data) => { .then((data) => {
...@@ -239,14 +220,12 @@ export default { ...@@ -239,14 +220,12 @@ 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"
:issuable-templates="issuableTemplates" :issuable-templates="issuableTemplates"
:markdown-docs-path="markdownDocsPath" :markdown-docs-path="markdownDocsPath"
:markdown-preview-path="markdownPreviewPath" :markdown-preview-path="markdownPreviewPath"
:project-path="projectPath" :project-path="projectPath"
:project-namespace="projectNamespace" :project-namespace="projectNamespace"
:projects-autocomplete-path="projectsAutocompletePath"
/> />
<div v-else> <div v-else>
<title-component <title-component
......
<script>
import tooltip from '../../../vue_shared/directives/tooltip';
export default {
directives: {
tooltip,
},
props: {
formState: {
type: Object,
required: true,
},
projectsAutocompletePath: {
type: String,
required: true,
},
},
mounted() {
const $moveDropdown = $(this.$refs['move-dropdown']);
$moveDropdown.select2({
ajax: {
url: this.projectsAutocompletePath,
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
v-tooltip
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.">
<i
class="fa fa-question-circle"
aria-hidden="true">
</i>
</span>
</fieldset>
</template>
...@@ -4,15 +4,10 @@ ...@@ -4,15 +4,10 @@
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 descriptionTemplate from './fields/description_template.vue'; import descriptionTemplate from './fields/description_template.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,
...@@ -42,10 +37,6 @@ ...@@ -42,10 +37,6 @@
type: String, type: String,
required: true, required: true,
}, },
projectsAutocompletePath: {
type: String,
required: true,
},
}, },
components: { components: {
lockedWarning, lockedWarning,
...@@ -53,7 +44,6 @@ ...@@ -53,7 +44,6 @@
descriptionField, descriptionField,
descriptionTemplate, descriptionTemplate,
editActions, editActions,
projectMove,
confidentialCheckbox, confidentialCheckbox,
}, },
computed: { computed: {
...@@ -93,10 +83,6 @@ ...@@ -93,10 +83,6 @@
:markdown-docs-path="markdownDocsPath" /> :markdown-docs-path="markdownDocsPath" />
<confidential-checkbox <confidential-checkbox
:form-state="formState" /> :form-state="formState" />
<project-move
v-if="canMove"
:form-state="formState"
:projects-autocomplete-path="projectsAutocompletePath" />
<edit-actions <edit-actions
:form-state="formState" :form-state="formState"
:can-destroy="canDestroy" /> :can-destroy="canDestroy" />
......
...@@ -28,7 +28,6 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -28,7 +28,6 @@ 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,
initialTitleHtml: this.initialTitleHtml, initialTitleHtml: this.initialTitleHtml,
...@@ -41,7 +40,6 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -41,7 +40,6 @@ document.addEventListener('DOMContentLoaded', () => {
markdownDocsPath: this.markdownDocsPath, markdownDocsPath: this.markdownDocsPath,
projectPath: this.projectPath, projectPath: this.projectPath,
projectNamespace: this.projectNamespace, projectNamespace: this.projectNamespace,
projectsAutocompletePath: this.projectsAutocompletePath,
updatedAt: this.updatedAt, updatedAt: this.updatedAt,
updatedByName: this.updatedByName, updatedByName: this.updatedByName,
updatedByPath: this.updatedByPath, updatedByPath: this.updatedByPath,
......
...@@ -6,7 +6,6 @@ export default class Store { ...@@ -6,7 +6,6 @@ export default class Store {
confidential: false, confidential: false,
description: '', description: '',
lockedWarningVisible: false, lockedWarningVisible: false,
move_to_project_id: 0,
updateLoading: false, updateLoading: false,
}; };
} }
......
...@@ -157,11 +157,16 @@ import SidebarHeightManager from './sidebar_height_manager'; ...@@ -157,11 +157,16 @@ import SidebarHeightManager from './sidebar_height_manager';
Sidebar.prototype.openDropdown = function(blockOrName) { Sidebar.prototype.openDropdown = function(blockOrName) {
var $block; var $block;
$block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName; $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName;
$block.find('.edit-link').trigger('click');
if (!this.isOpen()) { if (!this.isOpen()) {
this.setCollapseAfterUpdate($block); this.setCollapseAfterUpdate($block);
return this.toggleSidebar('open'); this.toggleSidebar('open');
} }
// Wait for the sidebar to trigger('click') open
// so it doesn't cause our dropdown to close preemptively
setTimeout(() => {
$block.find('.js-sidebar-dropdown-toggle').trigger('click');
});
}; };
Sidebar.prototype.setCollapseAfterUpdate = function($block) { Sidebar.prototype.setCollapseAfterUpdate = function($block) {
......
...@@ -36,7 +36,7 @@ export default { ...@@ -36,7 +36,7 @@ export default {
/> />
<a <a
v-if="editable" v-if="editable"
class="edit-link pull-right" class="js-sidebar-dropdown-toggle edit-link pull-right"
href="#" href="#"
> >
Edit Edit
......
/* global Flash */
function isValidProjectId(id) {
return id > 0;
}
class SidebarMoveIssue {
constructor(mediator, dropdownToggle, confirmButton) {
this.mediator = mediator;
this.$dropdownToggle = $(dropdownToggle);
this.$confirmButton = $(confirmButton);
this.onConfirmClickedWrapper = this.onConfirmClicked.bind(this);
}
init() {
this.initDropdown();
this.addEventListeners();
}
destroy() {
this.removeEventListeners();
}
initDropdown() {
this.$dropdownToggle.glDropdown({
search: {
fields: ['name_with_namespace'],
},
showMenuAbove: true,
selectable: true,
filterable: true,
filterRemote: true,
multiSelect: false,
// Keep the dropdown open after selecting an option
shouldPropagate: false,
data: (searchTerm, callback) => {
this.mediator.fetchAutocompleteProjects(searchTerm)
.then(callback)
.catch(() => new Flash('An error occured while fetching projects autocomplete.'));
},
renderRow: project => `
<li>
<a href="#" class="js-move-issue-dropdown-item">
${project.name_with_namespace}
</a>
</li>
`,
clicked: (options) => {
const project = options.selectedObj;
const selectedProjectId = options.isMarking ? project.id : 0;
this.mediator.setMoveToProjectId(selectedProjectId);
this.$confirmButton.attr('disabled', !isValidProjectId(selectedProjectId));
},
});
}
addEventListeners() {
this.$confirmButton.on('click', this.onConfirmClickedWrapper);
}
removeEventListeners() {
this.$confirmButton.off('click', this.onConfirmClickedWrapper);
}
onConfirmClicked() {
if (isValidProjectId(this.mediator.store.moveToProjectId)) {
this.$confirmButton
.disable()
.addClass('is-loading');
this.mediator.moveIssue()
.catch(() => {
Flash('An error occured while moving the issue.');
this.$confirmButton
.enable()
.removeClass('is-loading');
});
}
}
}
export default SidebarMoveIssue;
...@@ -4,9 +4,11 @@ import VueResource from 'vue-resource'; ...@@ -4,9 +4,11 @@ import VueResource from 'vue-resource';
Vue.use(VueResource); Vue.use(VueResource);
export default class SidebarService { export default class SidebarService {
constructor(endpoint) { constructor(endpointMap) {
if (!SidebarService.singleton) { if (!SidebarService.singleton) {
this.endpoint = endpoint; this.endpoint = endpointMap.endpoint;
this.moveIssueEndpoint = endpointMap.moveIssueEndpoint;
this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint;
SidebarService.singleton = this; SidebarService.singleton = this;
} }
...@@ -25,4 +27,18 @@ export default class SidebarService { ...@@ -25,4 +27,18 @@ export default class SidebarService {
emulateJSON: true, emulateJSON: true,
}); });
} }
getProjectsAutocomplete(searchTerm) {
return Vue.http.get(this.projectsAutocompleteEndpoint, {
params: {
search: searchTerm,
},
});
}
moveIssue(moveToProjectId) {
return Vue.http.post(this.moveIssueEndpoint, {
move_to_project_id: moveToProjectId,
});
}
} }
...@@ -2,6 +2,7 @@ import Vue from 'vue'; ...@@ -2,6 +2,7 @@ import Vue from 'vue';
import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking'; import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
import sidebarAssignees from './components/assignees/sidebar_assignees'; import sidebarAssignees from './components/assignees/sidebar_assignees';
import confidential from './components/confidential/confidential_issue_sidebar.vue'; import confidential from './components/confidential/confidential_issue_sidebar.vue';
import SidebarMoveIssue from './lib/sidebar_move_issue';
import Mediator from './sidebar_mediator'; import Mediator from './sidebar_mediator';
...@@ -31,6 +32,12 @@ function domContentLoaded() { ...@@ -31,6 +32,12 @@ function domContentLoaded() {
service: mediator.service, service: mediator.service,
}, },
}).$mount(confidentialEl); }).$mount(confidentialEl);
new SidebarMoveIssue(
mediator,
$('.js-move-issue'),
$('.js-move-issue-confirmation-button'),
).init();
} }
new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker'); new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker');
......
...@@ -7,7 +7,11 @@ export default class SidebarMediator { ...@@ -7,7 +7,11 @@ export default class SidebarMediator {
constructor(options) { constructor(options) {
if (!SidebarMediator.singleton) { if (!SidebarMediator.singleton) {
this.store = new Store(options); this.store = new Store(options);
this.service = new Service(options.endpoint); this.service = new Service({
endpoint: options.endpoint,
moveIssueEndpoint: options.moveIssueEndpoint,
projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint,
});
SidebarMediator.singleton = this; SidebarMediator.singleton = this;
} }
...@@ -26,6 +30,10 @@ export default class SidebarMediator { ...@@ -26,6 +30,10 @@ export default class SidebarMediator {
return this.service.update(field, selected.length === 0 ? [0] : selected); return this.service.update(field, selected.length === 0 ? [0] : selected);
} }
setMoveToProjectId(projectId) {
this.store.setMoveToProjectId(projectId);
}
fetch() { fetch() {
this.service.get() this.service.get()
.then(response => response.json()) .then(response => response.json())
...@@ -35,4 +43,23 @@ export default class SidebarMediator { ...@@ -35,4 +43,23 @@ export default class SidebarMediator {
}) })
.catch(() => new Flash('Error occured when fetching sidebar data')); .catch(() => new Flash('Error occured when fetching sidebar data'));
} }
fetchAutocompleteProjects(searchTerm) {
return this.service.getProjectsAutocomplete(searchTerm)
.then(response => response.json())
.then((data) => {
this.store.setAutocompleteProjects(data);
return this.store.autocompleteProjects;
});
}
moveIssue() {
return this.service.moveIssue(this.store.moveToProjectId)
.then(response => response.json())
.then((data) => {
if (location.pathname !== data.web_url) {
gl.utils.visitUrl(data.web_url);
}
});
}
} }
...@@ -13,6 +13,8 @@ export default class SidebarStore { ...@@ -13,6 +13,8 @@ export default class SidebarStore {
this.isFetching = { this.isFetching = {
assignees: true, assignees: true,
}; };
this.autocompleteProjects = [];
this.moveToProjectId = 0;
SidebarStore.singleton = this; SidebarStore.singleton = this;
} }
...@@ -53,4 +55,12 @@ export default class SidebarStore { ...@@ -53,4 +55,12 @@ export default class SidebarStore {
removeAllAssignees() { removeAllAssignees() {
this.assignees = []; this.assignees = [];
} }
setAutocompleteProjects(projects) {
this.autocompleteProjects = projects;
}
setMoveToProjectId(moveToProjectId) {
this.moveToProjectId = moveToProjectId;
}
} }
...@@ -193,7 +193,7 @@ ...@@ -193,7 +193,7 @@
min-width: 240px; min-width: 240px;
max-width: 500px; max-width: 500px;
margin-top: 2px; margin-top: 2px;
margin-bottom: 0; margin-bottom: 2px;
font-size: 14px; font-size: 14px;
font-weight: $gl-font-weight-normal; font-weight: $gl-font-weight-normal;
padding: 8px 0; padding: 8px 0;
...@@ -622,6 +622,11 @@ ...@@ -622,6 +622,11 @@
border-top: 1px solid $dropdown-divider-color; border-top: 1px solid $dropdown-divider-color;
} }
.dropdown-footer-content {
padding-left: 10px;
padding-right: 10px;
}
.dropdown-due-date-footer { .dropdown-due-date-footer {
padding-top: 0; padding-top: 0;
margin-left: 10px; margin-left: 10px;
......
...@@ -473,7 +473,7 @@ ...@@ -473,7 +473,7 @@
padding-top: 6px; padding-top: 6px;
} }
.open .dropdown-menu { .dropdown-menu {
width: 100%; width: 100%;
} }
} }
...@@ -486,6 +486,24 @@ ...@@ -486,6 +486,24 @@
} }
} }
.sidebar-move-issue-dropdown {
@include new-style-dropdown;
}
.sidebar-move-issue-confirmation-button {
width: 100%;
&.is-loading {
.sidebar-move-issue-confirmation-loading-icon {
display: inline-block;
}
}
}
.sidebar-move-issue-confirmation-loading-icon {
display: none;
}
.detail-page-description { .detail-page-description {
padding: 16px 0; padding: 16px 0;
......
...@@ -41,12 +41,6 @@ class AutocompleteController < ApplicationController ...@@ -41,12 +41,6 @@ class AutocompleteController < ApplicationController
project = Project.find_by_id(params[:project_id]) project = Project.find_by_id(params[:project_id])
projects = projects_finder.execute(project, search: params[:search], offset_id: params[:offset_id]) projects = projects_finder.execute(project, search: params[:search], offset_id: params[:offset_id])
no_project = {
id: 0,
name_with_namespace: 'No project'
}
projects.unshift(no_project) unless params[:offset_id].present?
render json: projects.to_json(only: [:id, :name_with_namespace], methods: :name_with_namespace) render json: projects.to_json(only: [:id, :name_with_namespace], methods: :name_with_namespace)
end end
......
...@@ -15,7 +15,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -15,7 +15,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :authorize_create_issue!, only: [:new, :create] before_action :authorize_create_issue!, only: [:new, :create]
# Allow modify issue # Allow modify issue
before_action :authorize_update_issue!, only: [:edit, :update] before_action :authorize_update_issue!, only: [:edit, :update, :move]
# Allow create a new branch and empty WIP merge request from current issue # Allow create a new branch and empty WIP merge request from current issue
before_action :authorize_create_merge_request!, only: [:create_merge_request] before_action :authorize_create_merge_request!, only: [:create_merge_request]
...@@ -142,25 +142,33 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -142,25 +142,33 @@ class Projects::IssuesController < Projects::ApplicationController
@issue = Issues::UpdateService.new(project, current_user, update_params).execute(issue) @issue = Issues::UpdateService.new(project, current_user, update_params).execute(issue)
respond_to do |format|
format.html do
recaptcha_check_with_fallback { render :edit }
end
format.json do
render_issue_json
end
end
rescue ActiveRecord::StaleObjectError
render_conflict_response
end
def move
params.require(:move_to_project_id)
if params[:move_to_project_id].to_i > 0 if params[:move_to_project_id].to_i > 0
new_project = Project.find(params[:move_to_project_id]) new_project = Project.find(params[:move_to_project_id])
return render_404 unless issue.can_move?(current_user, new_project) return render_404 unless issue.can_move?(current_user, new_project)
move_service = Issues::MoveService.new(project, current_user) @issue = Issues::UpdateService.new(project, current_user, target_project: new_project).execute(issue)
@issue = move_service.execute(@issue, new_project)
end end
respond_to do |format| respond_to do |format|
format.html do
recaptcha_check_with_fallback { render :edit }
end
format.json do format.json do
if @issue.valid? render_issue_json
render json: serializer.represent(@issue)
else
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
end
end end
end end
...@@ -271,6 +279,14 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -271,6 +279,14 @@ class Projects::IssuesController < Projects::ApplicationController
return render_404 unless @project.feature_available?(:issues, current_user) return render_404 unless @project.feature_available?(:issues, current_user)
end end
def render_issue_json
if @issue.valid?
render json: serializer.represent(@issue)
else
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
end
end
def issue_params def issue_params
params.require(:issue).permit(*issue_params_attributes) params.require(:issue).permit(*issue_params_attributes)
end end
......
...@@ -97,9 +97,11 @@ module DropdownsHelper ...@@ -97,9 +97,11 @@ module DropdownsHelper
end end
end end
def dropdown_footer(&block) def dropdown_footer(add_content_class: false, &block)
content_tag(:div, class: "dropdown-footer") do content_tag(:div, class: "dropdown-footer") do
if block if add_content_class
content_tag(:div, capture(&block), class: "dropdown-footer-content")
else
capture(&block) capture(&block)
end end
end end
......
...@@ -207,12 +207,10 @@ module IssuablesHelper ...@@ -207,12 +207,10 @@ module IssuablesHelper
endpoint: project_issue_path(@project, issuable), endpoint: project_issue_path(@project, issuable),
canUpdate: can?(current_user, :update_issue, issuable), canUpdate: can?(current_user, :update_issue, issuable),
canDestroy: can?(current_user, :destroy_issue, issuable), canDestroy: can?(current_user, :destroy_issue, issuable),
canMove: current_user ? issuable.can_move?(current_user) : false,
issuableRef: issuable.to_reference, issuableRef: issuable.to_reference,
isConfidential: issuable.confidential, isConfidential: issuable.confidential,
markdownPreviewPath: preview_markdown_path(@project), markdownPreviewPath: preview_markdown_path(@project),
markdownDocsPath: help_page_path('user/markdown'), markdownDocsPath: help_page_path('user/markdown'),
projectsAutocompletePath: autocomplete_projects_path(project_id: @project.id),
issuableTemplates: issuable_templates(issuable), issuableTemplates: issuable_templates(issuable),
projectPath: ref_project.path, projectPath: ref_project.path,
projectNamespace: ref_project.namespace.full_path, projectNamespace: ref_project.namespace.full_path,
...@@ -354,6 +352,8 @@ module IssuablesHelper ...@@ -354,6 +352,8 @@ module IssuablesHelper
def issuable_sidebar_options(issuable, can_edit_issuable) def issuable_sidebar_options(issuable, can_edit_issuable)
{ {
endpoint: "#{issuable_json_path(issuable)}?basic=true", endpoint: "#{issuable_json_path(issuable)}?basic=true",
moveIssueEndpoint: move_namespace_project_issue_path(namespace_id: issuable.project.namespace.to_param, project_id: issuable.project, id: issuable),
projectsAutocompleteEndpoint: autocomplete_projects_path(project_id: @project.id),
editable: can_edit_issuable, editable: can_edit_issuable,
currentUser: current_user.as_json(only: [:username, :id, :name], methods: :avatar_url), currentUser: current_user.as_json(only: [:username, :id, :name], methods: :avatar_url),
rootPath: root_path, rootPath: root_path,
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
Due date Due date
- if can?(current_user, :admin_issue, @project) - if can?(current_user, :admin_issue, @project)
= icon("spinner spin", class: "block-loading") = icon("spinner spin", class: "block-loading")
= link_to "Edit", "#", class: "edit-link pull-right" = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value .value
.value-content .value-content
%span.no-value{ "v-if" => "!issue.dueDate" } %span.no-value{ "v-if" => "!issue.dueDate" }
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
Labels Labels
- if can?(current_user, :admin_issue, @project) - if can?(current_user, :admin_issue, @project)
= icon("spinner spin", class: "block-loading") = icon("spinner spin", class: "block-loading")
= link_to "Edit", "#", class: "edit-link pull-right" = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value.issuable-show-labels .value.issuable-show-labels
%span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" } %span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" }
None None
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
Milestone Milestone
- if can?(current_user, :admin_issue, @project) - if can?(current_user, :admin_issue, @project)
= icon("spinner spin", class: "block-loading") = icon("spinner spin", class: "block-loading")
= link_to "Edit", "#", class: "edit-link pull-right" = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right"
.value .value
%span.no-value{ "v-if" => "!issue.milestone" } %span.no-value{ "v-if" => "!issue.milestone" }
None None
......
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M9 6H2a2 2 0 1 0 0 4h7v2.586a1 1 0 0 0 1.707.707l4.586-4.586a1 1 0 0 0 0-1.414l-4.586-4.586A1 1 0 0 0 9 3.414V6z"/></svg>
...@@ -29,18 +29,6 @@ ...@@ -29,18 +29,6 @@
= render 'shared/issuable/form/metadata', issuable: issuable, form: form = render 'shared/issuable/form/metadata', issuable: issuable, form: form
- if issuable.can_move?(current_user)
%hr
.form-group
= label_tag :move_to_project_id, 'Move', class: 'control-label'
.col-sm-10
.issuable-form-select-holder
= hidden_field_tag :move_to_project_id, nil, class: 'js-move-dropdown', data: { placeholder: 'Select project', projects_url: autocomplete_projects_path(project_id: @project.id), page_size: MoveToProjectFinder::PAGE_SIZE }
&nbsp;
%span{ data: { toggle: 'tooltip', 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.' }
= icon('question-circle')
= render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form = render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form
= render 'shared/issuable/form/merge_params', issuable: issuable = render 'shared/issuable/form/merge_params', issuable: issuable
......
...@@ -34,7 +34,7 @@ ...@@ -34,7 +34,7 @@
Milestone Milestone
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable - if can_edit_issuable
= link_to 'Edit', '#', class: 'edit-link pull-right' = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed .value.hide-collapsed
- if issuable.milestone - if issuable.milestone
= link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_remaining_days(issuable.milestone), data: { container: "body", html: 1 } = link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_remaining_days(issuable.milestone), data: { container: "body", html: 1 }
...@@ -60,7 +60,7 @@ ...@@ -60,7 +60,7 @@
Due date Due date
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
= link_to 'Edit', '#', class: 'edit-link pull-right' = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed .value.hide-collapsed
%span.value-content %span.value-content
- if issuable.due_date - if issuable.due_date
...@@ -95,7 +95,7 @@ ...@@ -95,7 +95,7 @@
Labels Labels
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable - if can_edit_issuable
= link_to 'Edit', '#', class: 'edit-link pull-right' = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.issuable-show-labels.hide-collapsed{ class: ("has-labels" if selected_labels.any?) } .value.issuable-show-labels.hide-collapsed{ class: ("has-labels" if selected_labels.any?) }
- if selected_labels.any? - if selected_labels.any?
- selected_labels.each do |label| - selected_labels.each do |label|
...@@ -141,5 +141,22 @@ ...@@ -141,5 +141,22 @@
%cite{ title: project_ref } %cite{ title: project_ref }
= project_ref = project_ref
= clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left") = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left")
- if current_user && issuable.can_move?(current_user)
.block.js-sidebar-move-issue-block
.sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body' }, title: 'Move issue' }
= custom_icon('icon_arrow_right')
.dropdown.sidebar-move-issue-dropdown.hide-collapsed
%button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button',
data: { toggle: 'dropdown' } }
Move issue
.dropdown-menu.dropdown-menu-selectable
= dropdown_title('Move issue')
= dropdown_filter('Search project', search_id: 'sidebar-move-issue-dropdown-search')
= dropdown_content
= dropdown_loading
= dropdown_footer add_content_class: true do
%button.btn.btn-new.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ disabled: true }
Move
= icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon')
%script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable, can_edit_issuable).to_json.html_safe %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable, can_edit_issuable).to_json.html_safe
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
Assignee Assignee
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable - if can_edit_issuable
= link_to 'Edit', '#', class: 'edit-link pull-right' = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
- if !signed_in - if !signed_in
%a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" } %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" }
= sidebar_gutter_toggle_icon = sidebar_gutter_toggle_icon
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
Assignee Assignee
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable - if can_edit_issuable
= link_to 'Edit', '#', class: 'edit-link pull-right' = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed .value.hide-collapsed
- if assignees.any? - if assignees.any?
- assignees.each do |assignee| - assignees.each do |assignee|
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
Assignee Assignee
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable - if can_edit_issuable
= link_to 'Edit', '#', class: 'edit-link pull-right' = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed .value.hide-collapsed
- if merge_request.assignee - if merge_request.assignee
= link_to_member(@project, merge_request.assignee, size: 32, extra_class: 'bold') do = link_to_member(@project, merge_request.assignee, size: 32, extra_class: 'bold') do
......
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
.title .title
Start date Start date
- if @project && can?(current_user, :admin_milestone, @project) - if @project && can?(current_user, :admin_milestone, @project)
= link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'edit-link pull-right' = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value .value
%span.value-content %span.value-content
- if milestone.start_date - if milestone.start_date
...@@ -51,7 +51,7 @@ ...@@ -51,7 +51,7 @@
.title.hide-collapsed .title.hide-collapsed
Due date Due date
- if @project && can?(current_user, :admin_milestone, @project) - if @project && can?(current_user, :admin_milestone, @project)
= link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'edit-link pull-right' = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link pull-right'
.value.hide-collapsed .value.hide-collapsed
%span.value-content %span.value-content
- if milestone.due_date - if milestone.due_date
......
---
title: Move "Move issue" controls to right-sidebar
merge_request:
author:
type: changed
...@@ -303,6 +303,7 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -303,6 +303,7 @@ constraints(ProjectUrlConstrainer.new) do
member do member do
post :toggle_subscription post :toggle_subscription
post :mark_as_spam post :mark_as_spam
post :move
get :referenced_merge_requests get :referenced_merge_requests
get :related_branches get :related_branches
get :can_create_branch get :can_create_branch
......
...@@ -86,6 +86,10 @@ Read through the [documentation on creating issues](create_new_issue.md). ...@@ -86,6 +86,10 @@ Read through the [documentation on creating issues](create_new_issue.md).
Learn distinct ways to [close issues](closing_issues.md) in GitLab. Learn distinct ways to [close issues](closing_issues.md) in GitLab.
## Moving issues
Read through the [documentation on moving issues](moving_issues.md).
## Create a merge request from an issue ## Create a merge request from an issue
Learn more about it on the [GitLab Issues Functionalities documentation](issues_functionalities.md#18-new-merge-request). Learn more about it on the [GitLab Issues Functionalities documentation](issues_functionalities.md#18-new-merge-request).
......
# Moving Issues
Please read through the [GitLab Issue Documentation](index.md) for an overview on GitLab Issues.
Moving an issue will close it and duplicate it on the specified project.
There will also be a system note added to both issues indicating where it came from or went to.
You can move an issue with the "Move issue" button at the bottom of the right-sidebar when viewing the issue.
![move issue - button](img/sidebar_move_issue.png)
...@@ -241,13 +241,10 @@ describe AutocompleteController do ...@@ -241,13 +241,10 @@ describe AutocompleteController do
it 'returns projects' do it 'returns projects' do
expect(json_response).to be_kind_of(Array) expect(json_response).to be_kind_of(Array)
expect(json_response.size).to eq(2) expect(json_response.size).to eq(1)
expect(json_response.first['id']).to eq(0)
expect(json_response.first['name_with_namespace']).to eq 'No project'
expect(json_response.last['id']).to eq authorized_project.id expect(json_response.first['id']).to eq authorized_project.id
expect(json_response.last['name_with_namespace']).to eq authorized_project.name_with_namespace expect(json_response.first['name_with_namespace']).to eq authorized_project.name_with_namespace
end end
end end
end end
...@@ -265,10 +262,10 @@ describe AutocompleteController do ...@@ -265,10 +262,10 @@ describe AutocompleteController do
it 'returns projects' do it 'returns projects' do
expect(json_response).to be_kind_of(Array) expect(json_response).to be_kind_of(Array)
expect(json_response.size).to eq(2) expect(json_response.size).to eq(1)
expect(json_response.last['id']).to eq authorized_search_project.id expect(json_response.first['id']).to eq authorized_search_project.id
expect(json_response.last['name_with_namespace']).to eq authorized_search_project.name_with_namespace expect(json_response.first['name_with_namespace']).to eq authorized_search_project.name_with_namespace
end end
end end
end end
...@@ -292,7 +289,7 @@ describe AutocompleteController do ...@@ -292,7 +289,7 @@ describe AutocompleteController do
it 'returns projects' do it 'returns projects' do
expect(json_response).to be_kind_of(Array) expect(json_response).to be_kind_of(Array)
expect(json_response.size).to eq 3 # Of a total of 4 expect(json_response.size).to eq 2 # Of a total of 3
end end
end end
end end
...@@ -312,9 +309,9 @@ describe AutocompleteController do ...@@ -312,9 +309,9 @@ describe AutocompleteController do
get(:projects, project_id: project.id, offset_id: authorized_project.id) get(:projects, project_id: project.id, offset_id: authorized_project.id)
end end
it 'returns "No project"' do it 'returns projects' do
expect(json_response.detect { |item| item['id'] == 0 }).to be_nil # 'No project' is not there expect(json_response).to be_kind_of(Array)
expect(json_response.detect { |item| item['id'] == authorized_project.id }).to be_nil # Offset project is not there either expect(json_response.size).to eq 2 # Of a total of 3
end end
end end
end end
...@@ -331,10 +328,9 @@ describe AutocompleteController do ...@@ -331,10 +328,9 @@ describe AutocompleteController do
get(:projects, project_id: project.id) get(:projects, project_id: project.id)
end end
it 'returns a single "No project"' do it 'returns no projects' do
expect(json_response).to be_kind_of(Array) expect(json_response).to be_kind_of(Array)
expect(json_response.size).to eq(1) # 'No project' expect(json_response.size).to eq(0)
expect(json_response.first['id']).to eq 0
end end
end end
end end
......
...@@ -15,11 +15,11 @@ feature 'issue move to another project' do ...@@ -15,11 +15,11 @@ feature 'issue move to another project' do
background do background do
old_project.team << [user, :guest] old_project.team << [user, :guest]
edit_issue(issue) visit issue_path(issue)
end end
scenario 'moving issue to another project not allowed' do scenario 'moving issue to another project not allowed' do
expect(page).to have_no_selector('#move_to_project_id') expect(page).to have_no_selector('.js-sidebar-move-issue-block')
end end
end end
...@@ -34,12 +34,14 @@ feature 'issue move to another project' do ...@@ -34,12 +34,14 @@ feature 'issue move to another project' do
old_project.team << [user, :reporter] old_project.team << [user, :reporter]
new_project.team << [user, :reporter] new_project.team << [user, :reporter]
edit_issue(issue) visit issue_path(issue)
end end
scenario 'moving issue to another project', js: true do scenario 'moving issue to another project', js: true do
find('#issuable-move', visible: false).set(new_project.id) find('.js-move-issue').trigger('click')
click_button('Save changes') wait_for_requests
all('.js-move-issue-dropdown-item')[0].click
find('.js-move-issue-confirmation-button').click
expect(page).to have_content("Text with #{cross_reference}#{mr.to_reference}") expect(page).to have_content("Text with #{cross_reference}#{mr.to_reference}")
expect(page).to have_content("moved from #{cross_reference}#{issue.to_reference}") expect(page).to have_content("moved from #{cross_reference}#{issue.to_reference}")
...@@ -50,13 +52,12 @@ feature 'issue move to another project' do ...@@ -50,13 +52,12 @@ feature 'issue move to another project' do
scenario 'searching project dropdown', js: true do scenario 'searching project dropdown', js: true do
new_project_search.team << [user, :reporter] new_project_search.team << [user, :reporter]
page.within '.detail-page-description' do find('.js-move-issue').trigger('click')
first('.select2-choice').click wait_for_requests
end
fill_in('s2id_autogen1_search', with: new_project_search.name) page.within '.js-sidebar-move-issue-block' do
fill_in('sidebar-move-issue-dropdown-search', with: new_project_search.name)
page.within '.select2-drop' do
expect(page).to have_content(new_project_search.name) expect(page).to have_content(new_project_search.name)
expect(page).not_to have_content(new_project.name) expect(page).not_to have_content(new_project.name)
end end
...@@ -68,10 +69,10 @@ feature 'issue move to another project' do ...@@ -68,10 +69,10 @@ feature 'issue move to another project' do
background { another_project.team << [user, :guest] } background { another_project.team << [user, :guest] }
scenario 'browsing projects in projects select' do scenario 'browsing projects in projects select' do
click_link 'Move to a different project' find('.js-move-issue').trigger('click')
wait_for_requests
page.within '.select2-results' do page.within '.js-sidebar-move-issue-block' do
expect(page).to have_content 'No project'
expect(page).to have_content new_project.name_with_namespace expect(page).to have_content new_project.name_with_namespace
end end
end end
...@@ -89,11 +90,6 @@ feature 'issue move to another project' do ...@@ -89,11 +90,6 @@ feature 'issue move to another project' do
end end
end end
def edit_issue(issue)
visit issue_path(issue)
page.within('.issuable-actions') { first(:link, 'Edit').click }
end
def issue_path(issue) def issue_path(issue)
project_issue_path(issue.project, issue) project_issue_path(issue.project, issue)
end end
......
...@@ -34,7 +34,6 @@ describe('Issuable output', () => { ...@@ -34,7 +34,6 @@ describe('Issuable output', () => {
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',
initialTitleHtml: '', initialTitleHtml: '',
...@@ -43,7 +42,6 @@ describe('Issuable output', () => { ...@@ -43,7 +42,6 @@ describe('Issuable output', () => {
initialDescriptionText: '', initialDescriptionText: '',
markdownPreviewPath: '/', markdownPreviewPath: '/',
markdownDocsPath: '/', markdownDocsPath: '/',
projectsAutocompletePath: '/',
isConfidential: false, isConfidential: false,
projectNamespace: '/', projectNamespace: '/',
projectPath: '/', projectPath: '/',
...@@ -226,7 +224,7 @@ describe('Issuable output', () => { ...@@ -226,7 +224,7 @@ describe('Issuable output', () => {
}); });
}); });
it('redirects if issue is moved', (done) => { it('redirects if returned web_url has changed', (done) => {
spyOn(gl.utils, 'visitUrl'); spyOn(gl.utils, 'visitUrl');
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => {
resolve({ resolve({
...@@ -250,23 +248,6 @@ describe('Issuable output', () => { ...@@ -250,23 +248,6 @@ describe('Issuable output', () => {
}); });
}); });
it('does not update issuable if project move confirm is false', (done) => {
spyOn(window, 'confirm').and.returnValue(false);
spyOn(vm.service, 'updateIssuable');
vm.store.formState.move_to_project_id = 1;
vm.updateIssuable();
setTimeout(() => {
expect(
vm.service.updateIssuable,
).not.toHaveBeenCalled();
done();
});
});
it('closes form on error', (done) => { it('closes form on error', (done) => {
spyOn(window, 'Flash').and.callThrough(); spyOn(window, 'Flash').and.callThrough();
spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve, reject) => { spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve, reject) => {
......
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,
projectsAutocompletePath: '/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);
});
});
...@@ -12,7 +12,6 @@ describe('Inline edit form component', () => { ...@@ -12,7 +12,6 @@ describe('Inline edit form component', () => {
vm = new Component({ vm = new Component({
propsData: { propsData: {
canDestroy: true, canDestroy: true,
canMove: true,
formState: { formState: {
title: 'b', title: 'b',
description: 'a', description: 'a',
...@@ -20,7 +19,6 @@ describe('Inline edit form component', () => { ...@@ -20,7 +19,6 @@ describe('Inline edit form component', () => {
}, },
markdownPreviewPath: '/', markdownPreviewPath: '/',
markdownDocsPath: '/', markdownDocsPath: '/',
projectsAutocompletePath: '/',
projectPath: '/', projectPath: '/',
projectNamespace: '/', projectNamespace: '/',
}, },
......
...@@ -66,17 +66,57 @@ const sidebarMockData = { ...@@ -66,17 +66,57 @@ const sidebarMockData = {
}, },
labels: [], labels: [],
}, },
'/autocomplete/projects?project_id=15': [
{
'id': 0,
'name_with_namespace': 'No project',
}, {
'id': 20,
'name_with_namespace': 'foo / bar',
},
],
}, },
'PUT': { 'PUT': {
'/gitlab-org/gitlab-shell/issues/5.json': { '/gitlab-org/gitlab-shell/issues/5.json': {
data: {}, data: {},
}, },
}, },
'POST': {
'/gitlab-org/gitlab-shell/issues/5/move': {
id: 123,
iid: 5,
author_id: 1,
description: 'some description',
lock_version: 5,
milestone_id: null,
state: 'opened',
title: 'some title',
updated_by_id: 1,
created_at: '2017-06-27T19:54:42.437Z',
updated_at: '2017-08-18T03:39:49.222Z',
deleted_at: null,
time_estimate: 0,
total_time_spent: 0,
human_time_estimate: null,
human_total_time_spent: null,
branch_name: null,
confidential: false,
assignees: [],
due_date: null,
moved_to_id: null,
project_id: 7,
milestone: null,
labels: [],
web_url: '/root/some-project/issues/5',
},
},
}; };
export default { export default {
mediator: { mediator: {
endpoint: '/gitlab-org/gitlab-shell/issues/5.json', endpoint: '/gitlab-org/gitlab-shell/issues/5.json',
moveIssueEndpoint: '/gitlab-org/gitlab-shell/issues/5/move',
projectsAutocompleteEndpoint: '/autocomplete/projects?project_id=15',
editable: true, editable: true,
currentUser: { currentUser: {
id: 1, id: 1,
...@@ -85,6 +125,7 @@ export default { ...@@ -85,6 +125,7 @@ export default {
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
}, },
rootPath: '/', rootPath: '/',
fullPath: '/gitlab-org/gitlab-shell',
}, },
time: { time: {
time_estimate: 3600, time_estimate: 3600,
......
...@@ -30,7 +30,7 @@ describe('Sidebar mediator', () => { ...@@ -30,7 +30,7 @@ describe('Sidebar mediator', () => {
expect(resp.status).toEqual(200); expect(resp.status).toEqual(200);
done(); done();
}) })
.catch(() => {}); .catch(done.fail);
}); });
it('fetches the data', () => { it('fetches the data', () => {
...@@ -38,4 +38,42 @@ describe('Sidebar mediator', () => { ...@@ -38,4 +38,42 @@ describe('Sidebar mediator', () => {
this.mediator.fetch(); this.mediator.fetch();
expect(this.mediator.service.get).toHaveBeenCalled(); expect(this.mediator.service.get).toHaveBeenCalled();
}); });
it('sets moveToProjectId', () => {
const projectId = 7;
spyOn(this.mediator.store, 'setMoveToProjectId').and.callThrough();
this.mediator.setMoveToProjectId(projectId);
expect(this.mediator.store.setMoveToProjectId).toHaveBeenCalledWith(projectId);
});
it('fetches autocomplete projects', (done) => {
const searchTerm = 'foo';
spyOn(this.mediator.service, 'getProjectsAutocomplete').and.callThrough();
spyOn(this.mediator.store, 'setAutocompleteProjects').and.callThrough();
this.mediator.fetchAutocompleteProjects(searchTerm)
.then(() => {
expect(this.mediator.service.getProjectsAutocomplete).toHaveBeenCalledWith(searchTerm);
expect(this.mediator.store.setAutocompleteProjects).toHaveBeenCalled();
done();
})
.catch(done.fail);
});
it('moves issue', (done) => {
const moveToProjectId = 7;
this.mediator.store.setMoveToProjectId(moveToProjectId);
spyOn(this.mediator.service, 'moveIssue').and.callThrough();
spyOn(gl.utils, 'visitUrl');
this.mediator.moveIssue()
.then(() => {
expect(this.mediator.service.moveIssue).toHaveBeenCalledWith(moveToProjectId);
expect(gl.utils.visitUrl).toHaveBeenCalledWith('/root/some-project/issues/5');
done();
})
.catch(done.fail);
});
}); });
import Vue from 'vue';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarStore from '~/sidebar/stores/sidebar_store';
import SidebarService from '~/sidebar/services/sidebar_service';
import SidebarMoveIssue from '~/sidebar/lib/sidebar_move_issue';
import Mock from './mock_data';
describe('SidebarMoveIssue', () => {
beforeEach(() => {
Vue.http.interceptors.push(Mock.sidebarMockInterceptor);
this.mediator = new SidebarMediator(Mock.mediator);
this.$content = $(`
<div class="dropdown">
<div class="js-toggle"></div>
<div class="dropdown-content"></div>
<div class="js-confirm-button"></div>
</div>
`);
this.$toggleButton = this.$content.find('.js-toggle');
this.$confirmButton = this.$content.find('.js-confirm-button');
this.sidebarMoveIssue = new SidebarMoveIssue(
this.mediator,
this.$toggleButton,
this.$confirmButton,
);
this.sidebarMoveIssue.init();
});
afterEach(() => {
SidebarService.singleton = null;
SidebarStore.singleton = null;
SidebarMediator.singleton = null;
this.sidebarMoveIssue.destroy();
Vue.http.interceptors = _.without(Vue.http.interceptors, Mock.sidebarMockInterceptor);
});
describe('init', () => {
it('should initialize the dropdown and listeners', () => {
spyOn(this.sidebarMoveIssue, 'initDropdown');
spyOn(this.sidebarMoveIssue, 'addEventListeners');
this.sidebarMoveIssue.init();
expect(this.sidebarMoveIssue.initDropdown).toHaveBeenCalled();
expect(this.sidebarMoveIssue.addEventListeners).toHaveBeenCalled();
});
});
describe('destroy', () => {
it('should remove the listeners', () => {
spyOn(this.sidebarMoveIssue, 'removeEventListeners');
this.sidebarMoveIssue.destroy();
expect(this.sidebarMoveIssue.removeEventListeners).toHaveBeenCalled();
});
});
describe('initDropdown', () => {
it('should initialize the gl_dropdown', () => {
spyOn($.fn, 'glDropdown');
this.sidebarMoveIssue.initDropdown();
expect($.fn.glDropdown).toHaveBeenCalled();
});
});
describe('onConfirmClicked', () => {
it('should move the issue with valid project ID', () => {
spyOn(this.mediator, 'moveIssue').and.returnValue(Promise.resolve());
this.mediator.setMoveToProjectId(7);
this.sidebarMoveIssue.onConfirmClicked();
expect(this.mediator.moveIssue).toHaveBeenCalled();
expect(this.$confirmButton.attr('disabled')).toBe('disabled');
expect(this.$confirmButton.hasClass('is-loading')).toBe(true);
});
it('should remove loading state from confirm button on failure', (done) => {
spyOn(window, 'Flash');
spyOn(this.mediator, 'moveIssue').and.returnValue(Promise.reject());
this.mediator.setMoveToProjectId(7);
this.sidebarMoveIssue.onConfirmClicked();
expect(this.mediator.moveIssue).toHaveBeenCalled();
// Wait for the move issue request to fail
setTimeout(() => {
expect(window.Flash).toHaveBeenCalled();
expect(this.$confirmButton.attr('disabled')).toBe(undefined);
expect(this.$confirmButton.hasClass('is-loading')).toBe(false);
done();
});
});
it('should not move the issue with id=0', () => {
spyOn(this.mediator, 'moveIssue');
this.mediator.setMoveToProjectId(0);
this.sidebarMoveIssue.onConfirmClicked();
expect(this.mediator.moveIssue).not.toHaveBeenCalled();
});
});
it('should set moveToProjectId on dropdown item "No project" click', (done) => {
spyOn(this.mediator, 'setMoveToProjectId');
// Open the dropdown
this.$toggleButton.dropdown('toggle');
// Wait for the autocomplete request to finish
setTimeout(() => {
this.$content.find('.js-move-issue-dropdown-item').eq(0).trigger('click');
expect(this.mediator.setMoveToProjectId).toHaveBeenCalledWith(0);
expect(this.$confirmButton.attr('disabled')).toBe('disabled');
done();
}, 0);
});
it('should set moveToProjectId on dropdown item click', (done) => {
spyOn(this.mediator, 'setMoveToProjectId');
// Open the dropdown
this.$toggleButton.dropdown('toggle');
// Wait for the autocomplete request to finish
setTimeout(() => {
this.$content.find('.js-move-issue-dropdown-item').eq(1).trigger('click');
expect(this.mediator.setMoveToProjectId).toHaveBeenCalledWith(20);
expect(this.$confirmButton.attr('disabled')).toBe(undefined);
done();
}, 0);
});
});
...@@ -5,7 +5,11 @@ import Mock from './mock_data'; ...@@ -5,7 +5,11 @@ import Mock from './mock_data';
describe('Sidebar service', () => { describe('Sidebar service', () => {
beforeEach(() => { beforeEach(() => {
Vue.http.interceptors.push(Mock.sidebarMockInterceptor); Vue.http.interceptors.push(Mock.sidebarMockInterceptor);
this.service = new SidebarService('/gitlab-org/gitlab-shell/issues/5.json'); this.service = new SidebarService({
endpoint: '/gitlab-org/gitlab-shell/issues/5.json',
moveIssueEndpoint: '/gitlab-org/gitlab-shell/issues/5/move',
projectsAutocompleteEndpoint: '/autocomplete/projects?project_id=15',
});
}); });
afterEach(() => { afterEach(() => {
...@@ -19,7 +23,7 @@ describe('Sidebar service', () => { ...@@ -19,7 +23,7 @@ describe('Sidebar service', () => {
expect(resp).toBeDefined(); expect(resp).toBeDefined();
done(); done();
}) })
.catch(() => {}); .catch(done.fail);
}); });
it('updates the data', (done) => { it('updates the data', (done) => {
...@@ -28,6 +32,24 @@ describe('Sidebar service', () => { ...@@ -28,6 +32,24 @@ describe('Sidebar service', () => {
expect(resp).toBeDefined(); expect(resp).toBeDefined();
done(); done();
}) })
.catch(() => {}); .catch(done.fail);
});
it('gets projects for autocomplete', (done) => {
this.service.getProjectsAutocomplete()
.then((resp) => {
expect(resp).toBeDefined();
done();
})
.catch(done.fail);
});
it('moves the issue to another project', (done) => {
this.service.moveIssue(123)
.then((resp) => {
expect(resp).toBeDefined();
done();
})
.catch(done.fail);
}); });
}); });
...@@ -82,4 +82,18 @@ describe('Sidebar store', () => { ...@@ -82,4 +82,18 @@ describe('Sidebar store', () => {
expect(this.store.humanTimeEstimate).toEqual(Mock.time.human_time_estimate); expect(this.store.humanTimeEstimate).toEqual(Mock.time.human_time_estimate);
expect(this.store.humanTotalTimeSpent).toEqual(Mock.time.human_total_time_spent); expect(this.store.humanTotalTimeSpent).toEqual(Mock.time.human_total_time_spent);
}); });
it('set autocomplete projects', () => {
const projects = [{ id: 0 }];
this.store.setAutocompleteProjects(projects);
expect(this.store.autocompleteProjects).toEqual(projects);
});
it('set move to project ID', () => {
const projectId = 7;
this.store.setMoveToProjectId(projectId);
expect(this.store.moveToProjectId).toEqual(projectId);
});
}); });
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