BigW Consortium Gitlab

Commit f3ac2e56 by Sean McGivern

Merge branch '21143-customize-branch-name-when-using-create-branch-in-an-issue' into 'master'

Resolve "Customize branch name when using create branch in an issue" Closes #21143 See merge request gitlab-org/gitlab-ce!13884
parents 673c4ccb 5bc32b65
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import Flash from './flash'; import Flash from './flash';
import DropLab from './droplab/drop_lab'; import DropLab from './droplab/drop_lab';
import ISetter from './droplab/plugins/input_setter'; import ISetter from './droplab/plugins/input_setter';
import { __, sprintf } from './locale';
// Todo: Remove this when fixing issue in input_setter plugin // Todo: Remove this when fixing issue in input_setter plugin
const InputSetter = Object.assign({}, ISetter); const InputSetter = Object.assign({}, ISetter);
...@@ -12,28 +13,49 @@ const CREATE_BRANCH = 'create-branch'; ...@@ -12,28 +13,49 @@ const CREATE_BRANCH = 'create-branch';
export default class CreateMergeRequestDropdown { export default class CreateMergeRequestDropdown {
constructor(wrapperEl) { constructor(wrapperEl) {
this.wrapperEl = 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.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.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.unavailableButton = this.wrapperEl.querySelector('.unavailable');
this.unavailableButtonArrow = this.unavailableButton.querySelector('.fa'); this.unavailableButtonArrow = this.unavailableButton.querySelector('.fa');
this.unavailableButtonText = this.unavailableButton.querySelector('.text'); 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.canCreatePath = this.wrapperEl.dataset.canCreatePath;
this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
this.createMrPath = this.wrapperEl.dataset.createMrPath; this.createMrPath = this.wrapperEl.dataset.createMrPath;
this.droplabInitialized = false; this.droplabInitialized = false;
this.isCreatingBranch = false;
this.isCreatingMergeRequest = false; this.isCreatingMergeRequest = false;
this.isGettingRef = false;
this.mergeRequestCreated = false; this.mergeRequestCreated = false;
this.isCreatingBranch = false; this.refDebounce = _.debounce((value, target) => this.getRef(value, target), 500);
this.branchCreated = false; 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.init();
this.checkAbilityToCreateBranch();
} }
available() { available() {
...@@ -41,41 +63,13 @@ export default class CreateMergeRequestDropdown { ...@@ -41,41 +63,13 @@ export default class CreateMergeRequestDropdown {
this.unavailableButton.classList.add('hide'); this.unavailableButton.classList.add('hide');
} }
unavailable() { bindEvents() {
this.availableButton.classList.add('hide'); this.createMergeRequestButton.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this));
this.unavailableButton.classList.remove('hide'); this.createTargetButton.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this));
} this.branchInput.addEventListener('keyup', this.onChangeInput.bind(this));
this.dropdownToggle.addEventListener('click', this.onClickSetFocusOnBranchNameInput.bind(this));
enable() { this.refInput.addEventListener('keyup', this.onChangeInput.bind(this));
this.createMergeRequestButton.classList.remove('disabled'); this.refInput.addEventListener('keydown', CreateMergeRequestDropdown.processTab.bind(this));
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';
}
} }
checkAbilityToCreateBranch() { checkAbilityToCreateBranch() {
...@@ -107,49 +101,233 @@ export default class CreateMergeRequestDropdown { ...@@ -107,49 +101,233 @@ export default class CreateMergeRequestDropdown {
}); });
} }
initDroplab() { createBranch() {
this.droplab = new DropLab(); 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], createMergeRequest() {
this.getDroplabConfig()); 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() { getDroplabConfig() {
return { return {
InputSetter: [{ addActiveClassToDropdownButton: true,
input: this.createMergeRequestButton, InputSetter: [
valueAttribute: 'data-value', {
inputAttribute: 'data-action', input: this.createMergeRequestButton,
}, { valueAttribute: 'data-value',
input: this.createMergeRequestButton, inputAttribute: 'data-action',
valueAttribute: 'data-text', },
}], {
input: this.createMergeRequestButton,
valueAttribute: 'data-text',
},
{
input: this.createTargetButton,
valueAttribute: 'data-value',
inputAttribute: 'data-action',
},
{
input: this.createTargetButton,
valueAttribute: 'data-text',
},
],
}; };
} }
bindEvents() { static getInputSelectedText(input) {
this.createMergeRequestButton const start = input.selectionStart;
.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this)); 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() { isBusy() {
return this.isCreatingMergeRequest || return this.isCreatingMergeRequest ||
this.mergeRequestCreated || this.mergeRequestCreated ||
this.isCreatingBranch || 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; let xhr = null;
e.preventDefault(); event.preventDefault();
if (this.isBusy()) { if (this.isBusy()) {
return; return;
} }
if (e.target.dataset.action === CREATE_MERGE_REQUEST) { if (event.target.dataset.action === CREATE_MERGE_REQUEST) {
xhr = this.createMergeRequest(); xhr = this.createMergeRequest();
} else if (e.target.dataset.action === CREATE_BRANCH) { } else if (event.target.dataset.action === CREATE_BRANCH) {
xhr = this.createBranch(); xhr = this.createBranch();
} }
...@@ -163,31 +341,131 @@ export default class CreateMergeRequestDropdown { ...@@ -163,31 +341,131 @@ export default class CreateMergeRequestDropdown {
this.disable(); this.disable();
} }
createMergeRequest() { onClickSetFocusOnBranchNameInput() {
return $.ajax({ this.branchInput.focus();
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.'));
} }
createBranch() { // `TAB` autocompletes the source.
return $.ajax({ static processTab(event) {
method: 'POST', if (event.keyCode !== 9 || this.isGettingRef) return;
dataType: 'json',
url: this.createBranchPath, const selectedText = CreateMergeRequestDropdown.getInputSelectedText(this.refInput);
beforeSend: () => (this.isCreatingBranch = true),
}) // if nothing selected, we don't need to autocomplete anything. Do the default TAB action.
.done((data) => { // If a user manually selected text, don't autocomplete anything. Do the default TAB action.
this.branchCreated = true; if (!selectedText || this.refInput.dataset.value === this.suggestedRef) return;
window.location.href = data.url;
}) event.preventDefault();
.fail(() => new Flash('Failed to create a branch for this issue. Please try again.')); 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();
}
} }
} }
...@@ -3,6 +3,7 @@ const DATA_DROPDOWN = 'data-dropdown'; ...@@ -3,6 +3,7 @@ const DATA_DROPDOWN = 'data-dropdown';
const SELECTED_CLASS = 'droplab-item-selected'; const SELECTED_CLASS = 'droplab-item-selected';
const ACTIVE_CLASS = 'droplab-item-active'; const ACTIVE_CLASS = 'droplab-item-active';
const IGNORE_CLASS = 'droplab-item-ignore'; const IGNORE_CLASS = 'droplab-item-ignore';
const IGNORE_HIDING_CLASS = 'droplab-item-ignore-hiding';
// Matches `{{anything}}` and `{{ everything }}`. // Matches `{{anything}}` and `{{ everything }}`.
const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g; const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g;
...@@ -13,4 +14,5 @@ export { ...@@ -13,4 +14,5 @@ export {
ACTIVE_CLASS, ACTIVE_CLASS,
TEMPLATE_REGEX, TEMPLATE_REGEX,
IGNORE_CLASS, IGNORE_CLASS,
IGNORE_HIDING_CLASS,
}; };
import utils from './utils'; import utils from './utils';
import { SELECTED_CLASS, IGNORE_CLASS } from './constants'; import { SELECTED_CLASS, IGNORE_CLASS, IGNORE_HIDING_CLASS } from './constants';
class DropDown { class DropDown {
constructor(list) { constructor(list, config = {}) {
this.currentIndex = 0; this.currentIndex = 0;
this.hidden = true; this.hidden = true;
this.list = typeof list === 'string' ? document.querySelector(list) : list; this.list = typeof list === 'string' ? document.querySelector(list) : list;
this.items = []; this.items = [];
this.eventWrapper = {}; this.eventWrapper = {};
if (config.addActiveClassToDropdownButton) {
this.dropdownToggle = this.list.parentNode.querySelector('.js-dropdown-toggle');
}
this.getItems(); this.getItems();
this.initTemplateString(); this.initTemplateString();
this.addEvents(); this.addEvents();
...@@ -42,7 +45,7 @@ class DropDown { ...@@ -42,7 +45,7 @@ class DropDown {
this.addSelectedClass(selected); this.addSelectedClass(selected);
e.preventDefault(); e.preventDefault();
this.hide(); if (!e.target.classList.contains(IGNORE_HIDING_CLASS)) this.hide();
const listEvent = new CustomEvent('click.dl', { const listEvent = new CustomEvent('click.dl', {
detail: { detail: {
...@@ -67,7 +70,20 @@ class DropDown { ...@@ -67,7 +70,20 @@ class DropDown {
addEvents() { addEvents() {
this.eventWrapper.clickEvent = this.clickEvent.bind(this); this.eventWrapper.clickEvent = this.clickEvent.bind(this);
this.eventWrapper.closeDropdown = this.closeDropdown.bind(this);
this.list.addEventListener('click', this.eventWrapper.clickEvent); 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) { setData(data) {
...@@ -110,6 +126,8 @@ class DropDown { ...@@ -110,6 +126,8 @@ class DropDown {
this.list.style.display = 'block'; this.list.style.display = 'block';
this.currentIndex = 0; this.currentIndex = 0;
this.hidden = false; this.hidden = false;
if (this.dropdownToggle) this.dropdownToggle.classList.add('active');
} }
hide() { hide() {
...@@ -117,6 +135,8 @@ class DropDown { ...@@ -117,6 +135,8 @@ class DropDown {
this.list.style.display = 'none'; this.list.style.display = 'none';
this.currentIndex = 0; this.currentIndex = 0;
this.hidden = true; this.hidden = true;
if (this.dropdownToggle) this.dropdownToggle.classList.remove('active');
} }
toggle() { toggle() {
...@@ -128,6 +148,7 @@ class DropDown { ...@@ -128,6 +148,7 @@ class DropDown {
destroy() { destroy() {
this.hide(); this.hide();
this.list.removeEventListener('click', this.eventWrapper.clickEvent); this.list.removeEventListener('click', this.eventWrapper.clickEvent);
this.list.removeEventListener('keyup', this.eventWrapper.closeDropdown);
} }
static setImagesSrc(template) { static setImagesSrc(template) {
......
...@@ -3,7 +3,7 @@ import DropDown from './drop_down'; ...@@ -3,7 +3,7 @@ import DropDown from './drop_down';
class Hook { class Hook {
constructor(trigger, list, plugins, config) { constructor(trigger, list, plugins, config) {
this.trigger = trigger; this.trigger = trigger;
this.list = new DropDown(list); this.list = new DropDown(list, config);
this.type = 'Hook'; this.type = 'Hook';
this.event = 'click'; this.event = 'click';
this.plugins = plugins || []; this.plugins = plugins || [];
......
...@@ -203,7 +203,24 @@ ul.related-merge-requests > li { ...@@ -203,7 +203,24 @@ ul.related-merge-requests > li {
} }
.create-mr-dropdown-wrap { .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) { .btn-group:not(.hide) {
display: flex; display: flex;
...@@ -214,15 +231,16 @@ ul.related-merge-requests > li { ...@@ -214,15 +231,16 @@ ul.related-merge-requests > li {
flex-shrink: 0; flex-shrink: 0;
} }
.dropdown-menu { .create-merge-request-dropdown-menu {
width: 300px; width: 300px;
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
transform: translateY(0); transform: translateY(0);
display: none; display: none;
margin-top: 4px;
} }
.dropdown-toggle { .create-merge-request-dropdown-toggle {
.fa-caret-down { .fa-caret-down {
pointer-events: none; pointer-events: none;
color: inherit; color: inherit;
...@@ -230,18 +248,50 @@ ul.related-merge-requests > li { ...@@ -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) { li:not(.divider) {
padding: 8px 9px;
&:last-child {
padding-bottom: 8px;
}
&.droplab-item-selected { &.droplab-item-selected {
.icon-container { .icon-container {
i { i {
visibility: visible; visibility: visible;
} }
} }
.description {
display: block;
}
}
&.droplab-item-ignore {
padding-top: 8px;
} }
.icon-container { .icon-container {
float: left; float: left;
padding-left: 6px;
i { i {
visibility: hidden; visibility: hidden;
...@@ -249,13 +299,12 @@ ul.related-merge-requests > li { ...@@ -249,13 +299,12 @@ ul.related-merge-requests > li {
} }
.description { .description {
padding-left: 30px; padding-left: 22px;
font-size: 13px; }
strong { input,
display: block; span {
font-weight: $gl-font-weight-bold; margin: 4px 0 0;
}
} }
} }
} }
......
...@@ -158,7 +158,8 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -158,7 +158,8 @@ class Projects::IssuesController < Projects::ApplicationController
end end
def create_merge_request 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 if result[:status] == :success
render json: MergeRequestCreateSerializer.new.represent(result[:merge_request]) render json: MergeRequestCreateSerializer.new.represent(result[:merge_request])
......
module MergeRequests module MergeRequests
class BuildService < MergeRequests::BaseService class BuildService < MergeRequests::BaseService
def execute def execute
@issue_iid = params.delete(:issue_iid)
self.merge_request = MergeRequest.new(params) self.merge_request = MergeRequest.new(params)
merge_request.compare_commits = [] merge_request.compare_commits = []
merge_request.source_project = find_source_project merge_request.source_project = find_source_project
...@@ -116,37 +118,53 @@ module MergeRequests ...@@ -116,37 +118,53 @@ module MergeRequests
# more than one commit in the MR # more than one commit in the MR
# #
def assign_title_and_description def assign_title_and_description
if match = source_branch.match(/\A(\d+)-/) assign_title_and_description_from_single_commit
iid = match[1] assign_title_from_issue
end
commits = compare_commits merge_request.title ||= source_branch.titleize.humanize
if commits && commits.count == 1 merge_request.title = wip_title if compare_commits.empty?
commit = commits.first
merge_request.title = commit.title append_closes_description
merge_request.description ||= commit.description.try(:strip) end
elsif iid && issue = target_project.get_issue(iid, current_user)
case issue def append_closes_description
when Issue return unless issue_iid
merge_request.title = "Resolve \"#{issue.title}\""
when ExternalIssue closes_issue = "Closes ##{issue_iid}"
merge_request.title = "Resolve #{issue.title}"
end if description.present?
merge_request.description += closes_issue.prepend("\n\n")
else else
merge_request.title = source_branch.titleize.humanize merge_request.description = closes_issue
end end
end
if iid def assign_title_and_description_from_single_commit
closes_issue = "Closes ##{iid}" 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.title =
merge_request.description += closes_issue.prepend("\n\n") case issue
else when Issue then "Resolve \"#{issue.title}\""
merge_request.description = closes_issue when ExternalIssue then "Resolve #{issue.title}"
end 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 end
end end
module MergeRequests module MergeRequests
class CreateFromIssueService < MergeRequests::CreateService 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 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? params[:label_ids] = issue.label_ids if issue.label_ids.any?
...@@ -21,20 +32,16 @@ module MergeRequests ...@@ -21,20 +32,16 @@ module MergeRequests
private private
def issue_iid
@isssue_iid ||= params.delete(:issue_iid)
end
def issue 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 end
def branch_name def branch_name
@branch_name ||= issue.to_branch_name @branch ||= @branch_name || issue.to_branch_name
end end
def ref def ref
project.default_branch || 'master' @ref || project.default_branch || 'master'
end end
def merge_request def merge_request
...@@ -43,6 +50,7 @@ module MergeRequests ...@@ -43,6 +50,7 @@ module MergeRequests
def merge_request_params def merge_request_params
{ {
issue_iid: @issue_iid,
source_project_id: project.id, source_project_id: project.id,
source_branch: branch_name, source_branch: branch_name,
target_project_id: project.id, target_project_id: project.id,
......
- can_create_merge_request = can?(current_user, :create_merge_request, @project) - can_create_merge_request = can?(current_user, :create_merge_request, @project)
- data_action = can_create_merge_request ? 'create-mr' : 'create-branch' - 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) - 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 .btn-group.unavailable
%button.btn.btn-grouped{ type: 'button', disabled: 'disabled' } %button.btn.btn-grouped{ type: 'button', disabled: 'disabled' }
= icon('spinner', class: 'fa-spin') = icon('spinner', class: 'fa-spin')
%span.text %span.text
Checking branch availability… Checking branch availability…
.btn-group.available.hide .btn-group.available.hide
%input.btn.js-create-merge-request.btn-inverted.btn-success{ type: 'button', value: value, data: { action: data_action } } %button.btn.js-create-merge-request.btn-default{ type: 'button', 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' } } = 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') = 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 - if can_create_merge_request
%li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', 'text' => 'Create a merge request' } } %li.create-item.droplab-item-selected.droplab-item-ignore-hiding{ role: 'button', data: { value: 'create-mr', text: 'Create merge request' } }
.menu-item .menu-item.droplab-item-ignore-hiding
.icon-container .icon-container.droplab-item-ignore-hiding= icon('check')
= icon('check') .description.droplab-item-ignore-hiding Create merge request and branch
.description
%strong Create a merge request %li.create-item.droplab-item-ignore-hiding{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: 'Create branch' } }
%span .menu-item.droplab-item-ignore-hiding
Creates a merge request named after this issue, with source branch created from '#{@project.default_branch}'. .icon-container.droplab-item-ignore-hiding= icon('check')
%li.divider.droplab-item-ignore .description.droplab-item-ignore-hiding Create branch
%li{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', 'text' => 'Create a branch' } } %li.divider
.menu-item
.icon-container %li.droplab-item-ignore
= icon('check') Branch name
.description %input.js-branch-name.form-control.droplab-item-ignore{ type: 'text', placeholder: "#{@issue.to_branch_name}", value: "#{@issue.to_branch_name}" }
%strong Create a branch %span.js-branch-message.branch-message.droplab-item-ignore
%span
Creates a branch named after this issue, from '#{@project.default_branch}'. %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' require 'rails_helper'
feature 'Create Branch/Merge Request Dropdown on issue page', :feature, :js do describe 'User creates branch and merge request on issue page', :js do
let(:user) { create(:user) } let(:user) { create(:user) }
let!(:project) { create(:project, :repository) } let!(:project) { create(:project, :repository) }
let(:issue) { create(:issue, project: project, title: 'Cherry-Coloured Funk') } let(:issue) { create(:issue, project: project, title: 'Cherry-Coloured Funk') }
context 'for team members' do context 'when signed out' do
before do before do
project.team << [user, :developer] 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) sign_in(user)
end end
it 'allows creating a merge request from the issue page' do context 'when interacting with the dropdown' do
visit project_issue_path(project, issue) 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')
perform_enqueued_jobs do test_selection_mark(li_create_branch, li_create_merge_request, button_create_target, button_create_merge_request)
select_dropdown_option('create-mr') test_branch_name_checking(input_branch_name)
test_source_checking(input_source)
expect(page).to have_content('WIP: Resolve "Cherry-Coloured Funk"') # The button inside dropdown should be disabled if any errors occured.
expect(current_path).to eq(project_merge_request_path(project, MergeRequest.first)) expect(page).to have_button('Create branch', disabled: true)
end
wait_for_requests # The top level button should be disabled if any errors occured.
expect(page).to have_button('Create branch', disabled: true)
end end
visit project_issue_path(project, issue) 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("created branch 1-cherry-coloured-funk") expect(page).to have_content('WIP: Resolve "Cherry-Coloured Funk"')
expect(page).to have_content("mentioned in merge request !1") expect(current_path).to eq(project_merge_request_path(project, MergeRequest.first))
end
it 'allows creating a branch from the issue page' do wait_for_requests
visit project_issue_path(project, issue) end
select_dropdown_option('create-branch') visit project_issue_path(project, issue)
wait_for_requests 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' }
expect(page).to have_selector('.dropdown-toggle-text ', text: '1-cherry-coloured-funk') it 'creates a merge request' do
expect(current_path).to eq project_tree_path(project, '1-cherry-coloured-funk') 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 end
context "when there is a referenced merge request" do context "when there is a referenced merge request" do
...@@ -72,15 +153,15 @@ feature 'Create Branch/Merge Request Dropdown on issue page', :feature, :js do ...@@ -72,15 +153,15 @@ feature 'Create Branch/Merge Request Dropdown on issue page', :feature, :js do
end end
it 'shows only create branch button' do it 'shows only create branch button' do
expect(page).not_to have_button('Create a merge request') expect(page).not_to have_button('Create merge request')
expect(page).to have_button('Create a branch') expect(page).to have_button('Create branch')
end end
end end
context 'when issue is confidential' do context 'when issue is confidential' do
it 'disables the create branch button' do let(:issue) { create(:issue, :confidential, project: project) }
issue = create(:issue, :confidential, project: project)
it 'disables the create branch button' do
visit project_issue_path(project, issue) visit project_issue_path(project, issue)
expect(page).not_to have_css('.create-mr-dropdown-wrap') expect(page).not_to have_css('.create-mr-dropdown-wrap')
...@@ -88,19 +169,80 @@ feature 'Create Branch/Merge Request Dropdown on issue page', :feature, :js do ...@@ -88,19 +169,80 @@ feature 'Create Branch/Merge Request Dropdown on issue page', :feature, :js do
end end
end end
context 'for visitors' do private
before do
visit project_issue_path(project, issue) 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 end
it 'shows no buttons' do find('.js-create-merge-request').click
expect(page).not_to have_selector('.create-mr-dropdown-wrap') 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
end end
def select_dropdown_option(option) def test_source_checking(input_source)
find('.create-mr-dropdown-wrap .dropdown-toggle').click expect(input_source.value).to eq(project.default_branch)
find("li[data-value='#{option}']").click
find('.js-create-merge-request').click 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
end end
...@@ -279,7 +279,12 @@ describe('DropDown', function () { ...@@ -279,7 +279,12 @@ describe('DropDown', function () {
describe('addEvents', function () { describe('addEvents', function () {
beforeEach(function () { beforeEach(function () {
this.list = { addEventListener: () => {} }; this.list = { addEventListener: () => {} };
this.dropdown = { list: this.list, clickEvent: () => {}, eventWrapper: {} }; this.dropdown = {
list: this.list,
clickEvent: () => {},
closeDropdown: () => {},
eventWrapper: {},
};
spyOn(this.list, 'addEventListener'); spyOn(this.list, 'addEventListener');
...@@ -288,6 +293,7 @@ describe('DropDown', function () { ...@@ -288,6 +293,7 @@ describe('DropDown', function () {
it('should call .addEventListener', function () { it('should call .addEventListener', function () {
expect(this.list.addEventListener).toHaveBeenCalledWith('click', jasmine.any(Function)); expect(this.list.addEventListener).toHaveBeenCalledWith('click', jasmine.any(Function));
expect(this.list.addEventListener).toHaveBeenCalledWith('keyup', jasmine.any(Function));
}); });
}); });
......
...@@ -24,7 +24,7 @@ describe('Hook', function () { ...@@ -24,7 +24,7 @@ describe('Hook', function () {
}); });
it('should call DropDown constructor', 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 () { it('should set .type', function () {
......
...@@ -6,8 +6,10 @@ describe MergeRequests::CreateFromIssueService do ...@@ -6,8 +6,10 @@ describe MergeRequests::CreateFromIssueService do
let(:label_ids) { create_pair(:label, project: project).map(&:id) } let(:label_ids) { create_pair(:label, project: project).map(&:id) }
let(:milestone_id) { create(:milestone, project: project).id } let(:milestone_id) { create(:milestone, project: project).id }
let(:issue) { create(:issue, project: project, milestone_id: milestone_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) { 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 before do
project.add_developer(user) project.add_developer(user)
...@@ -17,8 +19,8 @@ describe MergeRequests::CreateFromIssueService do ...@@ -17,8 +19,8 @@ describe MergeRequests::CreateFromIssueService do
it 'returns an error with invalid issue iid' do it 'returns an error with invalid issue iid' do
result = described_class.new(project, user, issue_iid: -1).execute result = described_class.new(project, user, issue_iid: -1).execute
expect(result[:status]).to eq :error expect(result[:status]).to eq(:error)
expect(result[:message]).to eq 'Invalid issue iid' expect(result[:message]).to eq('Invalid issue iid')
end end
it 'delegates issue search to IssuesFinder' do it 'delegates issue search to IssuesFinder' do
...@@ -53,6 +55,12 @@ describe MergeRequests::CreateFromIssueService do ...@@ -53,6 +55,12 @@ describe MergeRequests::CreateFromIssueService do
expect(project.repository.branch_exists?(issue.to_branch_name)).to be_truthy expect(project.repository.branch_exists?(issue.to_branch_name)).to be_truthy
end 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 it 'creates a system note' do
expect(SystemNoteService).to receive(:new_issue_branch).with(issue, project, user, issue.to_branch_name) expect(SystemNoteService).to receive(:new_issue_branch).with(issue, project, user, issue.to_branch_name)
...@@ -72,19 +80,25 @@ describe MergeRequests::CreateFromIssueService do ...@@ -72,19 +80,25 @@ describe MergeRequests::CreateFromIssueService do
it 'sets the merge request author to current user' do it 'sets the merge request author to current user' do
result = service.execute result = service.execute
expect(result[:merge_request].author).to eq user expect(result[:merge_request].author).to eq(user)
end end
it 'sets the merge request source branch to the new issue branch' do it 'sets the merge request source branch to the new issue branch' do
result = service.execute 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 end
it 'sets the merge request target branch to the project default branch' do it 'sets the merge request target branch to the project default branch' do
result = service.execute 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 end
end end
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