diff options
author | Sean McGivern <sean@mcgivern.me.uk> | 2017-11-29 13:01:05 +0300 |
---|---|---|
committer | Sean McGivern <sean@mcgivern.me.uk> | 2017-11-29 13:01:05 +0300 |
commit | f3ac2e56e72e483da192f4aea1b464e23d56f0b4 (patch) | |
tree | 9616471a899548a9c87a23fb375b9a071a6bcde9 /app/assets | |
parent | 673c4ccbc8914a1bee0ed4fe544c06ddb000fd44 (diff) | |
parent | 5bc32b65f1257ca1f2c9fefc1e251be55b0fdc17 (diff) |
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
Diffstat (limited to 'app/assets')
-rw-r--r-- | app/assets/javascripts/create_merge_request_dropdown.js | 454 | ||||
-rw-r--r-- | app/assets/javascripts/droplab/constants.js | 2 | ||||
-rw-r--r-- | app/assets/javascripts/droplab/drop_down.js | 29 | ||||
-rw-r--r-- | app/assets/javascripts/droplab/hook.js | 2 | ||||
-rw-r--r-- | app/assets/stylesheets/pages/issues.scss | 69 |
5 files changed, 453 insertions, 103 deletions
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index bf40eb3ee11..23425672b16 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -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); @@ -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() { @@ -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() { @@ -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(); } @@ -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(); + } } } diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/droplab/constants.js index 868d47e91b3..673e9bb4c0f 100644 --- a/app/assets/javascripts/droplab/constants.js +++ b/app/assets/javascripts/droplab/constants.js @@ -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; @@ -13,4 +14,5 @@ export { ACTIVE_CLASS, TEMPLATE_REGEX, IGNORE_CLASS, + IGNORE_HIDING_CLASS, }; diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js index 3901bb177fe..5eb0a339a1c 100644 --- a/app/assets/javascripts/droplab/drop_down.js +++ b/app/assets/javascripts/droplab/drop_down.js @@ -1,15 +1,18 @@ 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(); @@ -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: { @@ -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) { @@ -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() { @@ -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() { @@ -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) { diff --git a/app/assets/javascripts/droplab/hook.js b/app/assets/javascripts/droplab/hook.js index cf78165b0d8..8a8dcde9f88 100644 --- a/app/assets/javascripts/droplab/hook.js +++ b/app/assets/javascripts/droplab/hook.js @@ -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 || []; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 8bb68ad2425..d3dda2e7d25 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -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; @@ -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; @@ -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; @@ -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; } } } |