Commit 3a40cdf2 authored by Micaël Bergeron's avatar Micaël Bergeron Committed by Micael Bergeron

Restore the search autocomplete results

This reverts https://gitlab.com/gitlab-org/gitlab/merge_request/31187
parent 91479fec
......@@ -33,7 +33,7 @@ import initFrequentItemDropdowns from './frequent_items';
import initBreadcrumbs from './breadcrumb';
import initUsagePingConsent from './usage_ping_consent';
import initPerformanceBar from './performance_bar';
import initGlobalSearchInput from './global_search_input';
import initSearchAutocomplete from './search_autocomplete';
import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
import initBroadcastNotifications from './broadcast_notification';
......@@ -113,7 +113,7 @@ function deferredInitialisation() {
initFrequentItemDropdowns();
initPersistentUserCallouts();
if (document.querySelector('.search')) initGlobalSearchInput();
if (document.querySelector('.search')) initSearchAutocomplete();
addSelectOnFocusBehaviour('.js-select-on-focus');
......
/* eslint-disable no-return-assign, consistent-return, class-methods-use-this */
import $ from 'jquery';
import { throttle } from 'lodash';
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,
......@@ -65,11 +67,15 @@ function setSearchOptions() {
}
}
export class GlobalSearchInput {
constructor({ wrap } = {}) {
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');
......@@ -86,7 +92,7 @@ export class GlobalSearchInput {
// Only when user is logged in
if (gon.current_user_id) {
this.createGlobalSearchInput();
this.createAutocomplete();
}
this.bindEvents();
......@@ -111,7 +117,7 @@ export class GlobalSearchInput {
return (this.originalState = this.serializeState());
}
createGlobalSearchInput() {
createAutocomplete() {
return this.searchInput.glDropdown({
filterInputBlur: false,
filterable: true,
......@@ -143,17 +149,116 @@ export class GlobalSearchInput {
if (glDropdownInstance) {
glDropdownInstance.filter.options.callback(contents);
}
this.enableDropdown();
this.enableAutocomplete();
}
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();
this.setScrollFade();
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
......@@ -238,7 +343,7 @@ export class GlobalSearchInput {
});
}
enableDropdown() {
enableAutocomplete() {
this.setScrollFade();
// No need to enable anything if user is not logged in
......@@ -255,7 +360,7 @@ export class GlobalSearchInput {
}
onSearchInputChange() {
this.enableDropdown();
this.enableAutocomplete();
}
onSearchInputKeyUp(e) {
......@@ -264,7 +369,7 @@ export class GlobalSearchInput {
this.restoreOriginalState();
break;
case KEYCODE.ENTER:
this.disableDropdown();
this.disableAutocomplete();
break;
default:
}
......@@ -317,7 +422,7 @@ export class GlobalSearchInput {
return results;
}
disableDropdown() {
disableAutocomplete() {
if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) {
this.searchInput.addClass('js-autocomplete-disabled');
this.dropdownToggle.dropdown('toggle');
......@@ -333,8 +438,16 @@ export class GlobalSearchInput {
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.disableDropdown();
this.disableAutocomplete();
return this.searchInput.val('').focus();
}
}
......@@ -343,58 +456,20 @@ export class GlobalSearchInput {
this.searchInput.data('glDropdown').highlightRowAtIndex(null, 0);
}
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;
getAvatar(item) {
if (!Object.hasOwnProperty.call(item, 'avatar_url')) {
return false;
}
const { issuesPath, mrPath, name, issuesDisabled } = options;
const baseItems = [];
if (name) {
baseItems.push({
type: 'header',
content: `${name}`,
});
}
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>`;
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;
return avatar;
}
isScrolledUp() {
......@@ -420,6 +495,6 @@ export class GlobalSearchInput {
}
}
export default function initGlobalSearchInput(opts) {
return new GlobalSearchInput(opts);
export default function initSearchAutocomplete(opts) {
return new SearchAutocomplete(opts);
}
......@@ -51,6 +51,21 @@ class SearchController < ApplicationController
render json: { count: count }
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
def preload_method
......
......@@ -3,6 +3,28 @@
module SearchHelper
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)
return if collection.to_a.empty?
......@@ -73,6 +95,91 @@ module SearchHelper
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)
Sanitize.clean(str)
end
......
......@@ -2,7 +2,7 @@
= form_tag search_path, method: :get, class: 'form-inline' do |f|
.search-input-container
.search-input-wrap
.dropdown
.dropdown{ data: { url: search_autocomplete_path } }
= search_field_tag 'search', nil, placeholder: _('Search or jump to…'),
class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options',
spellcheck: false,
......@@ -37,3 +37,6 @@
-# workaround for non-JS feature specs, see spec/support/helpers/search_helpers.rb
- if ENV['RAILS_ENV'] == 'test'
%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
# Search
get 'search' => 'search#show'
get 'search/autocomplete' => 'search#autocomplete', as: :search_autocomplete
get 'search/count' => 'search#count', as: :search_count
# JSON Web Token
......
......@@ -1097,6 +1097,9 @@ msgstr ""
msgid "ACTION REQUIRED: Something went wrong while obtaining the Let's Encrypt certificate for GitLab Pages domain '%{domain}'"
msgstr ""
msgid "API Help"
msgstr ""
msgid "API Token"
msgstr ""
......@@ -1564,6 +1567,9 @@ msgstr ""
msgid "Admin Overview"
msgstr ""
msgid "Admin Section"
msgstr ""
msgid "Admin mode already enabled"
msgstr ""
......@@ -13864,6 +13870,9 @@ msgstr ""
msgid "Markdown"
msgstr ""
msgid "Markdown Help"
msgstr ""
msgid "Markdown enabled"
msgstr ""
......@@ -16398,6 +16407,9 @@ msgstr ""
msgid "Permissions"
msgstr ""
msgid "Permissions Help"
msgstr ""
msgid "Permissions, LFS, 2FA"
msgstr ""
......@@ -18540,6 +18552,9 @@ msgstr ""
msgid "Public - The project can be accessed without any authentication."
msgstr ""
msgid "Public Access Help"
msgstr ""
msgid "Public deploy keys (%{deploy_keys_count})"
msgstr ""
......@@ -18672,6 +18687,9 @@ msgstr ""
msgid "README"
msgstr ""
msgid "Rake Tasks Help"
msgstr ""
msgid "Raw blob request rate limit per minute"
msgstr ""
......@@ -19770,6 +19788,9 @@ msgstr ""
msgid "SSH Keys"
msgstr ""
msgid "SSH Keys Help"
msgstr ""
msgid "SSH host key fingerprints"
msgstr ""
......@@ -22320,6 +22341,9 @@ msgstr ""
msgid "System Hooks"
msgstr ""
msgid "System Hooks Help"
msgstr ""
msgid "System Info"
msgstr ""
......@@ -25049,6 +25073,9 @@ msgstr ""
msgid "User restrictions"
msgstr ""
msgid "User settings"
msgstr ""
msgid "User was successfully created."
msgstr ""
......@@ -25734,6 +25761,9 @@ msgstr ""
msgid "Webhooks"
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."
msgstr ""
......@@ -26006,6 +26036,9 @@ msgstr ""
msgid "Work in progress Limit"
msgstr ""
msgid "Workflow Help"
msgstr ""
msgid "Write"
msgstr ""
......
......@@ -211,4 +211,9 @@ RSpec.describe SearchController do
end.to raise_error(ActionController::ParameterMissing)
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
......@@ -2,30 +2,21 @@
import $ from 'jquery';
import '~/gl_dropdown';
import initGlobalSearchInput from '~/global_search_input';
import initSearchAutocomplete from '~/search_autocomplete';
import '~/lib/utils/common_utils';
describe('Global search input dropdown', () => {
describe('Search autocomplete dropdown', () => {
let widget = null;
const userName = 'root';
const userId = 1;
const dashboardIssuesPath = '/dashboard/issues';
const dashboardMRsPath = '/dashboard/merge_requests';
const projectIssuesPath = '/gitlab-org/gitlab-foss/issues';
const projectMRsPath = '/gitlab-org/gitlab-foss/-/merge_requests';
const groupIssuesPath = '/groups/gitlab-org/-/issues';
const groupMRsPath = '/groups/gitlab-org/-/merge_requests';
const projectName = 'GitLab Community Edition';
const groupName = 'Gitlab Org';
const removeBodyAttributes = () => {
......@@ -112,15 +103,15 @@ describe('Global search input dropdown', () => {
expect(list.find(mrsIHaveCreatedLink).text()).toBe("Merge requests I've created");
};
preloadFixtures('static/global_search_input.html');
preloadFixtures('static/search_autocomplete.html');
beforeEach(() => {
loadFixtures('static/global_search_input.html');
loadFixtures('static/search_autocomplete.html');
window.gon = {};
window.gon.current_user_id = userId;
window.gon.current_username = userName;
return (widget = initGlobalSearchInput());
return (widget = initSearchAutocomplete());
});
afterEach(() => {
......@@ -183,31 +174,32 @@ describe('Global search input dropdown', () => {
widget.wrap.trigger($.Event('keydown', { which: DOWN }));
const enterKeyEvent = $.Event('keydown', { which: ENTER });
widget.searchInput.trigger(enterKeyEvent);
// This does not currently catch failing behavior. For security reasons,
// browsers will not trigger default behavior (form submit, in this
// example) on JavaScript-created keypresses.
expect(submitSpy).not.toHaveBeenCalled();
});
describe('disableDropdown', () => {
describe('disableAutocomplete', () => {
beforeEach(() => {
widget.enableDropdown();
widget.enableAutocomplete();
});
it('should close the Dropdown', () => {
const toggleSpy = jest.spyOn(widget.dropdownToggle, 'dropdown');
widget.dropdown.addClass('show');
widget.disableDropdown();
widget.disableAutocomplete();
expect(toggleSpy).toHaveBeenCalledWith('toggle');
});
});
describe('enableDropdown', () => {
describe('enableAutocomplete', () => {
it('should open the Dropdown', () => {
const toggleSpy = jest.spyOn(widget.dropdownToggle, 'dropdown');
widget.enableDropdown();
widget.enableAutocomplete();
expect(toggleSpy).toHaveBeenCalledWith('toggle');
});
......
......@@ -8,6 +8,99 @@ RSpec.describe SearchHelper do
str
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
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