Commit 6832e6bd authored by Lin Jen-Shin's avatar Lin Jen-Shin

Merge remote-tracking branch 'ee/master' into ce-to-ee-2017-09-01

* ee/master:
  Move "Move issue" to sidebar - EE merge edition
  Update CHANGELOG.md for 9.5.3
  Update CHANGELOG-EE.md for 9.5.3-ee
  Fix merges not working when project is not licensed for squash
  Extend early adopters feature set
  Fix unsetting credentials data for pull mirrors
  Add icon for billing
  Remove private token.
  Docs: update repo mirroring
  Fixes bug where mirror trigger builds is nil at project create.
parents 3d7721da fea47966
Please view this file on the master branch, on stable branches it's out of date. Please view this file on the master branch, on stable branches it's out of date.
## 9.5.3 (2017-09-03)
- [FIXED] Check if table exists before loading the current license. !2783
- [FIXED] Extend early adopters feature set.
## 9.5.2 (2017-08-28) ## 9.5.2 (2017-08-28)
- [FIXED] Fix LDAP backwards-compatibility when using "method" or when "verify_certificates" is not defined. !2690 - [FIXED] Fix LDAP backwards-compatibility when using "method" or when "verify_certificates" is not defined. !2690
......
...@@ -2,6 +2,24 @@ ...@@ -2,6 +2,24 @@
documentation](doc/development/changelog.md) for instructions on adding your own documentation](doc/development/changelog.md) for instructions on adding your own
entry. entry.
## 9.5.3 (2017-09-03)
- [SECURITY] Filter additional secrets from Rails logs.
- [FIXED] Make username update fail if the namespace update fails. !13642
- [FIXED] Fix failure when issue is authored by a deleted user. !13807
- [FIXED] Reverts changes made to signin_enabled. !13956
- [FIXED] Fix Merge when pipeline succeeds button dropdown caret icon horizontal alignment.
- [FIXED] Fixed diff changes bar buttons from showing/hiding whilst scrolling.
- [FIXED] Fix events error importing GitLab projects.
- [FIXED] Fix pipeline trigger via API fails with 500 Internal Server Error in 9.5.
- [FIXED] Fixed fly-out nav flashing in & out.
- [FIXED] Remove closing external issues by reference error.
- [FIXED] Re-allow appearances.description_html to be NULL.
- [CHANGED] Update and fix resolvable note icons for easier recognition.
- [OTHER] Eager load head pipeline projects for MRs index.
- [OTHER] Instrument MergeRequest#fetch_ref.
- [OTHER] Instrument MergeRequest#ensure_ref_fetched.
## 9.5.2 (2017-08-28) ## 9.5.2 (2017-08-28)
- [FIXED] Fix signing in using LDAP when attribute mapping uses simple strings instead of arrays. - [FIXED] Fix signing in using LDAP when attribute mapping uses simple strings instead of arrays.
......
...@@ -186,7 +186,6 @@ const Api = { ...@@ -186,7 +186,6 @@ const Api = {
return Api.wrapAjaxCall({ return Api.wrapAjaxCall({
url, url,
data: Object.assign({ data: Object.assign({
private_token: gon.api_token,
search: query, search: query,
per_page: 20, per_page: 20,
active: true, active: true,
......
...@@ -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) {
...@@ -713,7 +713,7 @@ GitLabDropdown = (function() { ...@@ -713,7 +713,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) {
......
...@@ -11,8 +11,6 @@ import ZenMode from './zen_mode'; ...@@ -11,8 +11,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) {
...@@ -28,7 +26,6 @@ import ZenMode from './zen_mode'; ...@@ -28,7 +26,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;
} }
...@@ -36,7 +33,6 @@ import ZenMode from './zen_mode'; ...@@ -36,7 +33,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({
...@@ -58,12 +54,6 @@ import ZenMode from './zen_mode'; ...@@ -58,12 +54,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();
}; };
...@@ -115,48 +105,6 @@ import ZenMode from './zen_mode'; ...@@ -115,48 +105,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,
}, },
projectsAutocompleteUrl: {
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="markdownDocs" :markdown-docs="markdownDocs"
:markdown-preview-url="markdownPreviewUrl" :markdown-preview-url="markdownPreviewUrl"
:project-path="projectPath" :project-path="projectPath"
:project-namespace="projectNamespace" :project-namespace="projectNamespace"
:projects-autocomplete-url="projectsAutocompleteUrl"
/> />
<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,
},
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
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,
}, },
projectsAutocompleteUrl: {
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="markdownDocs" /> :markdown-docs="markdownDocs" />
<confidential-checkbox <confidential-checkbox
:form-state="formState" /> :form-state="formState" />
<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" />
......
...@@ -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', () => {
markdownDocs: this.markdownDocs, markdownDocs: this.markdownDocs,
projectPath: this.projectPath, projectPath: this.projectPath,
projectNamespace: this.projectNamespace, projectNamespace: this.projectNamespace,
projectsAutocompleteUrl: this.projectsAutocompleteUrl,
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;
...@@ -618,6 +618,11 @@ ...@@ -618,6 +618,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;
......
...@@ -45,12 +45,6 @@ class AutocompleteController < ApplicationController ...@@ -45,12 +45,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
......
...@@ -17,7 +17,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -17,7 +17,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]
...@@ -131,25 +131,33 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -131,25 +131,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: IssueSerializer.new.represent(@issue)
else
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
end
end end
end end
...@@ -260,6 +268,14 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -260,6 +268,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: IssueSerializer.new.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
......
...@@ -305,7 +305,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -305,7 +305,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
return :failed return :failed
end end
merge_request_service = MergeRequests::MergeService.new(@project, current_user, merge_params) merge_request_service = ::MergeRequests::MergeService.new(@project, current_user, merge_params)
unless merge_request_service.hooks_validation_pass?(@merge_request) unless merge_request_service.hooks_validation_pass?(@merge_request)
return :hook_validation_error return :hook_validation_error
...@@ -313,7 +313,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -313,7 +313,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
return :sha_mismatch if params[:sha] != @merge_request.diff_head_sha return :sha_mismatch if params[:sha] != @merge_request.diff_head_sha
@merge_request.update(merge_error: nil, squash: merge_params[:squash]) @merge_request.update(merge_error: nil, squash: merge_params.fetch(:squash, false))
if params[:merge_when_pipeline_succeeds].present? if params[:merge_when_pipeline_succeeds].present?
return :failed unless @merge_request.head_pipeline return :failed unless @merge_request.head_pipeline
......
...@@ -106,9 +106,11 @@ module DropdownsHelper ...@@ -106,9 +106,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
......
...@@ -215,12 +215,10 @@ module IssuablesHelper ...@@ -215,12 +215,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,
markdownPreviewUrl: preview_markdown_path(@project), markdownPreviewUrl: preview_markdown_path(@project),
markdownDocs: help_page_path('user/markdown'), markdownDocs: help_page_path('user/markdown'),
projectsAutocompleteUrl: 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,
...@@ -369,6 +367,8 @@ module IssuablesHelper ...@@ -369,6 +367,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,
......
...@@ -130,12 +130,15 @@ class License < ActiveRecord::Base ...@@ -130,12 +130,15 @@ class License < ActiveRecord::Base
# Early adopters should not earn new features as they're # Early adopters should not earn new features as they're
# introduced. # introduced.
EARLY_ADOPTER_FEATURES = [ EARLY_ADOPTER_FEATURES = [
{ ADMIN_AUDIT_LOG_FEATURE => 1 },
{ AUDIT_EVENTS_FEATURE => 1 }, { AUDIT_EVENTS_FEATURE => 1 },
{ AUDITOR_USER_FEATURE => 1 }, { AUDITOR_USER_FEATURE => 1 },
{ BURNDOWN_CHARTS_FEATURE => 1 }, { BURNDOWN_CHARTS_FEATURE => 1 },
{ CONTRIBUTION_ANALYTICS_FEATURE => 1 }, { CONTRIBUTION_ANALYTICS_FEATURE => 1 },
{ CROSS_PROJECT_PIPELINES_FEATURE => 1 }, { CROSS_PROJECT_PIPELINES_FEATURE => 1 },
{ DB_LOAD_BALANCING_FEATURE => 1 },
{ DEPLOY_BOARD_FEATURE => 1 }, { DEPLOY_BOARD_FEATURE => 1 },
{ ELASTIC_SEARCH_FEATURE => 1 },
{ EXPORT_ISSUES_FEATURE => 1 }, { EXPORT_ISSUES_FEATURE => 1 },
{ FAST_FORWARD_MERGE_FEATURE => 1 }, { FAST_FORWARD_MERGE_FEATURE => 1 },
{ FILE_LOCKS_FEATURE => 1 }, { FILE_LOCKS_FEATURE => 1 },
...@@ -146,6 +149,7 @@ class License < ActiveRecord::Base ...@@ -146,6 +149,7 @@ class License < ActiveRecord::Base
{ ISSUE_BOARD_MILESTONE_FEATURE => 1 }, { ISSUE_BOARD_MILESTONE_FEATURE => 1 },
{ ISSUE_WEIGHTS_FEATURE => 1 }, { ISSUE_WEIGHTS_FEATURE => 1 },
{ JENKINS_INTEGRATION_FEATURE => 1 }, { JENKINS_INTEGRATION_FEATURE => 1 },
{ LDAP_EXTRAS_FEATURE => 1 },
{ MERGE_REQUEST_APPROVERS_FEATURE => 1 }, { MERGE_REQUEST_APPROVERS_FEATURE => 1 },
{ MERGE_REQUEST_REBASE_FEATURE => 1 }, { MERGE_REQUEST_REBASE_FEATURE => 1 },
{ MERGE_REQUEST_SQUASH_FEATURE => 1 }, { MERGE_REQUEST_SQUASH_FEATURE => 1 },
...@@ -154,8 +158,11 @@ class License < ActiveRecord::Base ...@@ -154,8 +158,11 @@ class License < ActiveRecord::Base
{ OBJECT_STORAGE_FEATURE => 1 }, { OBJECT_STORAGE_FEATURE => 1 },
{ PROTECTED_REFS_FOR_USERS_FEATURE => 1 }, { PROTECTED_REFS_FOR_USERS_FEATURE => 1 },
{ PUSH_RULES_FEATURE => 1 }, { PUSH_RULES_FEATURE => 1 },
{ RELATED_ISSUES_FEATURE => 1 },
{ REPOSITORY_MIRRORS_FEATURE => 1 }, { REPOSITORY_MIRRORS_FEATURE => 1 },
{ SERVICE_DESK_FEATURE => 1 } { REPOSITORY_SIZE_LIMIT_FEATURE => 1 },
{ SERVICE_DESK_FEATURE => 1 },
{ VARIABLE_ENVIRONMENT_SCOPE_FEATURE => 1 }
].freeze ].freeze
FEATURES_BY_PLAN = { FEATURES_BY_PLAN = {
......
...@@ -20,8 +20,10 @@ ...@@ -20,8 +20,10 @@
Account Account
- if current_application_settings.should_check_namespace_plan? - if current_application_settings.should_check_namespace_plan?
= nav_link(controller: :billings) do = nav_link(controller: :billings) do
= link_to profile_billings_path, title: 'Billing' do = sidebar_link profile_billings_path, title: _('Billing') do
%span .nav-icon-container
= custom_icon('credit_card')
%span.nav-item-name
Billing Billing
- if current_application_settings.user_oauth_applications? - if current_application_settings.user_oauth_applications?
= nav_link(controller: 'oauth/applications') do = nav_link(controller: 'oauth/applications') do
......
...@@ -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 d="M14 5a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1h12zm0 3H2v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V8zM3 2h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3zm6.5 8h3a.5.5 0 1 1 0 1h-3a.5.5 0 1 1 0-1z"/></svg>
<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/approvals', issuable: issuable, form: form = render 'shared/issuable/approvals', issuable: issuable, form: form
= render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form = render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form
......
...@@ -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|
...@@ -168,5 +168,22 @@ ...@@ -168,5 +168,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: Check if table exists before loading the current license.
merge_request: 2783
author:
type: fixed
---
title: Fix unsetting credentials data for pull mirrors
merge_request: 2810
author:
type: fixed
--- ---
title: Fix events error importing GitLab projects title: Fix merges not working when project is not licensed for squash
merge_request: merge_request:
author: author:
type: fixed type: fixed
--- ---
title: Update and fix resolvable note icons for easier recognition title: Move "Move issue" controls to right-sidebar
merge_request: merge_request:
author: author:
type: changed type: changed
---
title: Instrument MergeRequest#ensure_ref_fetched
merge_request:
author:
type: other
---
title: Fix failure when issue is authored by a deleted user
merge_request: 13807
author:
type: fixed
---
title: Fixed: Notifications weren't sending to participating users with a `Custom` notification setting.
merge_request: 13680
author: jneen
type: fixed
---
title: Fixed diff changes bar buttons from showing/hiding whilst scrolling
merge_request:
author:
type: fixed
---
title: Fix pipeline trigger via API fails with 500 Internal Server Error in 9.5
merge_request:
author:
type: fixed
---
title: Make username update fail if the namespace update fails
merge_request: 13642
author:
type: fixed
---
title: Eager load head pipeline projects for MRs index
merge_request:
author:
type: other
---
title: Re-allow appearances.description_html to be NULL
merge_request:
author:
type: fixed
...@@ -339,6 +339,7 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -339,6 +339,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)
...@@ -41,7 +41,7 @@ do. ...@@ -41,7 +41,7 @@ do.
| `/clear_weight` | Clears the issue weight | | `/clear_weight` | Clears the issue weight |
| `/board_move ~column` | Move issue to column on the board | | `/board_move ~column` | Move issue to column on the board |
| `/duplicate #issue` | Closes this issue and marks it as a duplicate of another issue | | `/duplicate #issue` | Closes this issue and marks it as a duplicate of another issue |
| `/move path/to/project` | Moves issue to another project | | `/move path/to/project` | Moves issue to another project |
Note: In GitLab EES every issue can have more than one assignee, so commands `/assign`, `/unassign` and `/reassign` Note: In GitLab EES every issue can have more than one assignee, so commands `/assign`, `/unassign` and `/reassign`
support multiple assignees. support multiple assignees.
...@@ -210,6 +210,23 @@ In case of a diverged branch, you will see an error indicated at the ...@@ -210,6 +210,23 @@ In case of a diverged branch, you will see an error indicated at the
![Diverged branch](repository_mirroring/repository_mirroring_diverged_branch_push.png) ![Diverged branch](repository_mirroring/repository_mirroring_diverged_branch_push.png)
## Setting up a mirror from GitLab to GitHub
To set up a mirror from GitLab to GitHub, you need to follow these steps:
1. Create a [GitHub personal access token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/) with the "public_repo" box checked:
![edit personal access token GitHub](repository_mirroring/repository_mirroring_github_edit_personal_access_token.png)
1. Fill in the "Git repository URL" with the personal access token replacing the password `https://GitHubUsername:GitHubPersonalAccessToken@github.com/group/project.git`:
![push to remote repo](repository_mirroring/repository_mirroring_gitlab_push_to_a_remote_repository.png)
1. Save
1. And either wait or trigger the "Update Now" button:
![update now](repository_mirroring/repository_mirroring_gitlab_push_to_a_remote_repository_update_now.png)
## Forcing an update ## Forcing an update
While mirrors are scheduled to update automatically, you can always force an update (either **push** or While mirrors are scheduled to update automatically, you can always force an update (either **push** or
......
...@@ -5,6 +5,16 @@ module EE ...@@ -5,6 +5,16 @@ module EE
bits: 4096 bits: 4096
}.freeze }.freeze
CREDENTIALS_FIELDS = %i[
auth_method
password
ssh_known_hosts
ssh_known_hosts_verified_at
ssh_known_hosts_verified_by_id
ssh_private_key
user
].freeze
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
...@@ -26,14 +36,22 @@ module EE ...@@ -26,14 +36,22 @@ module EE
project&.import_url&.start_with?('ssh://') project&.import_url&.start_with?('ssh://')
end end
%i[auth_method user password ssh_private_key ssh_known_hosts ssh_known_hosts_verified_at ssh_known_hosts_verified_by_id].each do |name| CREDENTIALS_FIELDS.each do |name|
define_method(name) do define_method(name) do
credentials[name] if credentials.present? credentials[name] if credentials.present?
end end
define_method("#{name}=") do |value| define_method("#{name}=") do |value|
self.credentials ||= {} self.credentials ||= {}
self.credentials[name] = value
# Removal of the password, username, etc, generally causes an update of
# the value to the empty string. Detect and gracefully handle this case.
if value.present?
self.credentials[name] = value
else
self.credentials.delete(name)
nil
end
end end
end end
......
...@@ -10,9 +10,9 @@ module EE ...@@ -10,9 +10,9 @@ module EE
super do |project| super do |project|
if mirror && project.feature_available?(:repository_mirrors) if mirror && project.feature_available?(:repository_mirrors)
project.mirror = mirror project.mirror = mirror unless mirror.nil?
project.mirror_trigger_builds = mirror_trigger_builds unless mirror_trigger_builds.nil?
project.mirror_user_id = mirror_user_id project.mirror_user_id = mirror_user_id
project.mirror_trigger_builds = mirror_trigger_builds
end end
end end
end end
......
...@@ -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
......
...@@ -293,6 +293,13 @@ describe Projects::MergeRequestsController do ...@@ -293,6 +293,13 @@ describe Projects::MergeRequestsController do
expect(merge_request.reload.squash).to be_truthy expect(merge_request.reload.squash).to be_truthy
end end
it 'merges even when squash is unavailable' do
stub_licensed_features(merge_request_squash: false)
merge_with_sha(squash: '1')
expect(merge_request.reload.squash).to be_falsey
end
end end
context 'when squash is passed as 0' do context 'when squash is passed as 0' do
......
...@@ -62,6 +62,34 @@ describe ProjectImportData do ...@@ -62,6 +62,34 @@ describe ProjectImportData do
end end
end end
describe 'credential fields accessors' do
let(:accessors) { %i[auth_method password ssh_known_hosts ssh_known_hosts_verified_at ssh_known_hosts_verified_by_id ssh_private_key user] }
it { expect(described_class::CREDENTIALS_FIELDS).to contain_exactly(*accessors) }
where(:field) { described_class::CREDENTIALS_FIELDS }
with_them do
it 'sets the value in the credentials hash' do
import_data.send("#{field}=", 'foo')
expect(import_data.credentials[field]).to eq('foo')
end
it 'sets a not-present value to nil' do
import_data.send("#{field}=", '')
expect(import_data.credentials[field]).to be_nil
end
it 'returns the data in the credentials hash' do
import_data.credentials[field] = 'foo'
expect(import_data.send(field)).to eq('foo')
end
end
end
describe '#ssh_import?' do describe '#ssh_import?' do
where(:import_url, :expected) do where(:import_url, :expected) do
'ssh://example.com' | true 'ssh://example.com' | true
......
...@@ -33,12 +33,25 @@ describe Projects::CreateService, '#execute' do ...@@ -33,12 +33,25 @@ describe Projects::CreateService, '#execute' do
end end
end end
context 'repository mirror' do context 'without repository mirror' do
before do
stub_licensed_features(repository_mirrors: true)
opts.merge!(import_url: 'http://foo.com')
end
it 'sets the mirror to false' do
project = create_project(user, opts)
expect(project).to be_persisted
expect(project.mirror).to be false
end
end
context 'with repository mirror' do
before do before do
opts.merge!(import_url: 'http://foo.com', opts.merge!(import_url: 'http://foo.com',
mirror: true, mirror: true,
mirror_user_id: user.id, mirror_user_id: user.id)
mirror_trigger_builds: true)
end end
context 'when licensed' do context 'when licensed' do
...@@ -49,10 +62,22 @@ describe Projects::CreateService, '#execute' do ...@@ -49,10 +62,22 @@ describe Projects::CreateService, '#execute' do
it 'sets the correct attributes' do it 'sets the correct attributes' do
project = create_project(user, opts) project = create_project(user, opts)
expect(project).to be_valid expect(project).to be_persisted
expect(project.mirror).to be true expect(project.mirror).to be true
expect(project.mirror_user_id).to eq(user.id) expect(project.mirror_user_id).to eq(user.id)
expect(project.mirror_trigger_builds).to be true end
context 'with mirror trigger builds' do
before do
opts.merge!(mirror_trigger_builds: true)
end
it 'sets the mirror trigger builds' do
project = create_project(user, opts)
expect(project).to be_persisted
expect(project.mirror_trigger_builds).to be true
end
end end
end end
...@@ -64,10 +89,22 @@ describe Projects::CreateService, '#execute' do ...@@ -64,10 +89,22 @@ describe Projects::CreateService, '#execute' do
it 'does not set mirror attributes' do it 'does not set mirror attributes' do
project = create_project(user, opts) project = create_project(user, opts)
expect(project).to be_valid expect(project).to be_persisted
expect(project.mirror).to be false expect(project.mirror).to be false
expect(project.mirror_user_id).to be_nil expect(project.mirror_user_id).to be_nil
expect(project.mirror_trigger_builds).to be false end
context 'with mirror trigger builds' do
before do
opts.merge!(mirror_trigger_builds: true)
end
it 'sets the mirror trigger builds' do
project = create_project(user, opts)
expect(project).to be_persisted
expect(project.mirror_trigger_builds).to be false
end
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: '',
markdownPreviewUrl: '/', markdownPreviewUrl: '/',
markdownDocs: '/', markdownDocs: '/',
projectsAutocompleteUrl: '/',
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,
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);
});
});
...@@ -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', () => {
}, },
markdownPreviewUrl: '/', markdownPreviewUrl: '/',
markdownDocs: '/', markdownDocs: '/',
projectsAutocompleteUrl: '/',
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);
});
}); });
...@@ -519,7 +519,7 @@ describe Issues::UpdateService, :mailer do ...@@ -519,7 +519,7 @@ describe Issues::UpdateService, :mailer do
end end
it 'calls the move service with the proper issue and project' do it 'calls the move service with the proper issue and project' do
move_stub = class_double("Issues::MoveService").as_stubbed_const move_stub = instance_double(Issues::MoveService)
allow(Issues::MoveService).to receive(:new).and_return(move_stub) allow(Issues::MoveService).to receive(:new).and_return(move_stub)
allow(move_stub).to receive(:execute).with(issue, target_project).and_return(issue) allow(move_stub).to receive(:execute).with(issue, target_project).and_return(issue)
......
...@@ -1224,6 +1224,16 @@ describe QuickActions::InterpretService do ...@@ -1224,6 +1224,16 @@ describe QuickActions::InterpretService do
end end
end end
describe 'move issue to another project command' do
let(:content) { '/move test/project' }
it 'includes the project name' do
_, explanations = service.explain(content, issue)
expect(explanations).to eq(["Moves this issue to test/project."])
end
end
# EE-specific tests # EE-specific tests
describe 'weight command' do describe 'weight command' do
......
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