Commit 0fdd4a31 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Merge branch 'revert-bc8546a9' into 'master'

Revert "Merge branch '213699-remove-search-results-autocomplete' into 'master'"

Closes #223841

See merge request gitlab-org/gitlab!35983
parents 141c6ecd a899612b
...@@ -33,7 +33,7 @@ import initFrequentItemDropdowns from './frequent_items'; ...@@ -33,7 +33,7 @@ import initFrequentItemDropdowns from './frequent_items';
import initBreadcrumbs from './breadcrumb'; import initBreadcrumbs from './breadcrumb';
import initUsagePingConsent from './usage_ping_consent'; import initUsagePingConsent from './usage_ping_consent';
import initPerformanceBar from './performance_bar'; import initPerformanceBar from './performance_bar';
import initGlobalSearchInput from './global_search_input'; import initSearchAutocomplete from './search_autocomplete';
import GlFieldErrors from './gl_field_errors'; import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers'; import initUserPopovers from './user_popovers';
import initBroadcastNotifications from './broadcast_notification'; import initBroadcastNotifications from './broadcast_notification';
...@@ -113,7 +113,7 @@ function deferredInitialisation() { ...@@ -113,7 +113,7 @@ function deferredInitialisation() {
initFrequentItemDropdowns(); initFrequentItemDropdowns();
initPersistentUserCallouts(); initPersistentUserCallouts();
if (document.querySelector('.search')) initGlobalSearchInput(); if (document.querySelector('.search')) initSearchAutocomplete();
addSelectOnFocusBehaviour('.js-select-on-focus'); addSelectOnFocusBehaviour('.js-select-on-focus');
......
/* eslint-disable no-return-assign, consistent-return, class-methods-use-this */ /* eslint-disable no-return-assign, consistent-return, class-methods-use-this */
import $ from 'jquery'; import $ from 'jquery';
import { throttle } from 'lodash'; import { escape, throttle } from 'lodash';
import { s__, __, sprintf } from '~/locale'; import { s__, __, sprintf } from '~/locale';
import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper';
import axios from './lib/utils/axios_utils';
import { import {
isInGroupsPage, isInGroupsPage,
isInProjectPage, isInProjectPage,
...@@ -65,11 +67,15 @@ function setSearchOptions() { ...@@ -65,11 +67,15 @@ function setSearchOptions() {
} }
} }
export class GlobalSearchInput { export class SearchAutocomplete {
constructor({ wrap } = {}) { constructor({ wrap, optsEl, autocompletePath, projectId, projectRef } = {}) {
setSearchOptions(); setSearchOptions();
this.bindEventContext(); this.bindEventContext();
this.wrap = wrap || $('.search'); 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.dropdown = this.wrap.find('.dropdown');
this.dropdownToggle = this.wrap.find('.js-dropdown-search-toggle'); this.dropdownToggle = this.wrap.find('.js-dropdown-search-toggle');
this.dropdownMenu = this.dropdown.find('.dropdown-menu'); this.dropdownMenu = this.dropdown.find('.dropdown-menu');
...@@ -86,7 +92,7 @@ export class GlobalSearchInput { ...@@ -86,7 +92,7 @@ export class GlobalSearchInput {
// Only when user is logged in // Only when user is logged in
if (gon.current_user_id) { if (gon.current_user_id) {
this.createGlobalSearchInput(); this.createAutocomplete();
} }
this.bindEvents(); this.bindEvents();
...@@ -111,7 +117,7 @@ export class GlobalSearchInput { ...@@ -111,7 +117,7 @@ export class GlobalSearchInput {
return (this.originalState = this.serializeState()); return (this.originalState = this.serializeState());
} }
createGlobalSearchInput() { createAutocomplete() {
return this.searchInput.glDropdown({ return this.searchInput.glDropdown({
filterInputBlur: false, filterInputBlur: false,
filterable: true, filterable: true,
...@@ -143,17 +149,116 @@ export class GlobalSearchInput { ...@@ -143,17 +149,116 @@ export class GlobalSearchInput {
if (glDropdownInstance) { if (glDropdownInstance) {
glDropdownInstance.filter.options.callback(contents); glDropdownInstance.filter.options.callback(contents);
} }
this.enableDropdown(); this.enableAutocomplete();
} }
return; return;
} }
const options = this.scopedSearchOptions(term); // Prevent multiple ajax calls
if (this.loadingSuggestions) {
return;
}
callback(options); 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,
});
}
this.highlightFirstRow(); callback(options);
this.setScrollFade();
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 // Add option to proceed with the search for each
...@@ -238,7 +343,7 @@ export class GlobalSearchInput { ...@@ -238,7 +343,7 @@ export class GlobalSearchInput {
}); });
} }
enableDropdown() { enableAutocomplete() {
this.setScrollFade(); this.setScrollFade();
// No need to enable anything if user is not logged in // No need to enable anything if user is not logged in
...@@ -255,7 +360,7 @@ export class GlobalSearchInput { ...@@ -255,7 +360,7 @@ export class GlobalSearchInput {
} }
onSearchInputChange() { onSearchInputChange() {
this.enableDropdown(); this.enableAutocomplete();
} }
onSearchInputKeyUp(e) { onSearchInputKeyUp(e) {
...@@ -264,7 +369,7 @@ export class GlobalSearchInput { ...@@ -264,7 +369,7 @@ export class GlobalSearchInput {
this.restoreOriginalState(); this.restoreOriginalState();
break; break;
case KEYCODE.ENTER: case KEYCODE.ENTER:
this.disableDropdown(); this.disableAutocomplete();
break; break;
default: default:
} }
...@@ -317,7 +422,7 @@ export class GlobalSearchInput { ...@@ -317,7 +422,7 @@ export class GlobalSearchInput {
return results; return results;
} }
disableDropdown() { disableAutocomplete() {
if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) { if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) {
this.searchInput.addClass('js-autocomplete-disabled'); this.searchInput.addClass('js-autocomplete-disabled');
this.dropdownToggle.dropdown('toggle'); this.dropdownToggle.dropdown('toggle');
...@@ -333,8 +438,16 @@ export class GlobalSearchInput { ...@@ -333,8 +438,16 @@ export class GlobalSearchInput {
onClick(item, $el, e) { onClick(item, $el, e) {
if (window.location.pathname.indexOf(item.url) !== -1) { if (window.location.pathname.indexOf(item.url) !== -1) {
if (!e.metaKey) e.preventDefault(); 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'); $el.removeClass('is-active');
this.disableDropdown(); this.disableAutocomplete();
return this.searchInput.val('').focus(); return this.searchInput.val('').focus();
} }
} }
...@@ -343,58 +456,20 @@ export class GlobalSearchInput { ...@@ -343,58 +456,20 @@ export class GlobalSearchInput {
this.searchInput.data('glDropdown').highlightRowAtIndex(null, 0); this.searchInput.data('glDropdown').highlightRowAtIndex(null, 0);
} }
getCategoryContents() { getAvatar(item) {
const userName = gon.current_username; if (!Object.hasOwnProperty.call(item, 'avatar_url')) {
const { projectOptions, groupOptions, dashboardOptions } = gl; return false;
// 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 { label, id } = item;
const baseItems = []; const avatarUrl = item.avatar_url;
const avatar = avatarUrl
if (name) { ? `<img class="search-item-avatar" src="${avatarUrl}" />`
baseItems.push({ : `<div class="s16 avatar identicon ${getIdenticonBackgroundClass(id)}">${getIdenticonTitle(
type: 'header', escape(label),
content: `${name}`, )}</div>`;
});
}
const issueItems = [ return avatar;
{
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;
} }
isScrolledUp() { isScrolledUp() {
...@@ -420,6 +495,6 @@ export class GlobalSearchInput { ...@@ -420,6 +495,6 @@ export class GlobalSearchInput {
} }
} }
export default function initGlobalSearchInput(opts) { export default function initSearchAutocomplete(opts) {
return new GlobalSearchInput(opts); return new SearchAutocomplete(opts);
} }
...@@ -51,6 +51,21 @@ class SearchController < ApplicationController ...@@ -51,6 +51,21 @@ class SearchController < ApplicationController
render json: { count: count } render json: { count: count }
end end
# rubocop: disable CodeReuse/ActiveRecord
def autocomplete
term = params[:term]
if params[:project_id].present?
@project = Project.find_by(id: params[:project_id])
@project = nil unless can?(current_user, :read_project, @project)
end
@ref = params[:project_ref] if params[:project_ref].present?
render json: search_autocomplete_opts(term).to_json
end
# rubocop: enable CodeReuse/ActiveRecord
private private
def preload_method def preload_method
......
...@@ -3,6 +3,28 @@ ...@@ -3,6 +3,28 @@
module SearchHelper module SearchHelper
SEARCH_PERMITTED_PARAMS = [:search, :scope, :project_id, :group_id, :repository_ref, :snippets].freeze SEARCH_PERMITTED_PARAMS = [:search, :scope, :project_id, :group_id, :repository_ref, :snippets].freeze
def search_autocomplete_opts(term)
return unless current_user
resources_results = [
groups_autocomplete(term),
projects_autocomplete(term)
].flatten
search_pattern = Regexp.new(Regexp.escape(term), "i")
generic_results = project_autocomplete + default_autocomplete + help_autocomplete
generic_results.concat(default_autocomplete_admin) if current_user.admin?
generic_results.select! { |result| result[:label] =~ search_pattern }
[
resources_results,
generic_results
].flatten.uniq do |item|
item[:label]
end
end
def search_entries_info(collection, scope, term) def search_entries_info(collection, scope, term)
return if collection.to_a.empty? return if collection.to_a.empty?
...@@ -73,6 +95,91 @@ module SearchHelper ...@@ -73,6 +95,91 @@ module SearchHelper
private private
# Autocomplete results for various settings pages
def default_autocomplete
[
{ category: "Settings", label: _("User settings"), url: profile_path },
{ category: "Settings", label: _("SSH Keys"), url: profile_keys_path },
{ category: "Settings", label: _("Dashboard"), url: root_path }
]
end
# Autocomplete results for settings pages, for admins
def default_autocomplete_admin
[
{ category: "Settings", label: _("Admin Section"), url: admin_root_path }
]
end
# Autocomplete results for internal help pages
def help_autocomplete
[
{ category: "Help", label: _("API Help"), url: help_page_path("api/README") },
{ category: "Help", label: _("Markdown Help"), url: help_page_path("user/markdown") },
{ category: "Help", label: _("Permissions Help"), url: help_page_path("user/permissions") },
{ category: "Help", label: _("Public Access Help"), url: help_page_path("public_access/public_access") },
{ category: "Help", label: _("Rake Tasks Help"), url: help_page_path("raketasks/README") },
{ category: "Help", label: _("SSH Keys Help"), url: help_page_path("ssh/README") },
{ category: "Help", label: _("System Hooks Help"), url: help_page_path("system_hooks/system_hooks") },
{ category: "Help", label: _("Webhooks Help"), url: help_page_path("user/project/integrations/webhooks") },
{ category: "Help", label: _("Workflow Help"), url: help_page_path("workflow/README") }
]
end
# Autocomplete results for the current project, if it's defined
def project_autocomplete
if @project && @project.repository.root_ref
ref = @ref || @project.repository.root_ref
[
{ category: "In this project", label: _("Files"), url: project_tree_path(@project, ref) },
{ category: "In this project", label: _("Commits"), url: project_commits_path(@project, ref) },
{ category: "In this project", label: _("Network"), url: project_network_path(@project, ref) },
{ category: "In this project", label: _("Graph"), url: project_graph_path(@project, ref) },
{ category: "In this project", label: _("Issues"), url: project_issues_path(@project) },
{ category: "In this project", label: _("Merge Requests"), url: project_merge_requests_path(@project) },
{ category: "In this project", label: _("Milestones"), url: project_milestones_path(@project) },
{ category: "In this project", label: _("Snippets"), url: project_snippets_path(@project) },
{ category: "In this project", label: _("Members"), url: project_project_members_path(@project) },
{ category: "In this project", label: _("Wiki"), url: project_wikis_path(@project) }
]
else
[]
end
end
# Autocomplete results for the current user's groups
# rubocop: disable CodeReuse/ActiveRecord
def groups_autocomplete(term, limit = 5)
current_user.authorized_groups.order_id_desc.search(term).limit(limit).map do |group|
{
category: "Groups",
id: group.id,
label: "#{search_result_sanitize(group.full_name)}",
url: group_path(group),
avatar_url: group.avatar_url || ''
}
end
end
# rubocop: enable CodeReuse/ActiveRecord
# Autocomplete results for the current user's projects
# rubocop: disable CodeReuse/ActiveRecord
def projects_autocomplete(term, limit = 5)
current_user.authorized_projects.order_id_desc.search_by_title(term)
.sorted_by_stars_desc.non_archived.limit(limit).map do |p|
{
category: "Projects",
id: p.id,
value: "#{search_result_sanitize(p.name)}",
label: "#{search_result_sanitize(p.full_name)}",
url: project_path(p),
avatar_url: p.avatar_url || ''
}
end
end
# rubocop: enable CodeReuse/ActiveRecord
def search_result_sanitize(str) def search_result_sanitize(str)
Sanitize.clean(str) Sanitize.clean(str)
end end
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
= form_tag search_path, method: :get, class: 'form-inline' do |f| = form_tag search_path, method: :get, class: 'form-inline' do |f|
.search-input-container .search-input-container
.search-input-wrap .search-input-wrap
.dropdown .dropdown{ data: { url: search_autocomplete_path } }
= search_field_tag 'search', nil, placeholder: _('Search or jump to…'), = search_field_tag 'search', nil, placeholder: _('Search or jump to…'),
class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options',
spellcheck: false, spellcheck: false,
...@@ -37,3 +37,6 @@ ...@@ -37,3 +37,6 @@
-# workaround for non-JS feature specs, see spec/support/helpers/search_helpers.rb -# workaround for non-JS feature specs, see spec/support/helpers/search_helpers.rb
- if ENV['RAILS_ENV'] == 'test' - if ENV['RAILS_ENV'] == 'test'
%noscript= button_tag 'Search' %noscript= button_tag 'Search'
.search-autocomplete-opts.hide{ :'data-autocomplete-path' => search_autocomplete_path,
:'data-autocomplete-project-id' => search_context.project.try(:id),
:'data-autocomplete-project-ref' => search_context.ref }
---
title: Restore the search autocomplete for groups/project/other
merge_request: 35983
author:
type: other
...@@ -58,6 +58,7 @@ Rails.application.routes.draw do ...@@ -58,6 +58,7 @@ Rails.application.routes.draw do
# Search # Search
get 'search' => 'search#show' get 'search' => 'search#show'
get 'search/autocomplete' => 'search#autocomplete', as: :search_autocomplete
get 'search/count' => 'search#count', as: :search_count get 'search/count' => 'search#count', as: :search_count
# JSON Web Token # JSON Web Token
......
...@@ -1124,6 +1124,9 @@ msgstr "" ...@@ -1124,6 +1124,9 @@ msgstr ""
msgid "ACTION REQUIRED: Something went wrong while obtaining the Let's Encrypt certificate for GitLab Pages domain '%{domain}'" msgid "ACTION REQUIRED: Something went wrong while obtaining the Let's Encrypt certificate for GitLab Pages domain '%{domain}'"
msgstr "" msgstr ""
msgid "API Help"
msgstr ""
msgid "API Token" msgid "API Token"
msgstr "" msgstr ""
...@@ -1594,6 +1597,9 @@ msgstr "" ...@@ -1594,6 +1597,9 @@ msgstr ""
msgid "Admin Overview" msgid "Admin Overview"
msgstr "" msgstr ""
msgid "Admin Section"
msgstr ""
msgid "Admin mode already enabled" msgid "Admin mode already enabled"
msgstr "" msgstr ""
...@@ -14014,6 +14020,9 @@ msgstr "" ...@@ -14014,6 +14020,9 @@ msgstr ""
msgid "Markdown" msgid "Markdown"
msgstr "" msgstr ""
msgid "Markdown Help"
msgstr ""
msgid "Markdown enabled" msgid "Markdown enabled"
msgstr "" msgstr ""
...@@ -16605,6 +16614,9 @@ msgstr "" ...@@ -16605,6 +16614,9 @@ msgstr ""
msgid "Permissions" msgid "Permissions"
msgstr "" msgstr ""
msgid "Permissions Help"
msgstr ""
msgid "Permissions, LFS, 2FA" msgid "Permissions, LFS, 2FA"
msgstr "" msgstr ""
...@@ -18777,6 +18789,9 @@ msgstr "" ...@@ -18777,6 +18789,9 @@ msgstr ""
msgid "Public - The project can be accessed without any authentication." msgid "Public - The project can be accessed without any authentication."
msgstr "" msgstr ""
msgid "Public Access Help"
msgstr ""
msgid "Public deploy keys (%{deploy_keys_count})" msgid "Public deploy keys (%{deploy_keys_count})"
msgstr "" msgstr ""
...@@ -18909,6 +18924,9 @@ msgstr "" ...@@ -18909,6 +18924,9 @@ msgstr ""
msgid "README" msgid "README"
msgstr "" msgstr ""
msgid "Rake Tasks Help"
msgstr ""
msgid "Raw blob request rate limit per minute" msgid "Raw blob request rate limit per minute"
msgstr "" msgstr ""
...@@ -20016,6 +20034,9 @@ msgstr "" ...@@ -20016,6 +20034,9 @@ msgstr ""
msgid "SSH Keys" msgid "SSH Keys"
msgstr "" msgstr ""
msgid "SSH Keys Help"
msgstr ""
msgid "SSH host key fingerprints" msgid "SSH host key fingerprints"
msgstr "" msgstr ""
...@@ -22575,6 +22596,9 @@ msgstr "" ...@@ -22575,6 +22596,9 @@ msgstr ""
msgid "System Hooks" msgid "System Hooks"
msgstr "" msgstr ""
msgid "System Hooks Help"
msgstr ""
msgid "System Info" msgid "System Info"
msgstr "" msgstr ""
...@@ -25334,6 +25358,9 @@ msgstr "" ...@@ -25334,6 +25358,9 @@ msgstr ""
msgid "User restrictions" msgid "User restrictions"
msgstr "" msgstr ""
msgid "User settings"
msgstr ""
msgid "User was successfully created." msgid "User was successfully created."
msgstr "" msgstr ""
...@@ -26043,6 +26070,9 @@ msgstr "" ...@@ -26043,6 +26070,9 @@ msgstr ""
msgid "Webhooks" msgid "Webhooks"
msgstr "" msgstr ""
msgid "Webhooks Help"
msgstr ""
msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group." msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
msgstr "" msgstr ""
...@@ -26315,6 +26345,9 @@ msgstr "" ...@@ -26315,6 +26345,9 @@ msgstr ""
msgid "Work in progress Limit" msgid "Work in progress Limit"
msgstr "" msgstr ""
msgid "Workflow Help"
msgstr ""
msgid "Write" msgid "Write"
msgstr "" msgstr ""
......
...@@ -211,4 +211,9 @@ RSpec.describe SearchController do ...@@ -211,4 +211,9 @@ RSpec.describe SearchController do
end.to raise_error(ActionController::ParameterMissing) end.to raise_error(ActionController::ParameterMissing)
end end
end end
describe 'GET #autocomplete' do
it_behaves_like 'when the user cannot read cross project', :autocomplete, { term: 'hello' }
it_behaves_like 'with external authorization service enabled', :autocomplete, { term: 'hello' }
end
end end
...@@ -2,30 +2,24 @@ ...@@ -2,30 +2,24 @@
import $ from 'jquery'; import $ from 'jquery';
import '~/gl_dropdown'; import '~/gl_dropdown';
import initGlobalSearchInput from '~/global_search_input'; import initSearchAutocomplete from '~/search_autocomplete';
import '~/lib/utils/common_utils'; import '~/lib/utils/common_utils';
import axios from '~/lib/utils/axios_utils';
import AxiosMockAdapter from 'axios-mock-adapter';
describe('Global search input dropdown', () => { describe('Search autocomplete dropdown', () => {
let widget = null; let widget = null;
const userName = 'root'; const userName = 'root';
const userId = 1; const userId = 1;
const dashboardIssuesPath = '/dashboard/issues'; const dashboardIssuesPath = '/dashboard/issues';
const dashboardMRsPath = '/dashboard/merge_requests'; const dashboardMRsPath = '/dashboard/merge_requests';
const projectIssuesPath = '/gitlab-org/gitlab-foss/issues'; const projectIssuesPath = '/gitlab-org/gitlab-foss/issues';
const projectMRsPath = '/gitlab-org/gitlab-foss/-/merge_requests'; const projectMRsPath = '/gitlab-org/gitlab-foss/-/merge_requests';
const groupIssuesPath = '/groups/gitlab-org/-/issues'; const groupIssuesPath = '/groups/gitlab-org/-/issues';
const groupMRsPath = '/groups/gitlab-org/-/merge_requests'; const groupMRsPath = '/groups/gitlab-org/-/merge_requests';
const autocompletePath = '/search/autocomplete';
const projectName = 'GitLab Community Edition'; const projectName = 'GitLab Community Edition';
const groupName = 'Gitlab Org'; const groupName = 'Gitlab Org';
const removeBodyAttributes = () => { const removeBodyAttributes = () => {
...@@ -112,15 +106,15 @@ describe('Global search input dropdown', () => { ...@@ -112,15 +106,15 @@ describe('Global search input dropdown', () => {
expect(list.find(mrsIHaveCreatedLink).text()).toBe("Merge requests I've created"); expect(list.find(mrsIHaveCreatedLink).text()).toBe("Merge requests I've created");
}; };
preloadFixtures('static/global_search_input.html'); preloadFixtures('static/search_autocomplete.html');
beforeEach(() => { beforeEach(() => {
loadFixtures('static/global_search_input.html'); loadFixtures('static/search_autocomplete.html');
window.gon = {}; window.gon = {};
window.gon.current_user_id = userId; window.gon.current_user_id = userId;
window.gon.current_username = userName; window.gon.current_username = userName;
return (widget = initGlobalSearchInput()); return (widget = initSearchAutocomplete({ autocompletePath }));
}); });
afterEach(() => { afterEach(() => {
...@@ -183,31 +177,105 @@ describe('Global search input dropdown', () => { ...@@ -183,31 +177,105 @@ describe('Global search input dropdown', () => {
widget.wrap.trigger($.Event('keydown', { which: DOWN })); widget.wrap.trigger($.Event('keydown', { which: DOWN }));
const enterKeyEvent = $.Event('keydown', { which: ENTER }); const enterKeyEvent = $.Event('keydown', { which: ENTER });
widget.searchInput.trigger(enterKeyEvent); widget.searchInput.trigger(enterKeyEvent);
// This does not currently catch failing behavior. For security reasons, // This does not currently catch failing behavior. For security reasons,
// browsers will not trigger default behavior (form submit, in this // browsers will not trigger default behavior (form submit, in this
// example) on JavaScript-created keypresses. // example) on JavaScript-created keypresses.
expect(submitSpy).not.toHaveBeenCalled(); expect(submitSpy).not.toHaveBeenCalled();
}); });
describe('disableDropdown', () => { describe('show autocomplete results', () => {
beforeEach(() => {
widget.enableAutocomplete();
const axiosMock = new AxiosMockAdapter(axios);
const autocompleteUrl = new RegExp(autocompletePath);
axiosMock.onGet(autocompleteUrl).reply(200, [
{
category: 'Projects',
id: 1,
value: 'Gitlab Test',
label: 'Gitlab Org / Gitlab Test',
url: '/gitlab-org/gitlab-test',
avatar_url: '',
},
{
category: 'Groups',
id: 1,
value: 'Gitlab Org',
label: 'Gitlab Org',
url: '/gitlab-org',
avatar_url: '',
},
]);
});
function triggerAutocomplete() {
return new Promise(resolve => {
const dropdown = widget.searchInput.data('glDropdown');
const filterCallback = dropdown.filter.options.callback;
dropdown.filter.options.callback = jest.fn(data => {
filterCallback(data);
resolve();
});
widget.searchInput.val('Gitlab');
widget.searchInput.triggerHandler('input');
});
}
it('suggest Projects', done => {
// eslint-disable-next-line promise/catch-or-return
triggerAutocomplete().finally(() => {
const list = widget.wrap.find('.dropdown-menu').find('ul');
const link = "a[href$='/gitlab-org/gitlab-test']";
expect(list.find(link).length).toBe(1);
done();
});
// Make sure jest properly acknowledge the `done` invocation
jest.runOnlyPendingTimers();
});
it('suggest Groups', done => {
// eslint-disable-next-line promise/catch-or-return
triggerAutocomplete().finally(() => {
const list = widget.wrap.find('.dropdown-menu').find('ul');
const link = "a[href$='/gitlab-org']";
expect(list.find(link).length).toBe(1);
done();
});
// Make sure jest properly acknowledge the `done` invocation
jest.runOnlyPendingTimers();
});
});
describe('disableAutocomplete', () => {
beforeEach(() => { beforeEach(() => {
widget.enableDropdown(); widget.enableAutocomplete();
}); });
it('should close the Dropdown', () => { it('should close the Dropdown', () => {
const toggleSpy = jest.spyOn(widget.dropdownToggle, 'dropdown'); const toggleSpy = jest.spyOn(widget.dropdownToggle, 'dropdown');
widget.dropdown.addClass('show'); widget.dropdown.addClass('show');
widget.disableDropdown(); widget.disableAutocomplete();
expect(toggleSpy).toHaveBeenCalledWith('toggle'); expect(toggleSpy).toHaveBeenCalledWith('toggle');
}); });
}); });
describe('enableDropdown', () => { describe('enableAutocomplete', () => {
it('should open the Dropdown', () => { it('should open the Dropdown', () => {
const toggleSpy = jest.spyOn(widget.dropdownToggle, 'dropdown'); const toggleSpy = jest.spyOn(widget.dropdownToggle, 'dropdown');
widget.enableDropdown(); widget.enableAutocomplete();
expect(toggleSpy).toHaveBeenCalledWith('toggle'); expect(toggleSpy).toHaveBeenCalledWith('toggle');
}); });
......
...@@ -8,6 +8,99 @@ RSpec.describe SearchHelper do ...@@ -8,6 +8,99 @@ RSpec.describe SearchHelper do
str str
end end
describe 'search_autocomplete_opts' do
context "with no current user" do
before do
allow(self).to receive(:current_user).and_return(nil)
end
it "returns nil" do
expect(search_autocomplete_opts("q")).to be_nil
end
end
context "with a standard user" do
let(:user) { create(:user) }
before do
allow(self).to receive(:current_user).and_return(user)
end
it "includes Help sections" do
expect(search_autocomplete_opts("hel").size).to eq(9)
end
it "includes default sections" do
expect(search_autocomplete_opts("dash").size).to eq(1)
end
it "does not include admin sections" do
expect(search_autocomplete_opts("admin").size).to eq(0)
end
it "does not allow regular expression in search term" do
expect(search_autocomplete_opts("(webhooks|api)").size).to eq(0)
end
it "includes the user's groups" do
create(:group).add_owner(user)
expect(search_autocomplete_opts("gro").size).to eq(1)
end
it "includes nested group" do
create(:group, :nested, name: 'foo').add_owner(user)
expect(search_autocomplete_opts('foo').size).to eq(1)
end
it "includes the user's projects" do
project = create(:project, namespace: create(:namespace, owner: user))
expect(search_autocomplete_opts(project.name).size).to eq(1)
end
it "includes the required project attrs" do
project = create(:project, namespace: create(:namespace, owner: user))
result = search_autocomplete_opts(project.name).first
expect(result.keys).to match_array(%i[category id value label url avatar_url])
end
it "includes the required group attrs" do
create(:group).add_owner(user)
result = search_autocomplete_opts("gro").first
expect(result.keys).to match_array(%i[category id label url avatar_url])
end
it "does not include the public group" do
group = create(:group)
expect(search_autocomplete_opts(group.name).size).to eq(0)
end
context "with a current project" do
before do
@project = create(:project, :repository)
end
it "includes project-specific sections" do
expect(search_autocomplete_opts("Files").size).to eq(1)
expect(search_autocomplete_opts("Commits").size).to eq(1)
end
end
end
context 'with an admin user' do
let(:admin) { create(:admin) }
before do
allow(self).to receive(:current_user).and_return(admin)
end
it "includes admin sections" do
expect(search_autocomplete_opts("admin").size).to eq(1)
end
end
end
describe 'search_entries_info' do describe 'search_entries_info' do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment