Commit 842b0408 authored by Dylan Griffith's avatar Dylan Griffith Committed by Natalia Tepluhina

Remove all Javascript for search autocomplete in nav bar

This is the first step in removing the functionality in
https://gitlab.com/gitlab-org/gitlab/-/issues/213699
parent cd0bef45
/* eslint-disable no-return-assign, consistent-return, class-methods-use-this */
import $ from 'jquery';
import { escape, throttle } from 'lodash';
import { 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,
......@@ -67,15 +65,11 @@ function setSearchOptions() {
}
}
export class SearchAutocomplete {
constructor({ wrap, optsEl, autocompletePath, projectId, projectRef } = {}) {
export class GlobalSearchInput {
constructor({ wrap } = {}) {
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');
......@@ -92,7 +86,7 @@ export class SearchAutocomplete {
// Only when user is logged in
if (gon.current_user_id) {
this.createAutocomplete();
this.createGlobalSearchInput();
}
this.bindEvents();
......@@ -117,7 +111,7 @@ export class SearchAutocomplete {
return (this.originalState = this.serializeState());
}
createAutocomplete() {
createGlobalSearchInput() {
return this.searchInput.glDropdown({
filterInputBlur: false,
filterable: true,
......@@ -149,116 +143,17 @@ export class SearchAutocomplete {
if (glDropdownInstance) {
glDropdownInstance.filter.options.callback(contents);
}
this.enableAutocomplete();
this.enableDropdown();
}
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 options = this.scopedSearchOptions(term);
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}`,
},
];
callback(options);
let items;
if (issuesDisabled) {
items = baseItems.concat(mergeRequestItems);
} else {
items = baseItems.concat(...issueItems, ...mergeRequestItems);
}
return items;
this.highlightFirstRow();
this.setScrollFade();
}
// Add option to proceed with the search for each
......@@ -343,7 +238,7 @@ export class SearchAutocomplete {
});
}
enableAutocomplete() {
enableDropdown() {
this.setScrollFade();
// No need to enable anything if user is not logged in
......@@ -360,7 +255,7 @@ export class SearchAutocomplete {
}
onSearchInputChange() {
this.enableAutocomplete();
this.enableDropdown();
}
onSearchInputKeyUp(e) {
......@@ -369,7 +264,7 @@ export class SearchAutocomplete {
this.restoreOriginalState();
break;
case KEYCODE.ENTER:
this.disableAutocomplete();
this.disableDropdown();
break;
default:
}
......@@ -422,7 +317,7 @@ export class SearchAutocomplete {
return results;
}
disableAutocomplete() {
disableDropdown() {
if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) {
this.searchInput.addClass('js-autocomplete-disabled');
this.dropdownToggle.dropdown('toggle');
......@@ -438,16 +333,8 @@ export class SearchAutocomplete {
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();
this.disableDropdown();
return this.searchInput.val('').focus();
}
}
......@@ -456,20 +343,58 @@ export class SearchAutocomplete {
this.searchInput.data('glDropdown').highlightRowAtIndex(null, 0);
}
getAvatar(item) {
if (!Object.hasOwnProperty.call(item, 'avatar_url')) {
return 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 { 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 { issuesPath, mrPath, name, issuesDisabled } = options;
const baseItems = [];
if (name) {
baseItems.push({
type: 'header',
content: `${name}`,
});
}
return avatar;
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;
}
isScrolledUp() {
......@@ -495,6 +420,6 @@ export class SearchAutocomplete {
}
}
export default function initSearchAutocomplete(opts) {
return new SearchAutocomplete(opts);
export default function initGlobalSearchInput(opts) {
return new GlobalSearchInput(opts);
}
......@@ -32,7 +32,7 @@ import initFrequentItemDropdowns from './frequent_items';
import initBreadcrumbs from './breadcrumb';
import initUsagePingConsent from './usage_ping_consent';
import initPerformanceBar from './performance_bar';
import initSearchAutocomplete from './search_autocomplete';
import initGlobalSearchInput from './global_search_input';
import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
import initBroadcastNotifications from './broadcast_notification';
......@@ -110,7 +110,7 @@ function deferredInitialisation() {
initFrequentItemDropdowns();
initPersistentUserCallouts();
if (document.querySelector('.search')) initSearchAutocomplete();
if (document.querySelector('.search')) initGlobalSearchInput();
addSelectOnFocusBehaviour('.js-select-on-focus');
......
......@@ -51,21 +51,6 @@ 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,28 +3,6 @@
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?
......@@ -95,91 +73,6 @@ 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{ data: { url: search_autocomplete_path } }
.dropdown
= 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,6 +37,3 @@
-# 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: Remove all search autocomplete for groups/projects/other
merge_request: 31187
author:
type: removed
......@@ -58,7 +58,6 @@ 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
......
......@@ -983,9 +983,6 @@ 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 ""
......@@ -1441,9 +1438,6 @@ msgstr ""
msgid "Admin Overview"
msgstr ""
msgid "Admin Section"
msgstr ""
msgid "Admin mode already enabled"
msgstr ""
......@@ -13333,9 +13327,6 @@ msgstr ""
msgid "Markdown"
msgstr ""
msgid "Markdown Help"
msgstr ""
msgid "Markdown enabled"
msgstr ""
......@@ -15651,9 +15642,6 @@ msgstr ""
msgid "Permissions"
msgstr ""
msgid "Permissions Help"
msgstr ""
msgid "Permissions, LFS, 2FA"
msgstr ""
......@@ -17682,9 +17670,6 @@ 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 ""
......@@ -17814,9 +17799,6 @@ msgstr ""
msgid "README"
msgstr ""
msgid "Rake Tasks Help"
msgstr ""
msgid "Raw blob request rate limit per minute"
msgstr ""
......@@ -18861,9 +18843,6 @@ msgstr ""
msgid "SSH Keys"
msgstr ""
msgid "SSH Keys Help"
msgstr ""
msgid "SSH host key fingerprints"
msgstr ""
......@@ -21343,9 +21322,6 @@ msgstr ""
msgid "System Hooks"
msgstr ""
msgid "System Hooks Help"
msgstr ""
msgid "System Info"
msgstr ""
......@@ -23975,9 +23951,6 @@ msgstr ""
msgid "User restrictions"
msgstr ""
msgid "User settings"
msgstr ""
msgid "User was successfully created."
msgstr ""
......@@ -24789,9 +24762,6 @@ 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 ""
......@@ -25058,9 +25028,6 @@ msgstr ""
msgid "Work in progress Limit"
msgstr ""
msgid "Workflow Help"
msgstr ""
msgid "Write"
msgstr ""
......
......@@ -211,9 +211,4 @@ 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
......@@ -8,99 +8,6 @@ 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
......
......@@ -2,10 +2,10 @@
import $ from 'jquery';
import '~/gl_dropdown';
import initSearchAutocomplete from '~/search_autocomplete';
import initGlobalSearchInput from '~/global_search_input';
import '~/lib/utils/common_utils';
describe('Search autocomplete dropdown', () => {
describe('Global search input dropdown', () => {
let widget = null;
const userName = 'root';
......@@ -112,15 +112,15 @@ describe('Search autocomplete dropdown', () => {
expect(list.find(mrsIHaveCreatedLink).text()).toBe("Merge requests I've created");
};
preloadFixtures('static/search_autocomplete.html');
preloadFixtures('static/global_search_input.html');
beforeEach(function() {
loadFixtures('static/search_autocomplete.html');
loadFixtures('static/global_search_input.html');
window.gon = {};
window.gon.current_user_id = userId;
window.gon.current_username = userName;
return (widget = initSearchAutocomplete());
return (widget = initGlobalSearchInput());
});
afterEach(function() {
......@@ -189,25 +189,25 @@ describe('Search autocomplete dropdown', () => {
expect(submitSpy).not.toHaveBeenTriggered();
});
describe('disableAutocomplete', function() {
describe('disableDropdown', function() {
beforeEach(function() {
widget.enableAutocomplete();
widget.enableDropdown();
});
it('should close the Dropdown', function() {
const toggleSpy = spyOn(widget.dropdownToggle, 'dropdown');
widget.dropdown.addClass('show');
widget.disableAutocomplete();
widget.disableDropdown();
expect(toggleSpy).toHaveBeenCalledWith('toggle');
});
});
describe('enableAutocomplete', function() {
describe('enableDropdown', function() {
it('should open the Dropdown', function() {
const toggleSpy = spyOn(widget.dropdownToggle, 'dropdown');
widget.enableAutocomplete();
widget.enableDropdown();
expect(toggleSpy).toHaveBeenCalledWith('toggle');
});
......
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