Skip to content
Snippets Groups Projects
Commit 5bc32b65 authored by Vitaliy @blackst0ne Klachkov's avatar Vitaliy @blackst0ne Klachkov
Browse files

Add an ability to use a custom branch name on creation from issues

parent acae8ddb
No related branches found
No related tags found
No related merge requests found
Showing
with 831 additions and 271 deletions
Loading
Loading
@@ -2,6 +2,7 @@
import Flash from './flash';
import DropLab from './droplab/drop_lab';
import ISetter from './droplab/plugins/input_setter';
import { __, sprintf } from './locale';
 
// Todo: Remove this when fixing issue in input_setter plugin
const InputSetter = Object.assign({}, ISetter);
Loading
Loading
@@ -12,28 +13,49 @@ const CREATE_BRANCH = 'create-branch';
export default class CreateMergeRequestDropdown {
constructor(wrapperEl) {
this.wrapperEl = wrapperEl;
this.availableButton = this.wrapperEl.querySelector('.available');
this.branchInput = this.wrapperEl.querySelector('.js-branch-name');
this.branchMessage = this.wrapperEl.querySelector('.js-branch-message');
this.createMergeRequestButton = this.wrapperEl.querySelector('.js-create-merge-request');
this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle');
this.createTargetButton = this.wrapperEl.querySelector('.js-create-target');
this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu');
this.availableButton = this.wrapperEl.querySelector('.available');
this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle');
this.refInput = this.wrapperEl.querySelector('.js-ref');
this.refMessage = this.wrapperEl.querySelector('.js-ref-message');
this.unavailableButton = this.wrapperEl.querySelector('.unavailable');
this.unavailableButtonArrow = this.unavailableButton.querySelector('.fa');
this.unavailableButtonText = this.unavailableButton.querySelector('.text');
 
this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
this.branchCreated = false;
this.branchIsValid = true;
this.canCreatePath = this.wrapperEl.dataset.canCreatePath;
this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
this.createMrPath = this.wrapperEl.dataset.createMrPath;
this.droplabInitialized = false;
this.isCreatingBranch = false;
this.isCreatingMergeRequest = false;
this.isGettingRef = false;
this.mergeRequestCreated = false;
this.isCreatingBranch = false;
this.branchCreated = false;
this.refDebounce = _.debounce((value, target) => this.getRef(value, target), 500);
this.refIsValid = true;
this.refsPath = this.wrapperEl.dataset.refsPath;
this.suggestedRef = this.refInput.value;
 
this.init();
}
// These regexps are used to replace
// a backend generated new branch name and its source (ref)
// with user's inputs.
this.regexps = {
branch: {
createBranchPath: new RegExp('(branch_name=)(.+?)(?=&issue)'),
createMrPath: new RegExp('(branch_name=)(.+?)(?=&ref)'),
},
ref: {
createBranchPath: new RegExp('(ref=)(.+?)$'),
createMrPath: new RegExp('(ref=)(.+?)$'),
},
};
 
init() {
this.checkAbilityToCreateBranch();
this.init();
}
 
available() {
Loading
Loading
@@ -41,41 +63,13 @@ export default class CreateMergeRequestDropdown {
this.unavailableButton.classList.add('hide');
}
 
unavailable() {
this.availableButton.classList.add('hide');
this.unavailableButton.classList.remove('hide');
}
enable() {
this.createMergeRequestButton.classList.remove('disabled');
this.createMergeRequestButton.removeAttribute('disabled');
this.dropdownToggle.classList.remove('disabled');
this.dropdownToggle.removeAttribute('disabled');
}
disable() {
this.createMergeRequestButton.classList.add('disabled');
this.createMergeRequestButton.setAttribute('disabled', 'disabled');
this.dropdownToggle.classList.add('disabled');
this.dropdownToggle.setAttribute('disabled', 'disabled');
}
hide() {
this.wrapperEl.classList.add('hide');
}
setUnavailableButtonState(isLoading = true) {
if (isLoading) {
this.unavailableButtonArrow.classList.add('fa-spinner', 'fa-spin');
this.unavailableButtonArrow.classList.remove('fa-exclamation-triangle');
this.unavailableButtonText.textContent = 'Checking branch availability…';
} else {
this.unavailableButtonArrow.classList.remove('fa-spinner', 'fa-spin');
this.unavailableButtonArrow.classList.add('fa-exclamation-triangle');
this.unavailableButtonText.textContent = 'New branch unavailable';
}
bindEvents() {
this.createMergeRequestButton.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this));
this.createTargetButton.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this));
this.branchInput.addEventListener('keyup', this.onChangeInput.bind(this));
this.dropdownToggle.addEventListener('click', this.onClickSetFocusOnBranchNameInput.bind(this));
this.refInput.addEventListener('keyup', this.onChangeInput.bind(this));
this.refInput.addEventListener('keydown', CreateMergeRequestDropdown.processTab.bind(this));
}
 
checkAbilityToCreateBranch() {
Loading
Loading
@@ -107,49 +101,233 @@ export default class CreateMergeRequestDropdown {
});
}
 
initDroplab() {
this.droplab = new DropLab();
createBranch() {
return $.ajax({
method: 'POST',
dataType: 'json',
url: this.createBranchPath,
beforeSend: () => (this.isCreatingBranch = true),
})
.done((data) => {
this.branchCreated = true;
window.location.href = data.url;
})
.fail(() => new Flash('Failed to create a branch for this issue. Please try again.'));
}
 
this.droplab.init(this.dropdownToggle, this.dropdownList, [InputSetter],
this.getDroplabConfig());
createMergeRequest() {
return $.ajax({
method: 'POST',
dataType: 'json',
url: this.createMrPath,
beforeSend: () => (this.isCreatingMergeRequest = true),
})
.done((data) => {
this.mergeRequestCreated = true;
window.location.href = data.url;
})
.fail(() => new Flash('Failed to create Merge Request. Please try again.'));
}
disable() {
this.disableCreateAction();
this.dropdownToggle.classList.add('disabled');
this.dropdownToggle.setAttribute('disabled', 'disabled');
}
disableCreateAction() {
this.createMergeRequestButton.classList.add('disabled');
this.createMergeRequestButton.setAttribute('disabled', 'disabled');
this.createTargetButton.classList.add('disabled');
this.createTargetButton.setAttribute('disabled', 'disabled');
}
enable() {
this.createMergeRequestButton.classList.remove('disabled');
this.createMergeRequestButton.removeAttribute('disabled');
this.createTargetButton.classList.remove('disabled');
this.createTargetButton.removeAttribute('disabled');
this.dropdownToggle.classList.remove('disabled');
this.dropdownToggle.removeAttribute('disabled');
}
static findByValue(objects, ref, returnFirstMatch = false) {
if (!objects || !objects.length) return false;
if (objects.indexOf(ref) > -1) return ref;
if (returnFirstMatch) return objects.find(item => new RegExp(`^${ref}`).test(item));
return false;
}
 
getDroplabConfig() {
return {
InputSetter: [{
input: this.createMergeRequestButton,
valueAttribute: 'data-value',
inputAttribute: 'data-action',
}, {
input: this.createMergeRequestButton,
valueAttribute: 'data-text',
}],
addActiveClassToDropdownButton: true,
InputSetter: [
{
input: this.createMergeRequestButton,
valueAttribute: 'data-value',
inputAttribute: 'data-action',
},
{
input: this.createMergeRequestButton,
valueAttribute: 'data-text',
},
{
input: this.createTargetButton,
valueAttribute: 'data-value',
inputAttribute: 'data-action',
},
{
input: this.createTargetButton,
valueAttribute: 'data-text',
},
],
};
}
 
bindEvents() {
this.createMergeRequestButton
.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this));
static getInputSelectedText(input) {
const start = input.selectionStart;
const end = input.selectionEnd;
return input.value.substr(start, end - start);
}
getRef(ref, target = 'all') {
if (!ref) return false;
return $.ajax({
method: 'GET',
dataType: 'json',
url: this.refsPath + ref,
beforeSend: () => {
this.isGettingRef = true;
},
})
.always(() => {
this.isGettingRef = false;
})
.done((data) => {
const branches = data[Object.keys(data)[0]];
const tags = data[Object.keys(data)[1]];
let result;
if (target === 'branch') {
result = CreateMergeRequestDropdown.findByValue(branches, ref);
} else {
result = CreateMergeRequestDropdown.findByValue(branches, ref, true) ||
CreateMergeRequestDropdown.findByValue(tags, ref, true);
this.suggestedRef = result;
}
return this.updateInputState(target, ref, result);
})
.fail(() => {
this.unavailable();
this.disable();
new Flash('Failed to get ref.');
return false;
});
}
getTargetData(target) {
return {
input: this[`${target}Input`],
message: this[`${target}Message`],
};
}
hide() {
this.wrapperEl.classList.add('hide');
}
init() {
this.checkAbilityToCreateBranch();
}
initDroplab() {
this.droplab = new DropLab();
this.droplab.init(
this.dropdownToggle,
this.dropdownList,
[InputSetter],
this.getDroplabConfig(),
);
}
inputsAreValid() {
return this.branchIsValid && this.refIsValid;
}
 
isBusy() {
return this.isCreatingMergeRequest ||
this.mergeRequestCreated ||
this.isCreatingBranch ||
this.branchCreated;
this.branchCreated ||
this.isGettingRef;
}
 
onClickCreateMergeRequestButton(e) {
onChangeInput(event) {
let target;
let value;
if (event.srcElement === this.branchInput) {
target = 'branch';
value = this.branchInput.value;
} else if (event.srcElement === this.refInput) {
target = 'ref';
value = event.srcElement.value.slice(0, event.srcElement.selectionStart) +
event.srcElement.value.slice(event.srcElement.selectionEnd);
} else {
return false;
}
if (this.isGettingRef) return false;
// `ENTER` key submits the data.
if (event.keyCode === 13 && this.inputsAreValid()) {
event.preventDefault();
return this.createMergeRequestButton.click();
}
// If the input is empty, use the original value generated by the backend.
if (!value) {
this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
this.createMrPath = this.wrapperEl.dataset.createMrPath;
if (target === 'branch') {
this.branchIsValid = true;
} else {
this.refIsValid = true;
}
this.enable();
this.showAvailableMessage(target);
return true;
}
this.showCheckingMessage(target);
this.refDebounce(value, target);
return true;
}
onClickCreateMergeRequestButton(event) {
let xhr = null;
e.preventDefault();
event.preventDefault();
 
if (this.isBusy()) {
return;
}
 
if (e.target.dataset.action === CREATE_MERGE_REQUEST) {
if (event.target.dataset.action === CREATE_MERGE_REQUEST) {
xhr = this.createMergeRequest();
} else if (e.target.dataset.action === CREATE_BRANCH) {
} else if (event.target.dataset.action === CREATE_BRANCH) {
xhr = this.createBranch();
}
 
Loading
Loading
@@ -163,31 +341,131 @@ export default class CreateMergeRequestDropdown {
this.disable();
}
 
createMergeRequest() {
return $.ajax({
method: 'POST',
dataType: 'json',
url: this.createMrPath,
beforeSend: () => (this.isCreatingMergeRequest = true),
})
.done((data) => {
this.mergeRequestCreated = true;
window.location.href = data.url;
})
.fail(() => new Flash('Failed to create Merge Request. Please try again.'));
onClickSetFocusOnBranchNameInput() {
this.branchInput.focus();
}
 
createBranch() {
return $.ajax({
method: 'POST',
dataType: 'json',
url: this.createBranchPath,
beforeSend: () => (this.isCreatingBranch = true),
})
.done((data) => {
this.branchCreated = true;
window.location.href = data.url;
})
.fail(() => new Flash('Failed to create a branch for this issue. Please try again.'));
// `TAB` autocompletes the source.
static processTab(event) {
if (event.keyCode !== 9 || this.isGettingRef) return;
const selectedText = CreateMergeRequestDropdown.getInputSelectedText(this.refInput);
// if nothing selected, we don't need to autocomplete anything. Do the default TAB action.
// If a user manually selected text, don't autocomplete anything. Do the default TAB action.
if (!selectedText || this.refInput.dataset.value === this.suggestedRef) return;
event.preventDefault();
window.getSelection().removeAllRanges();
}
removeMessage(target) {
const { input, message } = this.getTargetData(target);
const inputClasses = ['gl-field-error-outline', 'gl-field-success-outline'];
const messageClasses = ['gl-field-hint', 'gl-field-error-message', 'gl-field-success-message'];
inputClasses.forEach(cssClass => input.classList.remove(cssClass));
messageClasses.forEach(cssClass => message.classList.remove(cssClass));
message.style.display = 'none';
}
setUnavailableButtonState(isLoading = true) {
if (isLoading) {
this.unavailableButtonArrow.classList.add('fa-spin');
this.unavailableButtonArrow.classList.add('fa-spinner');
this.unavailableButtonArrow.classList.remove('fa-exclamation-triangle');
this.unavailableButtonText.textContent = __('Checking branch availability...');
} else {
this.unavailableButtonArrow.classList.remove('fa-spin');
this.unavailableButtonArrow.classList.remove('fa-spinner');
this.unavailableButtonArrow.classList.add('fa-exclamation-triangle');
this.unavailableButtonText.textContent = __('New branch unavailable');
}
}
showAvailableMessage(target) {
const { input, message } = this.getTargetData(target);
const text = target === 'branch' ? __('Branch name') : __('Source');
this.removeMessage(target);
input.classList.add('gl-field-success-outline');
message.classList.add('gl-field-success-message');
message.textContent = sprintf(__('%{text} is available'), { text });
message.style.display = 'inline-block';
}
showCheckingMessage(target) {
const { message } = this.getTargetData(target);
const text = target === 'branch' ? __('branch name') : __('source');
this.removeMessage(target);
message.classList.add('gl-field-hint');
message.textContent = sprintf(__('Checking %{text} availability…'), { text });
message.style.display = 'inline-block';
}
showNotAvailableMessage(target) {
const { input, message } = this.getTargetData(target);
const text = target === 'branch' ? __('Branch is already taken') : __('Source is not available');
this.removeMessage(target);
input.classList.add('gl-field-error-outline');
message.classList.add('gl-field-error-message');
message.textContent = text;
message.style.display = 'inline-block';
}
unavailable() {
this.availableButton.classList.add('hide');
this.unavailableButton.classList.remove('hide');
}
updateInputState(target, ref, result) {
// target - 'branch' or 'ref' - which the input field we are searching a ref for.
// ref - string - what a user typed.
// result - string - what has been found on backend.
const pathReplacement = `$1${ref}`;
// If a found branch equals exact the same text a user typed,
// that means a new branch cannot be created as it already exists.
if (ref === result) {
if (target === 'branch') {
this.branchIsValid = false;
this.showNotAvailableMessage('branch');
} else {
this.refIsValid = true;
this.refInput.dataset.value = ref;
this.showAvailableMessage('ref');
this.createBranchPath = this.createBranchPath.replace(this.regexps.ref.createBranchPath,
pathReplacement);
this.createMrPath = this.createMrPath.replace(this.regexps.ref.createMrPath,
pathReplacement);
}
} else if (target === 'branch') {
this.branchIsValid = true;
this.showAvailableMessage('branch');
this.createBranchPath = this.createBranchPath.replace(this.regexps.branch.createBranchPath,
pathReplacement);
this.createMrPath = this.createMrPath.replace(this.regexps.branch.createMrPath,
pathReplacement);
} else {
this.refIsValid = false;
this.refInput.dataset.value = ref;
this.disableCreateAction();
this.showNotAvailableMessage('ref');
// Show ref hint.
if (result) {
this.refInput.value = result;
this.refInput.setSelectionRange(ref.length, result.length);
}
}
if (this.inputsAreValid()) {
this.enable();
} else {
this.disableCreateAction();
}
}
}
Loading
Loading
@@ -3,6 +3,7 @@ const DATA_DROPDOWN = 'data-dropdown';
const SELECTED_CLASS = 'droplab-item-selected';
const ACTIVE_CLASS = 'droplab-item-active';
const IGNORE_CLASS = 'droplab-item-ignore';
const IGNORE_HIDING_CLASS = 'droplab-item-ignore-hiding';
// Matches `{{anything}}` and `{{ everything }}`.
const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g;
 
Loading
Loading
@@ -13,4 +14,5 @@ export {
ACTIVE_CLASS,
TEMPLATE_REGEX,
IGNORE_CLASS,
IGNORE_HIDING_CLASS,
};
import utils from './utils';
import { SELECTED_CLASS, IGNORE_CLASS } from './constants';
import { SELECTED_CLASS, IGNORE_CLASS, IGNORE_HIDING_CLASS } from './constants';
 
class DropDown {
constructor(list) {
constructor(list, config = {}) {
this.currentIndex = 0;
this.hidden = true;
this.list = typeof list === 'string' ? document.querySelector(list) : list;
this.items = [];
this.eventWrapper = {};
 
if (config.addActiveClassToDropdownButton) {
this.dropdownToggle = this.list.parentNode.querySelector('.js-dropdown-toggle');
}
this.getItems();
this.initTemplateString();
this.addEvents();
Loading
Loading
@@ -42,7 +45,7 @@ class DropDown {
this.addSelectedClass(selected);
 
e.preventDefault();
this.hide();
if (!e.target.classList.contains(IGNORE_HIDING_CLASS)) this.hide();
 
const listEvent = new CustomEvent('click.dl', {
detail: {
Loading
Loading
@@ -67,7 +70,20 @@ class DropDown {
 
addEvents() {
this.eventWrapper.clickEvent = this.clickEvent.bind(this);
this.eventWrapper.closeDropdown = this.closeDropdown.bind(this);
this.list.addEventListener('click', this.eventWrapper.clickEvent);
this.list.addEventListener('keyup', this.eventWrapper.closeDropdown);
}
closeDropdown(event) {
// `ESC` key closes the dropdown.
if (event.keyCode === 27) {
event.preventDefault();
return this.toggle();
}
return true;
}
 
setData(data) {
Loading
Loading
@@ -110,6 +126,8 @@ class DropDown {
this.list.style.display = 'block';
this.currentIndex = 0;
this.hidden = false;
if (this.dropdownToggle) this.dropdownToggle.classList.add('active');
}
 
hide() {
Loading
Loading
@@ -117,6 +135,8 @@ class DropDown {
this.list.style.display = 'none';
this.currentIndex = 0;
this.hidden = true;
if (this.dropdownToggle) this.dropdownToggle.classList.remove('active');
}
 
toggle() {
Loading
Loading
@@ -128,6 +148,7 @@ class DropDown {
destroy() {
this.hide();
this.list.removeEventListener('click', this.eventWrapper.clickEvent);
this.list.removeEventListener('keyup', this.eventWrapper.closeDropdown);
}
 
static setImagesSrc(template) {
Loading
Loading
Loading
Loading
@@ -3,7 +3,7 @@ import DropDown from './drop_down';
class Hook {
constructor(trigger, list, plugins, config) {
this.trigger = trigger;
this.list = new DropDown(list);
this.list = new DropDown(list, config);
this.type = 'Hook';
this.event = 'click';
this.plugins = plugins || [];
Loading
Loading
Loading
Loading
@@ -203,7 +203,24 @@ ul.related-merge-requests > li {
}
 
.create-mr-dropdown-wrap {
@include new-style-dropdown;
.branch-message,
.ref-message {
display: none;
}
.ref::selection {
color: $placeholder-text-color;
}
.dropdown {
.dropdown-menu-toggle {
min-width: 285px;
}
.dropdown-select {
width: 285px;
}
}
 
.btn-group:not(.hide) {
display: flex;
Loading
Loading
@@ -214,15 +231,16 @@ ul.related-merge-requests > li {
flex-shrink: 0;
}
 
.dropdown-menu {
.create-merge-request-dropdown-menu {
width: 300px;
opacity: 1;
visibility: visible;
transform: translateY(0);
display: none;
margin-top: 4px;
}
 
.dropdown-toggle {
.create-merge-request-dropdown-toggle {
.fa-caret-down {
pointer-events: none;
color: inherit;
Loading
Loading
@@ -230,18 +248,50 @@ ul.related-merge-requests > li {
}
}
 
.droplab-item-ignore {
pointer-events: auto;
}
.create-item {
cursor: pointer;
margin: 0 1px;
&:hover,
&:focus {
background-color: $dropdown-item-hover-bg;
color: $gl-text-color;
}
}
li.divider {
margin: 8px 10px;
}
li:not(.divider) {
padding: 8px 9px;
&:last-child {
padding-bottom: 8px;
}
&.droplab-item-selected {
.icon-container {
i {
visibility: visible;
}
}
.description {
display: block;
}
}
&.droplab-item-ignore {
padding-top: 8px;
}
 
.icon-container {
float: left;
padding-left: 6px;
 
i {
visibility: hidden;
Loading
Loading
@@ -249,13 +299,12 @@ ul.related-merge-requests > li {
}
 
.description {
padding-left: 30px;
font-size: 13px;
padding-left: 22px;
}
 
strong {
display: block;
font-weight: $gl-font-weight-bold;
}
input,
span {
margin: 4px 0 0;
}
}
}
Loading
Loading
Loading
Loading
@@ -158,7 +158,8 @@ class Projects::IssuesController < Projects::ApplicationController
end
 
def create_merge_request
result = ::MergeRequests::CreateFromIssueService.new(project, current_user, issue_iid: issue.iid).execute
create_params = params.slice(:branch_name, :ref).merge(issue_iid: issue.iid)
result = ::MergeRequests::CreateFromIssueService.new(project, current_user, create_params).execute
 
if result[:status] == :success
render json: MergeRequestCreateSerializer.new.represent(result[:merge_request])
Loading
Loading
module MergeRequests
class BuildService < MergeRequests::BaseService
def execute
@issue_iid = params.delete(:issue_iid)
self.merge_request = MergeRequest.new(params)
merge_request.compare_commits = []
merge_request.source_project = find_source_project
Loading
Loading
@@ -106,37 +108,53 @@ module MergeRequests
# more than one commit in the MR
#
def assign_title_and_description
if match = source_branch.match(/\A(\d+)-/)
iid = match[1]
end
assign_title_and_description_from_single_commit
assign_title_from_issue
 
commits = compare_commits
if commits && commits.count == 1
commit = commits.first
merge_request.title = commit.title
merge_request.description ||= commit.description.try(:strip)
elsif iid && issue = target_project.get_issue(iid, current_user)
case issue
when Issue
merge_request.title = "Resolve \"#{issue.title}\""
when ExternalIssue
merge_request.title = "Resolve #{issue.title}"
end
merge_request.title ||= source_branch.titleize.humanize
merge_request.title = wip_title if compare_commits.empty?
append_closes_description
end
def append_closes_description
return unless issue_iid
closes_issue = "Closes ##{issue_iid}"
if description.present?
merge_request.description += closes_issue.prepend("\n\n")
else
merge_request.title = source_branch.titleize.humanize
merge_request.description = closes_issue
end
end
 
if iid
closes_issue = "Closes ##{iid}"
def assign_title_and_description_from_single_commit
commits = compare_commits
return unless commits&.count == 1
commit = commits.first
merge_request.title ||= commit.title
merge_request.description ||= commit.description.try(:strip)
end
def assign_title_from_issue
return unless issue
 
if description.present?
merge_request.description += closes_issue.prepend("\n\n")
else
merge_request.description = closes_issue
merge_request.title =
case issue
when Issue then "Resolve \"#{issue.title}\""
when ExternalIssue then "Resolve #{issue.title}"
end
end
end
def issue_iid
@issue_iid ||= source_branch.match(/\A(\d+)-/).try(:[], 1)
end
 
merge_request.title = wip_title if commits.empty?
def issue
@issue ||= target_project.get_issue(issue_iid, current_user)
end
end
end
module MergeRequests
class CreateFromIssueService < MergeRequests::CreateService
def initialize(project, user, params)
# branch - the name of new branch
# ref - the source of new branch.
@branch_name = params[:branch_name]
@issue_iid = params[:issue_iid]
@ref = params[:ref]
super(project, user)
end
def execute
return error('Invalid issue iid') unless issue_iid.present? && issue.present?
return error('Invalid issue iid') unless @issue_iid.present? && issue.present?
 
params[:label_ids] = issue.label_ids if issue.label_ids.any?
 
Loading
Loading
@@ -21,20 +32,16 @@ module MergeRequests
 
private
 
def issue_iid
@isssue_iid ||= params.delete(:issue_iid)
end
def issue
@issue ||= IssuesFinder.new(current_user, project_id: project.id).find_by(iid: issue_iid)
@issue ||= IssuesFinder.new(current_user, project_id: project.id).find_by(iid: @issue_iid)
end
 
def branch_name
@branch_name ||= issue.to_branch_name
@branch ||= @branch_name || issue.to_branch_name
end
 
def ref
project.default_branch || 'master'
@ref || project.default_branch || 'master'
end
 
def merge_request
Loading
Loading
@@ -43,6 +50,7 @@ module MergeRequests
 
def merge_request_params
{
issue_iid: @issue_iid,
source_project_id: project.id,
source_branch: branch_name,
target_project_id: project.id,
Loading
Loading
- can_create_merge_request = can?(current_user, :create_merge_request, @project)
- data_action = can_create_merge_request ? 'create-mr' : 'create-branch'
- value = can_create_merge_request ? 'Create a merge request' : 'Create a branch'
- value = can_create_merge_request ? 'Create merge request' : 'Create branch'
 
- if can?(current_user, :push_code, @project)
.create-mr-dropdown-wrap{ data: { can_create_path: can_create_branch_project_issue_path(@project, @issue), create_mr_path: create_merge_request_project_issue_path(@project, @issue), create_branch_path: project_branches_path(@project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid) } }
- can_create_path = can_create_branch_project_issue_path(@project, @issue)
- create_mr_path = create_merge_request_project_issue_path(@project, @issue, branch_name: @issue.to_branch_name, ref: @project.default_branch)
- create_branch_path = project_branches_path(@project, branch_name: @issue.to_branch_name, ref: @project.default_branch, issue_iid: @issue.iid)
- refs_path = refs_namespace_project_path(@project.namespace, @project, search: '')
.create-mr-dropdown-wrap{ data: { can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path } }
.btn-group.unavailable
%button.btn.btn-grouped{ type: 'button', disabled: 'disabled' }
= icon('spinner', class: 'fa-spin')
%span.text
Checking branch availability…
.btn-group.available.hide
%input.btn.js-create-merge-request.btn-inverted.btn-success{ type: 'button', value: value, data: { action: data_action } }
%button.btn.btn-inverted.dropdown-toggle.btn-inverted.btn-success.js-dropdown-toggle{ type: 'button', data: { 'dropdown-trigger' => '#create-merge-request-dropdown' } }
%button.btn.js-create-merge-request.btn-default{ type: 'button', data: { action: data_action } }
= value
%button.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-default.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' } } }
= icon('caret-down')
%ul#create-merge-request-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } }
%ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-align-right.gl-show-field-errors{ data: { dropdown: true } }
- if can_create_merge_request
%li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', 'text' => 'Create a merge request' } }
.menu-item
.icon-container
= icon('check')
.description
%strong Create a merge request
%span
Creates a merge request named after this issue, with source branch created from '#{@project.default_branch}'.
%li.divider.droplab-item-ignore
%li{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', 'text' => 'Create a branch' } }
.menu-item
.icon-container
= icon('check')
.description
%strong Create a branch
%span
Creates a branch named after this issue, from '#{@project.default_branch}'.
%li.create-item.droplab-item-selected.droplab-item-ignore-hiding{ role: 'button', data: { value: 'create-mr', text: 'Create merge request' } }
.menu-item.droplab-item-ignore-hiding
.icon-container.droplab-item-ignore-hiding= icon('check')
.description.droplab-item-ignore-hiding Create merge request and branch
%li.create-item.droplab-item-ignore-hiding{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: 'Create branch' } }
.menu-item.droplab-item-ignore-hiding
.icon-container.droplab-item-ignore-hiding= icon('check')
.description.droplab-item-ignore-hiding Create branch
%li.divider
%li.droplab-item-ignore
Branch name
%input.js-branch-name.form-control.droplab-item-ignore{ type: 'text', placeholder: "#{@issue.to_branch_name}", value: "#{@issue.to_branch_name}" }
%span.js-branch-message.branch-message.droplab-item-ignore
%li.droplab-item-ignore
Source (branch or tag)
%input.js-ref.ref.form-control.droplab-item-ignore{ type: 'text', placeholder: "#{@project.default_branch}", value: "#{@project.default_branch}", data: { value: "#{@project.default_branch}" } }
%span.js-ref-message.ref-message.droplab-item-ignore
%li.droplab-item-ignore
%button.btn.btn-success.js-create-target.droplab-item-ignore{ type: 'button', data: { action: 'create-mr' } }
Create merge request
---
title: Add an ability to use a custom branch name on creation from issues
merge_request: 13884
author: Vitaliy @blackst0ne Klachkov
type: added
require 'rails_helper'
feature 'Create Branch/Merge Request Dropdown on issue page', :feature, :js do
let(:user) { create(:user) }
let!(:project) { create(:project, :repository) }
let(:issue) { create(:issue, project: project, title: 'Cherry-Coloured Funk') }
context 'for team members' do
before do
project.team << [user, :developer]
sign_in(user)
end
it 'allows creating a merge request from the issue page' do
visit project_issue_path(project, issue)
perform_enqueued_jobs do
select_dropdown_option('create-mr')
expect(page).to have_content('WIP: Resolve "Cherry-Coloured Funk"')
expect(current_path).to eq(project_merge_request_path(project, MergeRequest.first))
wait_for_requests
end
visit project_issue_path(project, issue)
expect(page).to have_content("created branch 1-cherry-coloured-funk")
expect(page).to have_content("mentioned in merge request !1")
end
it 'allows creating a branch from the issue page' do
visit project_issue_path(project, issue)
select_dropdown_option('create-branch')
wait_for_requests
expect(page).to have_selector('.dropdown-toggle-text ', text: '1-cherry-coloured-funk')
expect(current_path).to eq project_tree_path(project, '1-cherry-coloured-funk')
end
context "when there is a referenced merge request" do
let!(:note) do
create(:note, :on_issue, :system, project: project, noteable: issue,
note: "mentioned in #{referenced_mr.to_reference}")
end
let(:referenced_mr) do
create(:merge_request, :simple, source_project: project, target_project: project,
description: "Fixes #{issue.to_reference}", author: user)
end
before do
referenced_mr.cache_merge_request_closes_issues!(user)
visit project_issue_path(project, issue)
end
it 'disables the create branch button' do
expect(page).to have_css('.create-mr-dropdown-wrap .unavailable:not(.hide)')
expect(page).to have_css('.create-mr-dropdown-wrap .available.hide', visible: false)
expect(page).to have_content /1 Related Merge Request/
end
end
context 'when merge requests are disabled' do
before do
project.project_feature.update(merge_requests_access_level: 0)
visit project_issue_path(project, issue)
end
it 'shows only create branch button' do
expect(page).not_to have_button('Create a merge request')
expect(page).to have_button('Create a branch')
end
end
context 'when issue is confidential' do
it 'disables the create branch button' do
issue = create(:issue, :confidential, project: project)
visit project_issue_path(project, issue)
expect(page).not_to have_css('.create-mr-dropdown-wrap')
end
end
end
context 'for visitors' do
before do
visit project_issue_path(project, issue)
end
it 'shows no buttons' do
expect(page).not_to have_selector('.create-mr-dropdown-wrap')
end
end
def select_dropdown_option(option)
find('.create-mr-dropdown-wrap .dropdown-toggle').click
find("li[data-value='#{option}']").click
find('.js-create-merge-request').click
end
end
require 'rails_helper'
describe 'User creates branch and merge request on issue page', :js do
let(:user) { create(:user) }
let!(:project) { create(:project, :repository) }
let(:issue) { create(:issue, project: project, title: 'Cherry-Coloured Funk') }
context 'when signed out' do
before do
visit project_issue_path(project, issue)
end
it "doesn't show 'Create merge request' button" do
expect(page).not_to have_selector('.create-mr-dropdown-wrap')
end
end
context 'when signed in' do
before do
project.add_developer(user)
sign_in(user)
end
context 'when interacting with the dropdown' do
before do
visit project_issue_path(project, issue)
end
# In order to improve tests performance, all UI checks are placed in this test.
it 'shows elements' do
button_create_merge_request = find('.js-create-merge-request')
button_toggle_dropdown = find('.create-mr-dropdown-wrap .dropdown-toggle')
button_toggle_dropdown.click
dropdown = find('.create-merge-request-dropdown-menu')
page.within(dropdown) do
button_create_target = find('.js-create-target')
input_branch_name = find('.js-branch-name')
input_source = find('.js-ref')
li_create_branch = find("li[data-value='create-branch']")
li_create_merge_request = find("li[data-value='create-mr']")
# Test that all elements are presented.
expect(page).to have_content('Create merge request and branch')
expect(page).to have_content('Create branch')
expect(page).to have_content('Branch name')
expect(page).to have_content('Source (branch or tag)')
expect(page).to have_button('Create merge request')
expect(page).to have_selector('.js-branch-name:focus')
test_selection_mark(li_create_branch, li_create_merge_request, button_create_target, button_create_merge_request)
test_branch_name_checking(input_branch_name)
test_source_checking(input_source)
# The button inside dropdown should be disabled if any errors occured.
expect(page).to have_button('Create branch', disabled: true)
end
# The top level button should be disabled if any errors occured.
expect(page).to have_button('Create branch', disabled: true)
end
context 'when branch name is auto-generated' do
it 'creates a merge request' do
perform_enqueued_jobs do
select_dropdown_option('create-mr')
expect(page).to have_content('WIP: Resolve "Cherry-Coloured Funk"')
expect(current_path).to eq(project_merge_request_path(project, MergeRequest.first))
wait_for_requests
end
visit project_issue_path(project, issue)
expect(page).to have_content('created branch 1-cherry-coloured-funk')
expect(page).to have_content('mentioned in merge request !1')
end
it 'creates a branch' do
select_dropdown_option('create-branch')
wait_for_requests
expect(page).to have_selector('.dropdown-toggle-text ', text: '1-cherry-coloured-funk')
expect(current_path).to eq project_tree_path(project, '1-cherry-coloured-funk')
end
end
context 'when branch name is custom' do
let(:branch_name) { 'custom-branch-name' }
it 'creates a merge request' do
perform_enqueued_jobs do
select_dropdown_option('create-mr', branch_name)
expect(page).to have_content('WIP: Resolve "Cherry-Coloured Funk"')
expect(page).to have_content('Request to merge custom-branch-name into')
expect(current_path).to eq(project_merge_request_path(project, MergeRequest.first))
wait_for_requests
end
visit project_issue_path(project, issue)
expect(page).to have_content('created branch custom-branch-name')
expect(page).to have_content('mentioned in merge request !1')
end
it 'creates a branch' do
select_dropdown_option('create-branch', branch_name)
wait_for_requests
expect(page).to have_selector('.dropdown-toggle-text ', text: branch_name)
expect(current_path).to eq project_tree_path(project, branch_name)
end
end
end
context "when there is a referenced merge request" do
let!(:note) do
create(:note, :on_issue, :system, project: project, noteable: issue,
note: "mentioned in #{referenced_mr.to_reference}")
end
let(:referenced_mr) do
create(:merge_request, :simple, source_project: project, target_project: project,
description: "Fixes #{issue.to_reference}", author: user)
end
before do
referenced_mr.cache_merge_request_closes_issues!(user)
visit project_issue_path(project, issue)
end
it 'disables the create branch button' do
expect(page).to have_css('.create-mr-dropdown-wrap .unavailable:not(.hide)')
expect(page).to have_css('.create-mr-dropdown-wrap .available.hide', visible: false)
expect(page).to have_content /1 Related Merge Request/
end
end
context 'when merge requests are disabled' do
before do
project.project_feature.update(merge_requests_access_level: 0)
visit project_issue_path(project, issue)
end
it 'shows only create branch button' do
expect(page).not_to have_button('Create merge request')
expect(page).to have_button('Create branch')
end
end
context 'when issue is confidential' do
let(:issue) { create(:issue, :confidential, project: project) }
it 'disables the create branch button' do
visit project_issue_path(project, issue)
expect(page).not_to have_css('.create-mr-dropdown-wrap')
end
end
end
private
def select_dropdown_option(option, branch_name = nil)
find('.create-mr-dropdown-wrap .dropdown-toggle').click
find("li[data-value='#{option}']").click
if branch_name
find('.js-branch-name').set(branch_name)
# Javascript debounces AJAX calls.
# So we have to wait until AJAX requests are started.
# Details are in app/assets/javascripts/create_merge_request_dropdown.js
# this.refDebounce = _.debounce(...)
sleep 0.5
wait_for_requests
end
find('.js-create-merge-request').click
end
def test_branch_name_checking(input_branch_name)
expect(input_branch_name.value).to eq(issue.to_branch_name)
input_branch_name.set('new-branch-name')
branch_name_message = find('.js-branch-message')
expect(branch_name_message).to have_text('Checking branch name availability…')
wait_for_requests
expect(branch_name_message).to have_text('Branch name is available')
input_branch_name.set(project.default_branch)
expect(branch_name_message).to have_text('Checking branch name availability…')
wait_for_requests
expect(branch_name_message).to have_text('Branch is already taken')
end
def test_selection_mark(li_create_branch, li_create_merge_request, button_create_target, button_create_merge_request)
page.within(li_create_merge_request) do
expect(page).to have_css('i.fa.fa-check')
expect(button_create_target).to have_text('Create merge request')
expect(button_create_merge_request).to have_text('Create merge request')
end
li_create_branch.click
page.within(li_create_branch) do
expect(page).to have_css('i.fa.fa-check')
expect(button_create_target).to have_text('Create branch')
expect(button_create_merge_request).to have_text('Create branch')
end
end
def test_source_checking(input_source)
expect(input_source.value).to eq(project.default_branch)
input_source.set('mas') # Intentionally entered first 3 letters of `master` to check autocomplete feature later.
source_message = find('.js-ref-message')
expect(source_message).to have_text('Checking source availability…')
wait_for_requests
expect(source_message).to have_text('Source is not available')
# JavaScript gets refs started with `mas` (entered above) and places the first match.
# User sees `mas` in black color (the part he entered) and the `ter` in gray color (a hint).
# Since hinting is implemented via text selection and rspec/capybara doesn't have matchers for it,
# we just checking the whole source name.
expect(input_source.value).to eq(project.default_branch)
end
end
Loading
Loading
@@ -279,7 +279,12 @@ describe('DropDown', function () {
describe('addEvents', function () {
beforeEach(function () {
this.list = { addEventListener: () => {} };
this.dropdown = { list: this.list, clickEvent: () => {}, eventWrapper: {} };
this.dropdown = {
list: this.list,
clickEvent: () => {},
closeDropdown: () => {},
eventWrapper: {},
};
 
spyOn(this.list, 'addEventListener');
 
Loading
Loading
@@ -288,6 +293,7 @@ describe('DropDown', function () {
 
it('should call .addEventListener', function () {
expect(this.list.addEventListener).toHaveBeenCalledWith('click', jasmine.any(Function));
expect(this.list.addEventListener).toHaveBeenCalledWith('keyup', jasmine.any(Function));
});
});
 
Loading
Loading
Loading
Loading
@@ -24,7 +24,7 @@ describe('Hook', function () {
});
 
it('should call DropDown constructor', function () {
expect(dropdownSrc.default).toHaveBeenCalledWith(this.list);
expect(dropdownSrc.default).toHaveBeenCalledWith(this.list, this.config);
});
 
it('should set .type', function () {
Loading
Loading
Loading
Loading
@@ -6,8 +6,10 @@ describe MergeRequests::CreateFromIssueService do
let(:label_ids) { create_pair(:label, project: project).map(&:id) }
let(:milestone_id) { create(:milestone, project: project).id }
let(:issue) { create(:issue, project: project, milestone_id: milestone_id) }
let(:custom_source_branch) { 'custom-source-branch' }
 
subject(:service) { described_class.new(project, user, issue_iid: issue.iid) }
subject(:service_with_custom_source_branch) { described_class.new(project, user, issue_iid: issue.iid, branch_name: custom_source_branch) }
 
before do
project.add_developer(user)
Loading
Loading
@@ -17,8 +19,8 @@ describe MergeRequests::CreateFromIssueService do
it 'returns an error with invalid issue iid' do
result = described_class.new(project, user, issue_iid: -1).execute
 
expect(result[:status]).to eq :error
expect(result[:message]).to eq 'Invalid issue iid'
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Invalid issue iid')
end
 
it 'delegates issue search to IssuesFinder' do
Loading
Loading
@@ -53,6 +55,12 @@ describe MergeRequests::CreateFromIssueService do
expect(project.repository.branch_exists?(issue.to_branch_name)).to be_truthy
end
 
it 'creates a branch using passed name' do
service_with_custom_source_branch.execute
expect(project.repository.branch_exists?(custom_source_branch)).to be_truthy
end
it 'creates a system note' do
expect(SystemNoteService).to receive(:new_issue_branch).with(issue, project, user, issue.to_branch_name)
 
Loading
Loading
@@ -72,19 +80,25 @@ describe MergeRequests::CreateFromIssueService do
it 'sets the merge request author to current user' do
result = service.execute
 
expect(result[:merge_request].author).to eq user
expect(result[:merge_request].author).to eq(user)
end
 
it 'sets the merge request source branch to the new issue branch' do
result = service.execute
 
expect(result[:merge_request].source_branch).to eq issue.to_branch_name
expect(result[:merge_request].source_branch).to eq(issue.to_branch_name)
end
it 'sets the merge request source branch to the passed branch name' do
result = service_with_custom_source_branch.execute
expect(result[:merge_request].source_branch).to eq(custom_source_branch)
end
 
it 'sets the merge request target branch to the project default branch' do
result = service.execute
 
expect(result[:merge_request].target_branch).to eq project.default_branch
expect(result[:merge_request].target_branch).to eq(project.default_branch)
end
end
end
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment