gitlabhq/gitlabhq

View on GitHub
app/assets/javascripts/create_merge_request_dropdown.js

Summary

Maintainability
F
3 days
Test Coverage
/* eslint-disable no-new */
import _ from 'underscore';
import axios from './lib/utils/axios_utils';
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);

const CREATE_MERGE_REQUEST = 'create-mr';
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.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.unavailableButtonArrow = this.unavailableButton.querySelector('.fa');
    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.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;

    // 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();
  }

  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('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() {
    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();
        Flash(__('Failed to check related branches.'));
      });
  }

  createBranch() {
    this.isCreatingBranch = true;

    return axios
      .post(this.createBranchPath)
      .then(({ data }) => {
        this.branchCreated = true;
        window.location.href = data.url;
      })
      .catch(() => Flash('Failed to create a branch for this issue. Please try again.'));
  }

  createMergeRequest() {
    this.isCreatingMergeRequest = true;

    return axios
      .post(this.createMrPath)
      .then(({ data }) => {
        this.mergeRequestCreated = true;
        window.location.href = data.url;
      })
      .catch(() => 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 {
      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;

    return axios
      .get(`${this.refsPath}${encodeURIComponent(ref)}`)
      .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(() => {
        this.unavailable();
        this.disable();
        new Flash('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) {
    let target;
    let value;

    if (event.target === this.branchInput) {
      target = 'branch';
      ({ value } = this.branchInput);
    } else if (event.target === this.refInput) {
      target = 'ref';
      value =
        event.target.value.slice(0, event.target.selectionStart) +
        event.target.value.slice(event.target.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;
    event.preventDefault();

    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.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();
    window.getSelection().removeAllRanges();
  }

  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.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('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,
    );
  }
}