diff options
Diffstat (limited to 'app/assets/javascripts/search_autocomplete.js')
-rw-r--r-- | app/assets/javascripts/search_autocomplete.js | 500 |
1 files changed, 500 insertions, 0 deletions
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js new file mode 100644 index 00000000000..05e0b9e7089 --- /dev/null +++ b/app/assets/javascripts/search_autocomplete.js @@ -0,0 +1,500 @@ +/* eslint-disable no-return-assign, consistent-return, class-methods-use-this */ + +import $ from 'jquery'; +import { escape, throttle } from 'lodash'; +import { s__, __, sprintf } from '~/locale'; +import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper'; +import axios from './lib/utils/axios_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() { + const $projectOptionsDataEl = $('.js-search-project-options'); + const $groupOptionsDataEl = $('.js-search-group-options'); + const $dashboardOptionsDataEl = $('.js-search-dashboard-options'); + + if ($projectOptionsDataEl.length) { + gl.projectOptions = gl.projectOptions || {}; + + const 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 || {}; + + const 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.bindEvents(); + this.dropdownToggle.dropdown(); + this.searchInput.addClass('js-autocomplete-disabled'); + } + + // 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.onSearchInputChange = this.onSearchInputChange.bind(this); + this.setScrollFade = this.setScrollFade.bind(this); + } + getElement(selector) { + return this.wrap.find(selector); + } + + saveOriginalState() { + return (this.originalState = this.serializeState()); + } + + 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) { + 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, + }, + }) + .then(response => { + const options = this.scopedSearchOptions(term); + + // List results + let lastCategory = null; + 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) { + options.push({ type: 'separator' }); + options.push({ + type: 'header', + content: suggestion.category, + }); + lastCategory = suggestion.category; + } + + // Add the suggestion + options.push({ + id: `${suggestion.category.toLowerCase()}-${suggestion.id}`, + icon: this.getAvatar(suggestion), + category: suggestion.category, + text: suggestion.label, + url: suggestion.url, + }); + } + + callback(options); + + 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 (isInProjectPage() && projectOptions) { + options = projectOptions[getProjectSlug()]; + } else if (isInGroupsPage() && groupOptions) { + options = groupOptions[getGroupSlug()]; + } else if (dashboardOptions) { + options = dashboardOptions; + } + + const { issuesPath, mrPath, name, issuesDisabled } = options; + const baseItems = []; + + if (name) { + baseItems.push({ + type: 'header', + content: `${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; + } + + // Add option to proceed with the search for each + // scope that is currently available, namely: + // + // - Search in this project + // - Search in this group (or project's group) + // - Search in all GitLab + scopedSearchOptions(term) { + const icon = spriteIcon('search', 's16 inline-search-icon'); + const projectId = this.projectInputEl.val(); + const groupId = this.groupInputEl.val(); + const options = []; + + if (projectId) { + const projectOptions = gl.projectOptions[getProjectSlug()]; + const url = groupId + ? `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}&group_id=${groupId}` + : `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}`; + + options.push({ + icon, + text: term, + template: sprintf( + s__(`SearchAutocomplete|in project %{projectName}`), + { + projectName: `<i>${projectOptions.name}</i>`, + }, + false, + ), + url, + }); + } + + if (groupId) { + const groupOptions = gl.groupOptions[getGroupSlug()]; + options.push({ + icon, + text: term, + template: sprintf( + s__(`SearchAutocomplete|in group %{groupName}`), + { + groupName: `<i>${groupOptions.name}</i>`, + }, + false, + ), + url: `${gon.relative_url_root}/search?search=${term}&group_id=${groupId}`, + }); + } + + options.push({ + icon, + text: term, + template: s__('SearchAutocomplete|in all GitLab'), + url: `${gon.relative_url_root}/search?search=${term}`, + }); + + return options; + } + + 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('input', this.onSearchInputChange); + 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)); + + this.searchInput.on('click', e => { + e.stopPropagation(); + }); + } + + 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('js-autocomplete-disabled'); + } + } + + onSearchInputChange() { + this.enableAutocomplete(); + } + + onSearchInputKeyUp(e) { + switch (e.keyCode) { + case KEYCODE.ESCAPE: + this.restoreOriginalState(); + break; + case KEYCODE.ENTER: + this.disableAutocomplete(); + break; + default: + } + this.wrap.toggleClass('has-value', Boolean(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', Boolean(e.target.value)); + return this.searchInput.val('').focus(); + } + + onSearchInputBlur() { + this.isFocused = false; + this.wrap.removeClass('search-active'); + // If input is blank then restore state + if (this.searchInput.val() === '') { + this.restoreOriginalState(); + } + this.dropdownMenu.removeClass('show'); + } + + restoreOriginalState() { + const inputs = Object.keys(this.originalState); + for (let i = 0, len = inputs.length; i < len; i += 1) { + const input = inputs[i]; + this.getElement(`#${input}`).val(this.originalState[input]); + } + } + + resetSearchState() { + const inputs = Object.keys(this.originalState); + const results = []; + for (let i = 0, len = inputs.length; i < len; i += 1) { + const input = inputs[i]; + results.push(this.getElement(`#${input}`).val('')); + } + return results; + } + + disableAutocomplete() { + if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) { + this.searchInput.addClass('js-autocomplete-disabled'); + this.dropdownToggle.dropdown('toggle'); + this.restoreMenu(); + } + } + + restoreMenu() { + const 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(); + /* eslint-disable-next-line @gitlab/require-i18n-strings */ + if (item.category === 'Projects') { + this.projectInputEl.val(item.id); + } + // eslint-disable-next-line @gitlab/require-i18n-strings + 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); +} |