gitlabhq/gitlabhq

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

Summary

Maintainability
D
3 days
Test Coverage
/* eslint-disable no-return-assign, one-var, no-var, no-unused-vars, consistent-return, object-shorthand, prefer-template, class-methods-use-this, no-lonely-if, vars-on-top */

import $ from 'jquery';
import { escape, throttle } from 'underscore';
import { s__, __, sprintf } from '~/locale';
import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper';
import axios from './lib/utils/axios_utils';
import DropdownUtils from './filtered_search/dropdown_utils';
import {
  isInGroupsPage,
  isInProjectPage,
  getGroupSlug,
  getProjectSlug,
  spriteIcon,
} from './lib/utils/common_utils';

/**
 * Search input in top navigation bar.
 * On click, opens a dropdown
 * As the user types it filters the results
 * When the user clicks `x` button it cleans the input and closes the dropdown.
 */

const KEYCODE = {
  ESCAPE: 27,
  BACKSPACE: 8,
  ENTER: 13,
  UP: 38,
  DOWN: 40,
};

function setSearchOptions() {
  var $projectOptionsDataEl = $('.js-search-project-options');
  var $groupOptionsDataEl = $('.js-search-group-options');
  var $dashboardOptionsDataEl = $('.js-search-dashboard-options');

  if ($projectOptionsDataEl.length) {
    gl.projectOptions = gl.projectOptions || {};

    var projectPath = $projectOptionsDataEl.data('projectPath');

    gl.projectOptions[projectPath] = {
      name: $projectOptionsDataEl.data('name'),
      issuesPath: $projectOptionsDataEl.data('issuesPath'),
      issuesDisabled: $projectOptionsDataEl.data('issuesDisabled'),
      mrPath: $projectOptionsDataEl.data('mrPath'),
    };
  }

  if ($groupOptionsDataEl.length) {
    gl.groupOptions = gl.groupOptions || {};

    var groupPath = $groupOptionsDataEl.data('groupPath');

    gl.groupOptions[groupPath] = {
      name: $groupOptionsDataEl.data('name'),
      issuesPath: $groupOptionsDataEl.data('issuesPath'),
      mrPath: $groupOptionsDataEl.data('mrPath'),
    };
  }

  if ($dashboardOptionsDataEl.length) {
    gl.dashboardOptions = {
      name: s__('SearchAutocomplete|All GitLab'),
      issuesPath: $dashboardOptionsDataEl.data('issuesPath'),
      mrPath: $dashboardOptionsDataEl.data('mrPath'),
    };
  }
}

export class SearchAutocomplete {
  constructor({ wrap, optsEl, autocompletePath, projectId, projectRef } = {}) {
    setSearchOptions();
    this.bindEventContext();
    this.wrap = wrap || $('.search');
    this.optsEl = optsEl || this.wrap.find('.search-autocomplete-opts');
    this.autocompletePath = autocompletePath || this.optsEl.data('autocompletePath');
    this.projectId = projectId || (this.optsEl.data('autocompleteProjectId') || '');
    this.projectRef = projectRef || (this.optsEl.data('autocompleteProjectRef') || '');
    this.dropdown = this.wrap.find('.dropdown');
    this.dropdownToggle = this.wrap.find('.js-dropdown-search-toggle');
    this.dropdownMenu = this.dropdown.find('.dropdown-menu');
    this.dropdownContent = this.dropdown.find('.dropdown-content');
    this.scopeInputEl = this.getElement('#scope');
    this.searchInput = this.getElement('.search-input');
    this.projectInputEl = this.getElement('#search_project_id');
    this.groupInputEl = this.getElement('#group_id');
    this.searchCodeInputEl = this.getElement('#search_code');
    this.repositoryInputEl = this.getElement('#repository_ref');
    this.clearInput = this.getElement('.js-clear-input');
    this.scrollFadeInitialized = false;
    this.saveOriginalState();

    // Only when user is logged in
    if (gon.current_user_id) {
      this.createAutocomplete();
    }

    this.searchInput.addClass('disabled');
    this.saveTextLength();
    this.bindEvents();
    this.dropdownToggle.dropdown();
  }

  // Finds an element inside wrapper element
  bindEventContext() {
    this.onSearchInputBlur = this.onSearchInputBlur.bind(this);
    this.onClearInputClick = this.onClearInputClick.bind(this);
    this.onSearchInputFocus = this.onSearchInputFocus.bind(this);
    this.onSearchInputKeyUp = this.onSearchInputKeyUp.bind(this);
    this.onSearchInputKeyDown = this.onSearchInputKeyDown.bind(this);
    this.setScrollFade = this.setScrollFade.bind(this);
  }
  getElement(selector) {
    return this.wrap.find(selector);
  }

  saveOriginalState() {
    return (this.originalState = this.serializeState());
  }

  saveTextLength() {
    return (this.lastTextLength = this.searchInput.val().length);
  }

  createAutocomplete() {
    return this.searchInput.glDropdown({
      filterInputBlur: false,
      filterable: true,
      filterRemote: true,
      highlight: true,
      icon: true,
      enterCallback: false,
      filterInput: 'input#search',
      search: {
        fields: ['text'],
      },
      id: this.getSearchText,
      data: this.getData.bind(this),
      selectable: true,
      clicked: this.onClick.bind(this),
    });
  }

  getSearchText(selectedObject, el) {
    return selectedObject.id ? selectedObject.text : '';
  }

  getData(term, callback) {
    if (!term) {
      const contents = this.getCategoryContents();
      if (contents) {
        const glDropdownInstance = this.searchInput.data('glDropdown');

        if (glDropdownInstance) {
          glDropdownInstance.filter.options.callback(contents);
        }
        this.enableAutocomplete();
      }
      return;
    }

    // Prevent multiple ajax calls
    if (this.loadingSuggestions) {
      return;
    }

    this.loadingSuggestions = true;

    return axios
      .get(this.autocompletePath, {
        params: {
          project_id: this.projectId,
          project_ref: this.projectRef,
          term: term,
        },
      })
      .then(response => {
        // Hide dropdown menu if no suggestions returns
        if (!response.data.length) {
          this.disableAutocomplete();
          return;
        }

        const data = [];
        // List results
        let firstCategory = true;
        let lastCategory;
        for (let i = 0, len = response.data.length; i < len; i += 1) {
          const suggestion = response.data[i];
          // Add group header before list each group
          if (lastCategory !== suggestion.category) {
            if (!firstCategory) {
              data.push('separator');
            }
            if (firstCategory) {
              firstCategory = false;
            }
            data.push({
              header: suggestion.category,
            });
            lastCategory = suggestion.category;
          }
          data.push({
            id: `${suggestion.category.toLowerCase()}-${suggestion.id}`,
            icon: this.getAvatar(suggestion),
            category: suggestion.category,
            text: suggestion.label,
            url: suggestion.url,
          });
        }
        // Add option to proceed with the search
        if (data.length) {
          const icon = spriteIcon('search', 's16 inline-search-icon');
          let template;

          if (this.projectInputEl.val()) {
            template = s__('SearchAutocomplete|in this project');
          }
          if (this.groupInputEl.val()) {
            template = s__('SearchAutocomplete|in this group');
          }

          data.unshift('separator');
          data.unshift({
            icon,
            text: term,
            template: s__('SearchAutocomplete|in all GitLab'),
            url: `${gon.relative_url_root}/search?search=${term}`,
          });

          if (template) {
            data.unshift({
              icon,
              text: term,
              template,
              url: `${
                gon.relative_url_root
              }/search?search=${term}&project_id=${this.projectInputEl.val()}&group_id=${this.groupInputEl.val()}`,
            });
          }
        }

        callback(data);

        this.loadingSuggestions = false;
        this.highlightFirstRow();
        this.setScrollFade();
      })
      .catch(() => {
        this.loadingSuggestions = false;
      });
  }

  getCategoryContents() {
    const userName = gon.current_username;
    const { projectOptions, groupOptions, dashboardOptions } = gl;

    // Get options
    let options;
    if (isInGroupsPage() && groupOptions) {
      options = groupOptions[getGroupSlug()];
    } else if (isInProjectPage() && projectOptions) {
      options = projectOptions[getProjectSlug()];
    } else if (dashboardOptions) {
      options = dashboardOptions;
    }

    const { issuesPath, mrPath, name, issuesDisabled } = options;
    const baseItems = [];

    if (name) {
      baseItems.push({
        header: `${name}`,
      });
    }

    const issueItems = [
      {
        text: s__('SearchAutocomplete|Issues assigned to me'),
        url: `${issuesPath}/?assignee_username=${userName}`,
      },
      {
        text: s__("SearchAutocomplete|Issues I've created"),
        url: `${issuesPath}/?author_username=${userName}`,
      },
    ];
    const mergeRequestItems = [
      {
        text: s__('SearchAutocomplete|Merge requests assigned to me'),
        url: `${mrPath}/?assignee_username=${userName}`,
      },
      {
        text: s__("SearchAutocomplete|Merge requests I've created"),
        url: `${mrPath}/?author_username=${userName}`,
      },
    ];

    let items;
    if (issuesDisabled) {
      items = baseItems.concat(mergeRequestItems);
    } else {
      items = baseItems.concat(...issueItems, ...mergeRequestItems);
    }
    return items;
  }

  serializeState() {
    return {
      // Search Criteria
      search_project_id: this.projectInputEl.val(),
      group_id: this.groupInputEl.val(),
      search_code: this.searchCodeInputEl.val(),
      repository_ref: this.repositoryInputEl.val(),
      scope: this.scopeInputEl.val(),
    };
  }

  bindEvents() {
    this.searchInput.on('keydown', this.onSearchInputKeyDown);
    this.searchInput.on('keyup', this.onSearchInputKeyUp);
    this.searchInput.on('focus', this.onSearchInputFocus);
    this.searchInput.on('blur', this.onSearchInputBlur);
    this.clearInput.on('click', this.onClearInputClick);
    this.dropdownContent.on('scroll', throttle(this.setScrollFade, 250));
  }

  enableAutocomplete() {
    this.setScrollFade();

    // No need to enable anything if user is not logged in
    if (!gon.current_user_id) {
      return;
    }

    // If the dropdown is closed, we'll open it
    if (!this.dropdown.hasClass('show')) {
      this.loadingSuggestions = false;
      this.dropdownToggle.dropdown('toggle');
      return this.searchInput.removeClass('disabled');
    }
  }

  // Saves last length of the entered text
  onSearchInputKeyDown() {
    return this.saveTextLength();
  }

  onSearchInputKeyUp(e) {
    switch (e.keyCode) {
      case KEYCODE.BACKSPACE:
        // When removing the last character and no badge is present
        if (this.lastTextLength === 1) {
          this.disableAutocomplete();
        }
        // When removing any character from existin value
        if (this.lastTextLength > 1) {
          this.enableAutocomplete();
        }
        break;
      case KEYCODE.ESCAPE:
        this.restoreOriginalState();
        break;
      case KEYCODE.ENTER:
        this.disableAutocomplete();
        break;
      case KEYCODE.UP:
      case KEYCODE.DOWN:
        return;
      default:
        // Handle the case when deleting the input value other than backspace
        // e.g. Pressing ctrl + backspace or ctrl + x
        if (this.searchInput.val() === '') {
          this.disableAutocomplete();
        } else {
          // We should display the menu only when input is not empty
          if (e.keyCode !== KEYCODE.ENTER) {
            this.enableAutocomplete();
          }
        }
    }
    this.wrap.toggleClass('has-value', !!e.target.value);
  }

  onSearchInputFocus() {
    this.isFocused = true;
    this.wrap.addClass('search-active');
    if (this.getValue() === '') {
      return this.getData();
    }
  }

  getValue() {
    return this.searchInput.val();
  }

  onClearInputClick(e) {
    e.preventDefault();
    this.wrap.toggleClass('has-value', !!e.target.value);
    return this.searchInput.val('').focus();
  }

  onSearchInputBlur(e) {
    this.isFocused = false;
    this.wrap.removeClass('search-active');
    // If input is blank then restore state
    if (this.searchInput.val() === '') {
      return this.restoreOriginalState();
    }
    this.dropdownMenu.removeClass('show');
  }

  restoreOriginalState() {
    var i, input, inputs, len;
    inputs = Object.keys(this.originalState);
    for (i = 0, len = inputs.length; i < len; i += 1) {
      input = inputs[i];
      this.getElement('#' + input).val(this.originalState[input]);
    }
  }

  resetSearchState() {
    var i, input, inputs, len, results;
    inputs = Object.keys(this.originalState);
    results = [];
    for (i = 0, len = inputs.length; i < len; i += 1) {
      input = inputs[i];
      results.push(this.getElement('#' + input).val(''));
    }
    return results;
  }

  disableAutocomplete() {
    if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('show')) {
      this.searchInput.addClass('disabled');
      this.dropdown.removeClass('show').trigger('hidden.bs.dropdown');
      this.restoreMenu();
    }
  }

  restoreMenu() {
    var html;
    html = `<ul><li class="dropdown-menu-empty-item"><a>${__('Loading...')}</a></li></ul>`;
    return this.dropdownContent.html(html);
  }

  onClick(item, $el, e) {
    if (window.location.pathname.indexOf(item.url) !== -1) {
      if (!e.metaKey) e.preventDefault();
      if (item.category === 'Projects') {
        this.projectInputEl.val(item.id);
      }
      if (item.category === 'Groups') {
        this.groupInputEl.val(item.id);
      }
      $el.removeClass('is-active');
      this.disableAutocomplete();
      return this.searchInput.val('').focus();
    }
  }

  highlightFirstRow() {
    this.searchInput.data('glDropdown').highlightRowAtIndex(null, 0);
  }

  getAvatar(item) {
    if (!Object.hasOwnProperty.call(item, 'avatar_url')) {
      return false;
    }

    const { label, id } = item;
    const avatarUrl = item.avatar_url;
    const avatar = avatarUrl
      ? `<img class="search-item-avatar" src="${avatarUrl}" />`
      : `<div class="s16 avatar identicon ${getIdenticonBackgroundClass(id)}">${getIdenticonTitle(
          escape(label),
        )}</div>`;

    return avatar;
  }

  isScrolledUp() {
    const el = this.dropdownContent[0];
    const currentPosition = this.contentClientHeight + el.scrollTop;

    return currentPosition < this.maxPosition;
  }

  initScrollFade() {
    const el = this.dropdownContent[0];
    this.scrollFadeInitialized = true;

    this.contentClientHeight = el.clientHeight;
    this.maxPosition = el.scrollHeight;
    this.dropdownMenu.addClass('dropdown-content-faded-mask');
  }

  setScrollFade() {
    this.initScrollFade();

    this.dropdownMenu.toggleClass('fade-out', !this.isScrolledUp());
  }
}

export default function initSearchAutocomplete(opts) {
  return new SearchAutocomplete(opts);
}