diff options
Diffstat (limited to 'app/assets/javascripts/issues/create_merge_request_dropdown.js')
-rw-r--r-- | app/assets/javascripts/issues/create_merge_request_dropdown.js | 566 |
1 files changed, 566 insertions, 0 deletions
diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js new file mode 100644 index 00000000000..5d36396bc6e --- /dev/null +++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js @@ -0,0 +1,566 @@ +import { debounce } from 'lodash'; +import { + init as initConfidentialMergeRequest, + isConfidentialIssue, + canCreateConfidentialMergeRequest, +} from '~/confidential_merge_request'; +import confidentialMergeRequestState from '~/confidential_merge_request/state'; +import DropLab from '~/filtered_search/droplab/drop_lab_deprecated'; +import ISetter from '~/filtered_search/droplab/plugins/input_setter'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __, sprintf } from '~/locale'; + +// Todo: Remove this when fixing issue in input_setter plugin +const InputSetter = { ...ISetter }; + +const CREATE_MERGE_REQUEST = 'create-mr'; +const CREATE_BRANCH = 'create-branch'; + +function createEndpoint(projectPath, endpoint) { + if (canCreateConfidentialMergeRequest()) { + return endpoint.replace( + projectPath, + confidentialMergeRequestState.selectedProject.pathWithNamespace, + ); + } + + return endpoint; +} + +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.createMergeRequestLoading = this.createMergeRequestButton.querySelector('.js-spinner'); + this.createTargetButton = this.wrapperEl.querySelector('.js-create-target'); + this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu'); + 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.unavailableButtonSpinner = this.unavailableButton.querySelector('.gl-spinner'); + this.unavailableButtonText = this.unavailableButton.querySelector('.text'); + + 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.refCancelToken = null; + this.mergeRequestCreated = 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.projectPath = this.wrapperEl.dataset.projectPath; + this.projectId = this.wrapperEl.dataset.projectId; + + // 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=)(.+?)$'), + }, + }; + + this.init(); + + if (isConfidentialIssue()) { + this.createMergeRequestButton.setAttribute( + 'data-dropdown-trigger', + '#create-merge-request-dropdown', + ); + initConfidentialMergeRequest(); + } + } + + available() { + this.availableButton.classList.remove('hidden'); + this.unavailableButton.classList.add('hidden'); + } + + bindEvents() { + this.createMergeRequestButton.addEventListener( + 'click', + this.onClickCreateMergeRequestButton.bind(this), + ); + this.createTargetButton.addEventListener( + 'click', + this.onClickCreateMergeRequestButton.bind(this), + ); + this.branchInput.addEventListener('input', this.onChangeInput.bind(this)); + this.branchInput.addEventListener('keyup', this.onChangeInput.bind(this)); + this.dropdownToggle.addEventListener('click', this.onClickSetFocusOnBranchNameInput.bind(this)); + // Detect for example when user pastes ref using the mouse + this.refInput.addEventListener('input', this.onChangeInput.bind(this)); + // Detect for example when user presses right arrow to apply the suggested ref + this.refInput.addEventListener('keyup', this.onChangeInput.bind(this)); + // Detect when user clicks inside the input to apply the suggested ref + this.refInput.addEventListener('click', this.onChangeInput.bind(this)); + // Detect when user clicks outside the input to apply the suggested ref + this.refInput.addEventListener('blur', this.onChangeInput.bind(this)); + // Detect when user presses tab to apply the suggested ref + this.refInput.addEventListener('keydown', CreateMergeRequestDropdown.processTab.bind(this)); + } + + checkAbilityToCreateBranch() { + this.setUnavailableButtonState(); + + axios + .get(this.canCreatePath) + .then(({ data }) => { + this.setUnavailableButtonState(false); + + if (data.can_create_branch) { + this.available(); + this.enable(); + this.updateBranchName(data.suggested_branch_name); + + if (!this.droplabInitialized) { + this.droplabInitialized = true; + this.initDroplab(); + this.bindEvents(); + } + } else { + this.hide(); + } + }) + .catch(() => { + this.unavailable(); + this.disable(); + createFlash({ + message: __('Failed to check related branches.'), + }); + }); + } + + createBranch() { + this.isCreatingBranch = true; + + return axios + .post(createEndpoint(this.projectPath, this.createBranchPath), { + confidential_issue_project_id: canCreateConfidentialMergeRequest() ? this.projectId : null, + }) + .then(({ data }) => { + this.branchCreated = true; + window.location.href = data.url; + }) + .catch(() => + createFlash({ + message: __('Failed to create a branch for this issue. Please try again.'), + }), + ); + } + + createMergeRequest() { + this.isCreatingMergeRequest = true; + + return axios + .post(this.createMrPath, { + target_project_id: canCreateConfidentialMergeRequest() + ? confidentialMergeRequestState.selectedProject.id + : null, + }) + .then(({ data }) => { + this.mergeRequestCreated = true; + window.location.href = data.url; + }) + .catch(() => + createFlash({ + message: __('Failed to create merge request. Please try again.'), + }), + ); + } + + disable() { + this.disableCreateAction(); + } + + setLoading(loading) { + this.createMergeRequestLoading.classList.toggle('gl-display-none', !loading); + } + + disableCreateAction() { + this.createMergeRequestButton.classList.add('disabled'); + this.createMergeRequestButton.setAttribute('disabled', 'disabled'); + + this.createTargetButton.classList.add('disabled'); + this.createTargetButton.setAttribute('disabled', 'disabled'); + } + + enable() { + if (isConfidentialIssue() && !canCreateConfidentialMergeRequest()) return; + + this.createMergeRequestButton.classList.remove('disabled'); + this.createMergeRequestButton.removeAttribute('disabled'); + + this.createTargetButton.classList.remove('disabled'); + this.createTargetButton.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 { + 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', + }, + ], + hideOnClick: false, + }; + } + + 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; + + this.refCancelToken = axios.CancelToken.source(); + + return axios + .get(`${createEndpoint(this.projectPath, this.refsPath)}${encodeURIComponent(ref)}`, { + cancelToken: this.refCancelToken.token, + }) + .then(({ 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; + } + + this.isGettingRef = false; + + return this.updateInputState(target, ref, result); + }) + .catch((thrown) => { + if (axios.isCancel(thrown)) { + return false; + } + this.unavailable(); + this.disable(); + createFlash({ + message: __('Failed to get ref.'), + }); + + this.isGettingRef = false; + + return false; + }); + } + + getTargetData(target) { + return { + input: this[`${target}Input`], + message: this[`${target}Message`], + }; + } + + hide() { + this.wrapperEl.classList.add('hidden'); + } + + 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.isGettingRef + ); + } + + onChangeInput(event) { + this.disable(); + let target; + let value; + + // User changed input, cancel to prevent previous request from interfering + if (this.refCancelToken !== null) { + this.refCancelToken.cancel(); + } + + if (event.target === this.branchInput) { + target = 'branch'; + ({ value } = this.branchInput); + } else if (event.target === this.refInput) { + target = 'ref'; + if (event.target === document.activeElement) { + value = + event.target.value.slice(0, event.target.selectionStart) + + event.target.value.slice(event.target.selectionEnd); + } else { + value = event.target.value; + } + } 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); + this.refDebounce(value, target); + return true; + } + + this.showCheckingMessage(target); + this.refDebounce(value, target); + + return true; + } + + onClickCreateMergeRequestButton(event) { + let xhr = null; + event.preventDefault(); + + if (isConfidentialIssue() && !event.target.classList.contains('js-create-target')) { + this.droplab.hooks.forEach((hook) => hook.list.toggle()); + + return; + } + + if (this.isBusy()) { + return; + } + + if (event.target.dataset.action === CREATE_MERGE_REQUEST) { + xhr = this.createMergeRequest(); + } else if (event.target.dataset.action === CREATE_BRANCH) { + xhr = this.createBranch(); + } + + xhr.catch(() => { + this.isCreatingMergeRequest = false; + this.isCreatingBranch = false; + + this.enable(); + this.setLoading(false); + }); + + this.setLoading(true); + this.disable(); + } + + onClickSetFocusOnBranchNameInput() { + this.branchInput.focus(); + } + + // `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(); + const caretPositionEnd = this.refInput.value.length; + this.refInput.setSelectionRange(caretPositionEnd, caretPositionEnd); + } + + removeMessage(target) { + const { input, message } = this.getTargetData(target); + const inputClasses = ['gl-field-error-outline', 'gl-field-success-outline']; + const messageClasses = ['text-muted', 'text-danger', 'text-success']; + + inputClasses.forEach((cssClass) => input.classList.remove(cssClass)); + messageClasses.forEach((cssClass) => message.classList.remove(cssClass)); + message.style.display = 'none'; + } + + setUnavailableButtonState(isLoading = true) { + if (isLoading) { + this.unavailableButtonSpinner.classList.remove('hide'); + this.unavailableButtonText.textContent = __('Checking branch availability...'); + } else { + this.unavailableButtonSpinner.classList.add('hide'); + 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('text-success'); + 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('text-muted'); + 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('text-danger'); + message.textContent = text; + message.style.display = 'inline-block'; + } + + unavailable() { + this.availableButton.classList.add('hidden'); + this.unavailableButton.classList.remove('hidden'); + } + + updateBranchName(suggestedBranchName) { + this.branchInput.value = suggestedBranchName; + this.updateCreatePaths('branch', suggestedBranchName); + } + + 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. + + // 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.updateCreatePaths(target, ref); + } + } else if (target === 'branch') { + this.branchIsValid = true; + this.showAvailableMessage('branch'); + this.updateCreatePaths(target, ref); + } 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(); + } + } + + // target - 'branch' or 'ref' + // ref - string - the new value to use as branch or ref + updateCreatePaths(target, ref) { + const pathReplacement = `$1${encodeURIComponent(ref)}`; + + this.createBranchPath = this.createBranchPath.replace( + this.regexps[target].createBranchPath, + pathReplacement, + ); + this.createMrPath = this.createMrPath.replace( + this.regexps[target].createMrPath, + pathReplacement, + ); + } +} |