Commit f3baab86 authored by Michael Kozono's avatar Michael Kozono

Merge branch '12547-load-search-counts-async' into 'master'

Load search result counts asynchronously (EE)

See merge request gitlab-org/gitlab-ee!14689
parents e2be49c7 08bee854
import axios from '~/lib/utils/axios_utils';
function showCount(el, count) {
el.textContent = count;
el.classList.remove('hidden');
}
function refreshCount(el) {
const { url } = el.dataset;
return axios
.get(url)
.then(({ data }) => showCount(el, data.count))
.catch(e => {
// eslint-disable-next-line no-console
console.error(`Failed to fetch search count from '${url}'.`, e);
});
}
export default function refreshCounts() {
const elements = Array.from(document.querySelectorAll('.js-search-count'));
return Promise.all(elements.map(refreshCount));
}
...@@ -3,6 +3,7 @@ import Flash from '~/flash'; ...@@ -3,6 +3,7 @@ import Flash from '~/flash';
import Api from '~/api'; import Api from '~/api';
import { __ } from '~/locale'; import { __ } from '~/locale';
import Project from '~/pages/projects/project'; import Project from '~/pages/projects/project';
import refreshCounts from './refresh_counts';
export default class Search { export default class Search {
constructor() { constructor() {
...@@ -14,6 +15,7 @@ export default class Search { ...@@ -14,6 +15,7 @@ export default class Search {
this.groupId = $groupDropdown.data('groupId'); this.groupId = $groupDropdown.data('groupId');
this.eventListeners(); this.eventListeners();
refreshCounts();
$groupDropdown.glDropdown({ $groupDropdown.glDropdown({
selectable: true, selectable: true,
......
...@@ -36,6 +36,15 @@ class SearchController < ApplicationController ...@@ -36,6 +36,15 @@ class SearchController < ApplicationController
check_single_commit_result check_single_commit_result
end end
def count
params.require([:search, :scope])
scope = search_service.scope
count = search_service.search_results.formatted_count(scope)
render json: { count: count }
end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def autocomplete def autocomplete
term = params[:term] term = params[:term]
......
...@@ -145,17 +145,27 @@ module SearchHelper ...@@ -145,17 +145,27 @@ module SearchHelper
Sanitize.clean(str) Sanitize.clean(str)
end end
def search_filter_path(options = {}) def search_filter_link(scope, label, data: {}, search: {})
exist_opts = { search_params = params
search: params[:search], .merge(search)
project_id: params[:project_id], .merge({ scope: scope })
group_id: params[:group_id], .permit(:search, :scope, :project_id, :group_id, :repository_ref, :snippets)
scope: params[:scope],
repository_ref: params[:repository_ref] if @scope == scope
} li_class = 'active'
count = @search_results.formatted_count(scope)
else
badge_class = 'js-search-count hidden'
badge_data = { url: search_count_path(search_params) }
end
options = exist_opts.merge(options) content_tag :li, class: li_class, data: data do
search_path(options) link_to search_path(search_params) do
concat label
concat ' '
concat content_tag(:span, count, class: ['badge badge-pill', badge_class], data: badge_data)
end
end
end end
def search_filter_input_options(type) def search_filter_input_options(type)
...@@ -212,10 +222,6 @@ module SearchHelper ...@@ -212,10 +222,6 @@ module SearchHelper
sanitize(html, tags: %w(a p ol ul li pre code)) sanitize(html, tags: %w(a p ol ul li pre code))
end end
def limited_count(count, limit = 1000)
count > limit ? "#{limit}+" : count
end
def search_tabs?(tab) def search_tabs?(tab)
return false if Feature.disabled?(:users_search, default_enabled: true) return false if Feature.disabled?(:users_search, default_enabled: true)
......
...@@ -438,18 +438,20 @@ class User < ApplicationRecord ...@@ -438,18 +438,20 @@ class User < ApplicationRecord
order = <<~SQL order = <<~SQL
CASE CASE
WHEN users.name = %{query} THEN 0 WHEN users.name = :query THEN 0
WHEN users.username = %{query} THEN 1 WHEN users.username = :query THEN 1
WHEN users.email = %{query} THEN 2 WHEN users.email = :query THEN 2
ELSE 3 ELSE 3
END END
SQL SQL
sanitized_order_sql = Arel.sql(sanitize_sql_array([order, query: query]))
where( where(
fuzzy_arel_match(:name, query, lower_exact_match: true) fuzzy_arel_match(:name, query, lower_exact_match: true)
.or(fuzzy_arel_match(:username, query, lower_exact_match: true)) .or(fuzzy_arel_match(:username, query, lower_exact_match: true))
.or(arel_table[:email].eq(query)) .or(arel_table[:email].eq(query))
).reorder(order % { query: ApplicationRecord.connection.quote(query) }, :name) ).reorder(sanitized_order_sql, :name)
end end
# Limits the result set to users _not_ in the given query/list of IDs. # Limits the result set to users _not_ in the given query/list of IDs.
......
...@@ -47,5 +47,6 @@ ...@@ -47,5 +47,6 @@
= hidden_field_tag :snippets, true = hidden_field_tag :snippets, true
= hidden_field_tag :repository_ref, @ref = hidden_field_tag :repository_ref, @ref
= hidden_field_tag :nav_source, 'navbar' = hidden_field_tag :nav_source, 'navbar'
-# workaround for non-JS feature specs, for JS you need to use find('#search').send_keys(:enter)
= button_tag 'Go' if ENV['RAILS_ENV'] == 'test' = button_tag 'Go' if ENV['RAILS_ENV'] == 'test'
.search-autocomplete-opts.hide{ :'data-autocomplete-path' => search_autocomplete_path, :'data-autocomplete-project-id' => @project.try(:id), :'data-autocomplete-project-ref' => @ref } .search-autocomplete-opts.hide{ :'data-autocomplete-path' => search_autocomplete_path, :'data-autocomplete-project-id' => @project.try(:id), :'data-autocomplete-project-ref' => @ref }
- users = capture_haml do - users = capture_haml do
- if search_tabs?(:members) - if search_tabs?(:members)
%li{ class: active_when(@scope == 'users') } = search_filter_link 'users', _("Users")
= link_to search_filter_path(scope: 'users') do
Users
%span.badge.badge-pill
= limited_count(@search_results.limited_users_count)
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
.fade-left= icon('angle-left') .fade-left= icon('angle-left')
...@@ -12,80 +8,28 @@ ...@@ -12,80 +8,28 @@
%ul.nav-links.search-filter.scrolling-tabs.nav.nav-tabs %ul.nav-links.search-filter.scrolling-tabs.nav.nav-tabs
- if @project - if @project
- if project_search_tabs?(:blobs) - if project_search_tabs?(:blobs)
%li{ class: active_when(@scope == 'blobs'), data: { qa_selector: 'code_tab' } } = search_filter_link 'blobs', _("Code"), data: { qa_selector: 'code_tab' }
= link_to search_filter_path(scope: 'blobs') do
= _("Code")
%span.badge.badge-pill
= @search_results.blobs_count
- if project_search_tabs?(:issues) - if project_search_tabs?(:issues)
%li{ class: active_when(@scope == 'issues') } = search_filter_link 'issues', _("Issues")
= link_to search_filter_path(scope: 'issues') do
= _("Issues")
%span.badge.badge-pill
= limited_count(@search_results.limited_issues_count)
- if project_search_tabs?(:merge_requests) - if project_search_tabs?(:merge_requests)
%li{ class: active_when(@scope == 'merge_requests') } = search_filter_link 'merge_requests', _("Merge requests")
= link_to search_filter_path(scope: 'merge_requests') do
= _("Merge requests")
%span.badge.badge-pill
= limited_count(@search_results.limited_merge_requests_count)
- if project_search_tabs?(:milestones) - if project_search_tabs?(:milestones)
%li{ class: active_when(@scope == 'milestones') } = search_filter_link 'milestones', _("Milestones")
= link_to search_filter_path(scope: 'milestones') do
= _("Milestones")
%span.badge.badge-pill
= limited_count(@search_results.limited_milestones_count)
- if project_search_tabs?(:notes) - if project_search_tabs?(:notes)
%li{ class: active_when(@scope == 'notes') } = search_filter_link 'notes', _("Comments")
= link_to search_filter_path(scope: 'notes') do
= _("Comments")
%span.badge.badge-pill
= limited_count(@search_results.limited_notes_count)
- if project_search_tabs?(:wiki) - if project_search_tabs?(:wiki)
%li{ class: active_when(@scope == 'wiki_blobs') } = search_filter_link 'wiki_blobs', _("Wiki")
= link_to search_filter_path(scope: 'wiki_blobs') do
= _("Wiki")
%span.badge.badge-pill
= @search_results.wiki_blobs_count
- if project_search_tabs?(:commits) - if project_search_tabs?(:commits)
%li{ class: active_when(@scope == 'commits') } = search_filter_link 'commits', _("Commits")
= link_to search_filter_path(scope: 'commits') do
= _("Commits")
%span.badge.badge-pill
= @search_results.commits_count
= users = users
- elsif @show_snippets - elsif @show_snippets
%li{ class: active_when(@scope == 'snippet_blobs') } = search_filter_link 'snippet_blobs', _("Snippet Contents"), search: { snippets: true, group_id: nil, project_id: nil }
= link_to search_filter_path(scope: 'snippet_blobs', snippets: true, group_id: nil, project_id: nil) do = search_filter_link 'snippet_titles', _("Titles and Filenames"), search: { snippets: true, group_id: nil, project_id: nil }
= _("Snippet Contents")
%span.badge.badge-pill
= @search_results.snippet_blobs_count
%li{ class: active_when(@scope == 'snippet_titles') }
= link_to search_filter_path(scope: 'snippet_titles', snippets: true, group_id: nil, project_id: nil) do
= _("Titles and Filenames")
%span.badge.badge-pill
= @search_results.snippet_titles_count
- else - else
%li{ class: active_when(@scope == 'projects') } = search_filter_link 'projects', _("Projects")
= link_to search_filter_path(scope: 'projects') do = search_filter_link 'issues', _("Issues")
= _("Projects") = search_filter_link 'merge_requests', _("Merge requests")
%span.badge.badge-pill = search_filter_link 'milestones', _("Milestones")
= limited_count(@search_results.limited_projects_count)
%li{ class: active_when(@scope == 'issues') }
= link_to search_filter_path(scope: 'issues') do
= _("Issues")
%span.badge.badge-pill
= limited_count(@search_results.limited_issues_count)
%li{ class: active_when(@scope == 'merge_requests') }
= link_to search_filter_path(scope: 'merge_requests') do
= _("Merge requests")
%span.badge.badge-pill
= limited_count(@search_results.limited_merge_requests_count)
%li{ class: active_when(@scope == 'milestones') }
= link_to search_filter_path(scope: 'milestones') do
= _("Milestones")
%span.badge.badge-pill
= limited_count(@search_results.limited_milestones_count)
= render_if_exists 'search/category_elasticsearch' = render_if_exists 'search/category_elasticsearch'
= users = users
---
title: Load search result counts asynchronously
merge_request: 31663
author:
type: changed
...@@ -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/autocomplete' => 'search#autocomplete', as: :search_autocomplete
get 'search/count' => 'search#count', as: :search_count
# JSON Web Token # JSON Web Token
get 'jwt/auth' => 'jwt#auth' get 'jwt/auth' => 'jwt#auth'
......
- if search_service.use_elasticsearch? - if search_service.use_elasticsearch?
%li{ class: active_when(@scope == 'notes') } = search_filter_link 'notes', _("Comments")
= link_to search_filter_path(scope: 'notes') do = search_filter_link 'blobs', _("Code"), data: { qa_selector: 'code_tab' }
= _("Comments") = search_filter_link 'commits', _("Commits")
%span.badge.badge-pill = search_filter_link 'wiki_blobs', _("Wiki")
= limited_count(@search_results.notes_count)
%li{ class: active_when(@scope == 'blobs'), data: { qa_selector: 'code_tab' } }
= link_to search_filter_path(scope: 'blobs') do
= _("Code")
%span.badge.badge-pill
= limited_count(@search_results.blobs_count)
%li{ class: active_when(@scope == 'commits') }
= link_to search_filter_path(scope: 'commits') do
= _("Commits")
%span.badge.badge-pill
= limited_count(@search_results.commits_count)
%li{ class: active_when(@scope == 'wiki_blobs') }
= link_to search_filter_path(scope: 'wiki_blobs') do
= _("Wiki")
%span.badge.badge-pill
= limited_count(@search_results.wiki_blobs_count)
...@@ -318,7 +318,14 @@ module Elasticsearch ...@@ -318,7 +318,14 @@ module Elasticsearch
res res
end end
def delete_index_for_commits_and_blobs def delete_index_for_commits_and_blobs(wiki: false)
types =
if wiki
%w[wiki_blob]
else
%w[commit blob]
end
client_for_indexing.delete_by_query( client_for_indexing.delete_by_query(
index: self.class.index_name, index: self.class.index_name,
routing: es_parent, routing: es_parent,
...@@ -328,7 +335,7 @@ module Elasticsearch ...@@ -328,7 +335,7 @@ module Elasticsearch
filter: [ filter: [
{ {
terms: { terms: {
type: %w{commit blob} type: types
} }
}, },
{ {
......
...@@ -92,7 +92,7 @@ module Gitlab ...@@ -92,7 +92,7 @@ module Gitlab
def run_indexer!(to_sha) def run_indexer!(to_sha)
if index_status && !repository_contains_last_indexed_commit? if index_status && !repository_contains_last_indexed_commit?
repository.delete_index_for_commits_and_blobs repository.delete_index_for_commits_and_blobs(wiki: wiki?)
end end
command = command =
......
...@@ -40,23 +40,6 @@ module Gitlab ...@@ -40,23 +40,6 @@ module Gitlab
@generic_search_results ||= Gitlab::ProjectSearchResults.new(current_user, project, query, repository_ref) @generic_search_results ||= Gitlab::ProjectSearchResults.new(current_user, project, query, repository_ref)
end end
def blobs_count
@blobs_count ||= blobs.total_count
end
def notes_count
@notes_count ||= notes.total_count
end
alias_method :limited_notes_count, :notes_count
def wiki_blobs_count
@wiki_blobs_count ||= wiki_blobs.total_count
end
def commits_count
@commits_count ||= commits.total_count
end
private private
def blobs def blobs
......
...@@ -53,10 +53,32 @@ module Gitlab ...@@ -53,10 +53,32 @@ module Gitlab
@generic_search_results ||= Gitlab::SearchResults.new(current_user, limit_projects, query) @generic_search_results ||= Gitlab::SearchResults.new(current_user, limit_projects, query)
end end
def formatted_count(scope)
case scope
when 'projects'
projects_count.to_s
when 'notes'
notes_count.to_s
when 'blobs'
blobs_count.to_s
when 'wiki_blobs'
wiki_blobs_count.to_s
when 'commits'
commits_count.to_s
when 'issues'
issues_count.to_s
when 'merge_requests'
merge_requests_count.to_s
when 'milestones'
milestones_count.to_s
when 'users'
generic_search_results.formatted_count('users')
end
end
def projects_count def projects_count
@projects_count ||= projects.total_count @projects_count ||= projects.total_count
end end
alias_method :limited_projects_count, :projects_count
def notes_count def notes_count
@notes_count ||= notes.total_count @notes_count ||= notes.total_count
...@@ -77,17 +99,14 @@ module Gitlab ...@@ -77,17 +99,14 @@ module Gitlab
def issues_count def issues_count
@issues_count ||= issues.total_count @issues_count ||= issues.total_count
end end
alias_method :limited_issues_count, :issues_count
def merge_requests_count def merge_requests_count
@merge_requests_count ||= merge_requests.total_count @merge_requests_count ||= merge_requests.total_count
end end
alias_method :limited_merge_requests_count, :merge_requests_count
def milestones_count def milestones_count
@milestones_count ||= milestones.total_count @milestones_count ||= milestones.total_count
end end
alias_method :limited_milestones_count, :milestones_count
def single_commit_result? def single_commit_result?
false false
......
require 'spec_helper' require 'spec_helper'
describe 'GlobalSearch' do describe 'GlobalSearch', :elastic do
let(:features) { %i(issues merge_requests repository builds wiki snippets) } let(:features) { %i(issues merge_requests repository builds wiki snippets) }
let(:admin) { create :user, admin: true } let(:admin) { create :user, admin: true }
let(:auditor) {create :user, auditor: true } let(:auditor) {create :user, auditor: true }
...@@ -12,18 +12,12 @@ describe 'GlobalSearch' do ...@@ -12,18 +12,12 @@ describe 'GlobalSearch' do
before do before do
stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true) stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true)
Gitlab::Elastic::Helper.create_empty_index
project.add_developer(member) project.add_developer(member)
project.add_developer(external_member) project.add_developer(external_member)
project.add_guest(guest) project.add_guest(guest)
end end
after do
Gitlab::Elastic::Helper.delete_index
stub_ee_application_setting(elasticsearch_search: false, elasticsearch_indexing: false)
end
context "Respect feature visibility levels" do context "Respect feature visibility levels" do
context "Private projects" do context "Private projects" do
let(:project) { create(:project, :private, :repository, :wiki_repo) } let(:project) { create(:project, :private, :repository, :wiki_repo) }
......
...@@ -11,10 +11,6 @@ describe 'Global elastic search', :elastic do ...@@ -11,10 +11,6 @@ describe 'Global elastic search', :elastic do
sign_in(user) sign_in(user)
end end
after do
stub_ee_application_setting(elasticsearch_search: false, elasticsearch_indexing: false)
end
shared_examples 'an efficient database result' do shared_examples 'an efficient database result' do
it 'avoids N+1 database queries' do it 'avoids N+1 database queries' do
create(object, creation_args) create(object, creation_args)
...@@ -209,4 +205,28 @@ describe 'Global elastic search', :elastic do ...@@ -209,4 +205,28 @@ describe 'Global elastic search', :elastic do
expect(page).to have_content(expected_message) expect(page).to have_content(expected_message)
end end
end end
describe 'I search globally', :js do
before do
create(:issue, project: project, title: 'project issue')
Gitlab::Elastic::Helper.refresh_index
visit dashboard_projects_path
fill_in('search', with: 'project')
find('#search').send_keys(:enter)
end
it 'displays result counts for all categories' do
expect(page).to have_content('Projects 1')
expect(page).to have_content('Issues 1')
expect(page).to have_content('Merge requests 0')
expect(page).to have_content('Milestones 0')
expect(page).to have_content('Comments 0')
expect(page).to have_content('Code 0')
expect(page).to have_content('Commits 0')
expect(page).to have_content('Wiki 0')
expect(page).to have_content('Users 0')
end
end
end end
require 'spec_helper' require 'spec_helper'
describe 'Group elastic search', :js do describe 'Group elastic search', :js, :elastic do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:group) { create(:group) } let(:group) { create(:group) }
let(:project) { create(:project, :repository, :wiki_repo, namespace: group) } let(:project) { create(:project, :repository, :wiki_repo, namespace: group) }
...@@ -16,7 +16,6 @@ describe 'Group elastic search', :js do ...@@ -16,7 +16,6 @@ describe 'Group elastic search', :js do
before do before do
stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true) stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true)
Gitlab::Elastic::Helper.create_empty_index
project.add_maintainer(user) project.add_maintainer(user)
group.add_owner(user) group.add_owner(user)
...@@ -24,11 +23,6 @@ describe 'Group elastic search', :js do ...@@ -24,11 +23,6 @@ describe 'Group elastic search', :js do
sign_in(user) sign_in(user)
end end
after do
Gitlab::Elastic::Helper.delete_index
stub_ee_application_setting(elasticsearch_search: false, elasticsearch_indexing: false)
end
describe 'issue search' do describe 'issue search' do
before do before do
create(:issue, project: project, title: 'chosen issue title') create(:issue, project: project, title: 'chosen issue title')
......
...@@ -31,7 +31,7 @@ describe Gitlab::Elastic::Indexer do ...@@ -31,7 +31,7 @@ describe Gitlab::Elastic::Indexer do
end end
context 'wikis' do context 'wikis' do
let!(:project) { create(:project, :wiki_repo) } let(:project) { create(:project, :wiki_repo) }
let(:indexer) { described_class.new(project, wiki: true) } let(:indexer) { described_class.new(project, wiki: true) }
before do before do
...@@ -66,6 +66,57 @@ describe Gitlab::Elastic::Indexer do ...@@ -66,6 +66,57 @@ describe Gitlab::Elastic::Indexer do
indexer.run indexer.run
end end
context 'when IndexStatus#last_wiki_commit is no longer in repository', :elastic do
let(:user) { project.owner }
let(:ee_application_setting) { nil }
before do
stub_ee_application_setting(elasticsearch_indexing: true)
ElasticIndexerWorker.new.perform('index', 'Project', project.id, project.es_id)
end
def change_wiki_and_index(project, &blk)
yield blk if blk
current_commit = project.wiki.repository.commit('master').sha
described_class.new(project, wiki: true).run(current_commit)
Gitlab::Elastic::Helper.refresh_index
end
def indexed_wiki_paths_for(term)
blobs = ProjectWiki.search(
term,
type: :wiki_blob
)[:wiki_blobs][:results].response
blobs.map do |blob|
blob['_source']['blob']['path']
end
end
it 'reindexes from scratch' do
sha_for_reset = nil
change_wiki_and_index(project) do
sha_for_reset = project.wiki.repository.create_file(user, '12', '', message: '12', branch_name: 'master')
project.wiki.repository.create_file(user, '23', '', message: '23', branch_name: 'master')
end
expect(indexed_wiki_paths_for('12')).to include('12')
expect(indexed_wiki_paths_for('23')).to include('23')
project.index_status.update!(last_wiki_commit: '____________')
change_wiki_and_index(project) do
project.wiki.repository.write_ref('master', sha_for_reset)
end
expect(indexed_wiki_paths_for('12')).to include('12')
expect(indexed_wiki_paths_for('23')).not_to include('23')
end
end
end end
context 'repository has unborn head' do context 'repository has unborn head' do
...@@ -250,10 +301,10 @@ describe Gitlab::Elastic::Indexer do ...@@ -250,10 +301,10 @@ describe Gitlab::Elastic::Indexer do
context 'when IndexStatus#last_commit is no longer in repository' do context 'when IndexStatus#last_commit is no longer in repository' do
before do before do
ElasticIndexerWorker.new.perform("index", "Project", project.id, project.es_id) ElasticIndexerWorker.new.perform('index', 'Project', project.id, project.es_id)
end end
it 'reindexes from scratch if IndexStatus#last_commit is no longer in repository' do it 'reindexes from scratch' do
sha_for_reset = nil sha_for_reset = nil
change_repository_and_index(project) do change_repository_and_index(project) do
...@@ -264,7 +315,7 @@ describe Gitlab::Elastic::Indexer do ...@@ -264,7 +315,7 @@ describe Gitlab::Elastic::Indexer do
expect(indexed_file_paths_for('12')).to include('12') expect(indexed_file_paths_for('12')).to include('12')
expect(indexed_file_paths_for('23')).to include('23') expect(indexed_file_paths_for('23')).to include('23')
project.index_status.update(last_commit: '____________') project.index_status.update!(last_commit: '____________')
change_repository_and_index(project) do change_repository_and_index(project) do
project.repository.write_ref('master', sha_for_reset) project.repository.write_ref('master', sha_for_reset)
......
require 'spec_helper' require 'spec_helper'
describe Gitlab::Elastic::ProjectSearchResults do describe Gitlab::Elastic::ProjectSearchResults, :elastic do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project, :repository) } let(:project) { create(:project, :repository) }
let(:query) { 'hello world' } let(:query) { 'hello world' }
before do before do
stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true) stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true)
Gitlab::Elastic::Helper.create_empty_index
end
after do
Gitlab::Elastic::Helper.delete_index
stub_ee_application_setting(elasticsearch_search: false, elasticsearch_indexing: false)
end end
describe 'initialize with empty ref' do describe 'initialize with empty ref' do
......
...@@ -6,10 +6,6 @@ describe Gitlab::Elastic::SearchResults, :elastic do ...@@ -6,10 +6,6 @@ describe Gitlab::Elastic::SearchResults, :elastic do
stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true) stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true)
end end
after do
stub_ee_application_setting(elasticsearch_search: false, elasticsearch_indexing: false)
end
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project_1) { create(:project, :repository, :wiki_repo) } let(:project_1) { create(:project, :repository, :wiki_repo) }
let(:project_2) { create(:project, :repository, :wiki_repo) } let(:project_2) { create(:project, :repository, :wiki_repo) }
...@@ -25,6 +21,36 @@ describe Gitlab::Elastic::SearchResults, :elastic do ...@@ -25,6 +21,36 @@ describe Gitlab::Elastic::SearchResults, :elastic do
end end
end end
describe '#formatted_count' do
using RSpec::Parameterized::TableSyntax
let(:results) { described_class.new(user, 'hello world', limit_project_ids) }
where(:scope, :count_method, :expected) do
'projects' | :projects_count | '1234'
'notes' | :notes_count | '1234'
'blobs' | :blobs_count | '1234'
'wiki_blobs' | :wiki_blobs_count | '1234'
'commits' | :commits_count | '1234'
'issues' | :issues_count | '1234'
'merge_requests' | :merge_requests_count | '1234'
'milestones' | :milestones_count | '1234'
'unknown' | nil | nil
end
with_them do
it 'returns the expected formatted count' do
expect(results).to receive(count_method).and_return(1234) if count_method
expect(results.formatted_count(scope)).to eq(expected)
end
end
it 'delegates to generic_search_results for users' do
expect(results.generic_search_results).to receive(:formatted_count).with('users').and_return('1000+')
expect(results.formatted_count('users')).to eq('1000+')
end
end
shared_examples_for 'a paginated object' do |object_type| shared_examples_for 'a paginated object' do |object_type|
let(:results) { described_class.new(user, 'hello world', limit_project_ids) } let(:results) { described_class.new(user, 'hello world', limit_project_ids) }
......
...@@ -36,12 +36,6 @@ describe API::Search do ...@@ -36,12 +36,6 @@ describe API::Search do
shared_examples 'elasticsearch enabled' do shared_examples 'elasticsearch enabled' do
before do before do
stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true) stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true)
Gitlab::Elastic::Helper.create_empty_index
end
after do
Gitlab::Elastic::Helper.delete_index
stub_ee_application_setting(elasticsearch_search: false, elasticsearch_indexing: false)
end end
context 'for wiki_blobs scope' do context 'for wiki_blobs scope' do
...@@ -119,7 +113,7 @@ describe API::Search do ...@@ -119,7 +113,7 @@ describe API::Search do
end end
end end
context 'when elasticsearch is enabled' do context 'when elasticsearch is enabled', :elastic do
it_behaves_like 'elasticsearch enabled' do it_behaves_like 'elasticsearch enabled' do
let(:endpoint) { '/search' } let(:endpoint) { '/search' }
end end
...@@ -135,7 +129,7 @@ describe API::Search do ...@@ -135,7 +129,7 @@ describe API::Search do
end end
end end
context 'when elasticsearch is enabled' do context 'when elasticsearch is enabled', :elastic do
it_behaves_like 'elasticsearch enabled' do it_behaves_like 'elasticsearch enabled' do
let(:endpoint) { "/groups/#{group.id}/-/search" } let(:endpoint) { "/groups/#{group.id}/-/search" }
end end
......
...@@ -29,6 +29,21 @@ module Gitlab ...@@ -29,6 +29,21 @@ module Gitlab
end end
end end
def formatted_count(scope)
case scope
when 'blobs'
blobs_count.to_s
when 'notes'
formatted_limited_count(limited_notes_count)
when 'wiki_blobs'
wiki_blobs_count.to_s
when 'commits'
commits_count.to_s
else
super
end
end
def users def users
super.where(id: @project.team.members) # rubocop:disable CodeReuse/ActiveRecord super.where(id: @project.team.members) # rubocop:disable CodeReuse/ActiveRecord
end end
......
...@@ -43,6 +43,29 @@ module Gitlab ...@@ -43,6 +43,29 @@ module Gitlab
without_count ? collection.without_count : collection without_count ? collection.without_count : collection
end end
def formatted_count(scope)
case scope
when 'projects'
formatted_limited_count(limited_projects_count)
when 'issues'
formatted_limited_count(limited_issues_count)
when 'merge_requests'
formatted_limited_count(limited_merge_requests_count)
when 'milestones'
formatted_limited_count(limited_milestones_count)
when 'users'
formatted_limited_count(limited_users_count)
end
end
def formatted_limited_count(count)
if count >= COUNT_LIMIT
"#{COUNT_LIMIT - 1}+"
else
count.to_s
end
end
def limited_projects_count def limited_projects_count
@limited_projects_count ||= limited_count(projects) @limited_projects_count ||= limited_count(projects)
end end
......
...@@ -22,6 +22,17 @@ module Gitlab ...@@ -22,6 +22,17 @@ module Gitlab
end end
end end
def formatted_count(scope)
case scope
when 'snippet_titles'
snippet_titles_count.to_s
when 'snippet_blobs'
snippet_blobs_count.to_s
else
super
end
end
def snippet_titles_count def snippet_titles_count
@snippet_titles_count ||= snippet_titles.count @snippet_titles_count ||= snippet_titles.count
end end
......
...@@ -11,151 +11,173 @@ describe SearchController do ...@@ -11,151 +11,173 @@ describe SearchController do
sign_in(user) sign_in(user)
end end
context 'uses the right partials depending on scope' do shared_examples_for 'when the user cannot read cross project' do |action, params|
using RSpec::Parameterized::TableSyntax
render_views
set(:project) { create(:project, :public, :repository, :wiki_repo) }
before do before do
expect(::Gitlab::GitalyClient).to receive(:allow_ref_name_caching).and_call_original allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?)
.with(user, :read_cross_project, :global) { false }
end end
subject { get(:show, params: { project_id: project.id, scope: scope, search: 'merge' }) } it 'blocks access without a project_id' do
get action, params: params
where(:partial, :scope) do expect(response).to have_gitlab_http_status(403)
'_blob' | :blobs
'_wiki_blob' | :wiki_blobs
'_commit' | :commits
end end
with_them do it 'allows access with a project_id' do
it do get action, params: params.merge(project_id: create(:project, :public).id)
project_wiki = create(:project_wiki, project: project, user: user)
create(:wiki_page, wiki: project_wiki, attrs: { title: 'merge', content: 'merge' })
expect(subject).to render_template("search/results/#{partial}") expect(response).to have_gitlab_http_status(200)
end
end end
end end
context 'global search' do shared_examples_for 'with external authorization service enabled' do |action, params|
render_views let(:project) { create(:project, namespace: user.namespace) }
let(:note) { create(:note_on_issue, project: project) }
it 'omits pipeline status from load' do
project = create(:project, :public)
expect(Gitlab::Cache::Ci::ProjectPipelineStatus).not_to receive(:load_in_batch_for_projects)
get :show, params: { scope: 'projects', search: project.name }
expect(assigns[:search_objects].first).to eq project before do
enable_external_authorization_service_check
end end
end
it 'finds issue comments' do
project = create(:project, :public)
note = create(:note_on_issue, project: project)
get :show, params: { project_id: project.id, scope: 'notes', search: note.note } it 'renders a 403 when no project is given' do
get action, params: params
expect(assigns[:search_objects].first).to eq note expect(response).to have_gitlab_http_status(403)
end
context 'when the user cannot read cross project' do
before do
allow(Ability).to receive(:allowed?).and_call_original
allow(Ability).to receive(:allowed?)
.with(user, :read_cross_project, :global) { false }
end end
it 'still allows accessing the search page' do it 'renders a 200 when a project was set' do
get :show get action, params: params.merge(project_id: project.id)
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
end end
end
it 'still blocks searches without a project_id' do describe 'GET #show' do
get :show, params: { search: 'hello' } it_behaves_like 'when the user cannot read cross project', :show, { search: 'hello' } do
it 'still allows accessing the search page' do
get :show
expect(response).to have_gitlab_http_status(403) expect(response).to have_gitlab_http_status(200)
end
end end
it 'allows searches with a project_id' do it_behaves_like 'with external authorization service enabled', :show, { search: 'hello' }
get :show, params: { search: 'hello', project_id: create(:project, :public).id }
expect(response).to have_gitlab_http_status(200) context 'uses the right partials depending on scope' do
end using RSpec::Parameterized::TableSyntax
end render_views
set(:project) { create(:project, :public, :repository, :wiki_repo) }
context 'on restricted projects' do
context 'when signed out' do
before do before do
sign_out(user) expect(::Gitlab::GitalyClient).to receive(:allow_ref_name_caching).and_call_original
end end
it "doesn't expose comments on issues" do subject { get(:show, params: { project_id: project.id, scope: scope, search: 'merge' }) }
project = create(:project, :public, :issues_private)
note = create(:note_on_issue, project: project)
get :show, params: { project_id: project.id, scope: 'notes', search: note.note } where(:partial, :scope) do
'_blob' | :blobs
'_wiki_blob' | :wiki_blobs
'_commit' | :commits
end
expect(assigns[:search_objects].count).to eq(0) with_them do
it do
project_wiki = create(:project_wiki, project: project, user: user)
create(:wiki_page, wiki: project_wiki, attrs: { title: 'merge', content: 'merge' })
expect(subject).to render_template("search/results/#{partial}")
end
end end
end end
it "doesn't expose comments on merge_requests" do context 'global search' do
project = create(:project, :public, :merge_requests_private) render_views
note = create(:note_on_merge_request, project: project)
get :show, params: { project_id: project.id, scope: 'notes', search: note.note } it 'omits pipeline status from load' do
project = create(:project, :public)
expect(Gitlab::Cache::Ci::ProjectPipelineStatus).not_to receive(:load_in_batch_for_projects)
get :show, params: { scope: 'projects', search: project.name }
expect(assigns[:search_objects].count).to eq(0) expect(assigns[:search_objects].first).to eq project
end
end end
it "doesn't expose comments on snippets" do it 'finds issue comments' do
project = create(:project, :public, :snippets_private) project = create(:project, :public)
note = create(:note_on_project_snippet, project: project) note = create(:note_on_issue, project: project)
get :show, params: { project_id: project.id, scope: 'notes', search: note.note } get :show, params: { project_id: project.id, scope: 'notes', search: note.note }
expect(assigns[:search_objects].count).to eq(0) expect(assigns[:search_objects].first).to eq note
end end
end
context 'with external authorization service enabled' do context 'on restricted projects' do
let(:project) { create(:project, namespace: user.namespace) } context 'when signed out' do
let(:note) { create(:note_on_issue, project: project) } before do
sign_out(user)
end
before do it "doesn't expose comments on issues" do
enable_external_authorization_service_check project = create(:project, :public, :issues_private)
end note = create(:note_on_issue, project: project)
describe 'GET #show' do get :show, params: { project_id: project.id, scope: 'notes', search: note.note }
it 'renders a 403 when no project is given' do
get :show, params: { scope: 'notes', search: note.note }
expect(response).to have_gitlab_http_status(403) expect(assigns[:search_objects].count).to eq(0)
end
end end
it 'renders a 200 when a project was set' do it "doesn't expose comments on merge_requests" do
project = create(:project, :public, :merge_requests_private)
note = create(:note_on_merge_request, project: project)
get :show, params: { project_id: project.id, scope: 'notes', search: note.note } get :show, params: { project_id: project.id, scope: 'notes', search: note.note }
expect(response).to have_gitlab_http_status(200) expect(assigns[:search_objects].count).to eq(0)
end end
end
describe 'GET #autocomplete' do it "doesn't expose comments on snippets" do
it 'renders a 403 when no project is given' do project = create(:project, :public, :snippets_private)
get :autocomplete, params: { term: 'hello' } note = create(:note_on_project_snippet, project: project)
expect(response).to have_gitlab_http_status(403) get :show, params: { project_id: project.id, scope: 'notes', search: note.note }
expect(assigns[:search_objects].count).to eq(0)
end end
end
end
it 'renders a 200 when a project was set' do describe 'GET #count' do
get :autocomplete, params: { project_id: project.id, term: 'hello' } it_behaves_like 'when the user cannot read cross project', :count, { search: 'hello', scope: 'projects' }
it_behaves_like 'with external authorization service enabled', :count, { search: 'hello', scope: 'projects' }
expect(response).to have_gitlab_http_status(200) it 'returns the result count for the given term and scope' do
end create(:project, :public, name: 'hello world')
create(:project, :public, name: 'foo bar')
get :count, params: { search: 'hello', scope: 'projects' }
expect(response).to have_gitlab_http_status(200)
expect(json_response).to eq({ 'count' => '1' })
end
it 'raises an error if search term is missing' do
expect do
get :count, params: { scope: 'projects' }
end.to raise_error(ActionController::ParameterMissing)
end end
it 'raises an error if search scope is missing' do
expect do
get :count, params: { search: 'hello' }
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
end end
...@@ -4,7 +4,7 @@ require 'spec_helper' ...@@ -4,7 +4,7 @@ require 'spec_helper'
describe 'User searches for users' do describe 'User searches for users' do
context 'when on the dashboard' do context 'when on the dashboard' do
it 'finds the user' do it 'finds the user', :js do
create(:user, username: 'gob_bluth', name: 'Gob Bluth') create(:user, username: 'gob_bluth', name: 'Gob Bluth')
sign_in(create(:user)) sign_in(create(:user))
...@@ -12,7 +12,7 @@ describe 'User searches for users' do ...@@ -12,7 +12,7 @@ describe 'User searches for users' do
visit dashboard_projects_path visit dashboard_projects_path
fill_in 'search', with: 'gob' fill_in 'search', with: 'gob'
click_button 'Go' find('#search').send_keys(:enter)
expect(page).to have_content('Users 1') expect(page).to have_content('Users 1')
......
...@@ -96,6 +96,23 @@ describe 'User uses header search field', :js do ...@@ -96,6 +96,23 @@ describe 'User uses header search field', :js do
let(:url) { root_path } let(:url) { root_path }
let(:scope_name) { 'All GitLab' } let(:scope_name) { 'All GitLab' }
end end
context 'when searching through the search field' do
before do
create(:issue, project: project, title: 'project issue')
fill_in('search', with: 'project')
find('#search').send_keys(:enter)
end
it 'displays result counts for all categories' do
expect(page).to have_content('Projects 1')
expect(page).to have_content('Issues 1')
expect(page).to have_content('Merge requests 0')
expect(page).to have_content('Milestones 0')
expect(page).to have_content('Users 0')
end
end
end end
context 'when user is in a project scope' do context 'when user is in a project scope' do
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`pages/search/show/refresh_counts fetches and displays search counts 1`] = `
"<div class=\\"badge\\">22</div>
<div class=\\"badge js-search-count\\" data-url=\\"http://test.host/search/count?search=lorem+ipsum&amp;project_id=3&amp;scope=issues\\">4</div>
<div class=\\"badge js-search-count\\" data-url=\\"http://test.host/search/count?search=lorem+ipsum&amp;project_id=3&amp;scope=merge_requests\\">5</div>"
`;
import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import axios from '~/lib/utils/axios_utils';
import refreshCounts from '~/pages/search/show/refresh_counts';
const URL = `${TEST_HOST}/search/count?search=lorem+ipsum&project_id=3`;
const urlWithScope = scope => `${URL}&scope=${scope}`;
const counts = [{ scope: 'issues', count: 4 }, { scope: 'merge_requests', count: 5 }];
const fixture = `<div class="badge">22</div>
<div class="badge js-search-count hidden" data-url="${urlWithScope('issues')}"></div>
<div class="badge js-search-count hidden" data-url="${urlWithScope('merge_requests')}"></div>`;
describe('pages/search/show/refresh_counts', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
setFixtures(fixture);
});
afterEach(() => {
mock.restore();
});
it('fetches and displays search counts', () => {
counts.forEach(({ scope, count }) => {
mock.onGet(urlWithScope(scope)).reply(200, { count });
});
// assert before act behavior
return refreshCounts().then(() => {
expect(document.body.innerHTML).toMatchSnapshot();
});
});
});
...@@ -177,4 +177,48 @@ describe SearchHelper do ...@@ -177,4 +177,48 @@ describe SearchHelper do
end end
end end
end end
describe 'search_filter_link' do
it 'renders a search filter link for the current scope' do
@scope = 'projects'
@search_results = double
expect(@search_results).to receive(:formatted_count).with('projects').and_return('23')
link = search_filter_link('projects', 'Projects')
expect(link).to have_css('li.active')
expect(link).to have_link('Projects', href: search_path(scope: 'projects'))
expect(link).to have_css('span.badge.badge-pill:not(.js-search-count):not(.hidden):not([data-url])', text: '23')
end
it 'renders a search filter link for another scope' do
link = search_filter_link('projects', 'Projects')
count_path = search_count_path(scope: 'projects')
expect(link).to have_css('li:not([class="active"])')
expect(link).to have_link('Projects', href: search_path(scope: 'projects'))
expect(link).to have_css("span.badge.badge-pill.js-search-count.hidden[data-url='#{count_path}']", text: '')
end
it 'merges in the current search params and given params' do
expect(self).to receive(:params).and_return(
ActionController::Parameters.new(
search: 'hello',
scope: 'ignored',
other_param: 'ignored'
)
)
link = search_filter_link('projects', 'Projects', search: { project_id: 23 })
expect(link).to have_link('Projects', href: search_path(scope: 'projects', search: 'hello', project_id: 23))
end
it 'assigns given data attributes on the list container' do
link = search_filter_link('projects', 'Projects', data: { foo: 'bar' })
expect(link).to have_css('li[data-foo="bar"]')
end
end
end end
...@@ -22,6 +22,28 @@ describe Gitlab::ProjectSearchResults do ...@@ -22,6 +22,28 @@ describe Gitlab::ProjectSearchResults do
it { expect(results.query).to eq('hello world') } it { expect(results.query).to eq('hello world') }
end end
describe '#formatted_count' do
using RSpec::Parameterized::TableSyntax
let(:results) { described_class.new(user, project, query) }
where(:scope, :count_method, :expected) do
'blobs' | :blobs_count | '1234'
'notes' | :limited_notes_count | '1000+'
'wiki_blobs' | :wiki_blobs_count | '1234'
'commits' | :commits_count | '1234'
'projects' | :limited_projects_count | '1000+'
'unknown' | nil | nil
end
with_them do
it 'returns the expected formatted count' do
expect(results).to receive(count_method).and_return(1234) if count_method
expect(results.formatted_count(scope)).to eq(expected)
end
end
end
shared_examples 'general blob search' do |entity_type, blob_kind| shared_examples 'general blob search' do |entity_type, blob_kind|
let(:query) { 'files' } let(:query) { 'files' }
subject(:results) { described_class.new(user, project, query).objects(blob_type) } subject(:results) { described_class.new(user, project, query).objects(blob_type) }
......
...@@ -29,6 +29,43 @@ describe Gitlab::SearchResults do ...@@ -29,6 +29,43 @@ describe Gitlab::SearchResults do
end end
end end
describe '#formatted_count' do
using RSpec::Parameterized::TableSyntax
where(:scope, :count_method, :expected) do
'projects' | :limited_projects_count | '1000+'
'issues' | :limited_issues_count | '1000+'
'merge_requests' | :limited_merge_requests_count | '1000+'
'milestones' | :limited_milestones_count | '1000+'
'users' | :limited_users_count | '1000+'
'unknown' | nil | nil
end
with_them do
it 'returns the expected formatted count' do
expect(results).to receive(count_method).and_return(1234) if count_method
expect(results.formatted_count(scope)).to eq(expected)
end
end
end
describe '#formatted_limited_count' do
using RSpec::Parameterized::TableSyntax
where(:count, :expected) do
23 | '23'
1000 | '1000'
1001 | '1000+'
1234 | '1000+'
end
with_them do
it 'returns the expected formatted limited count' do
expect(results.formatted_limited_count(count)).to eq(expected)
end
end
end
context "when count_limit is lower than total amount" do context "when count_limit is lower than total amount" do
before do before do
allow(results).to receive(:count_limit).and_return(1) allow(results).to receive(:count_limit).and_return(1)
......
...@@ -16,4 +16,22 @@ describe Gitlab::SnippetSearchResults do ...@@ -16,4 +16,22 @@ describe Gitlab::SnippetSearchResults do
expect(results.snippet_blobs_count).to eq(1) expect(results.snippet_blobs_count).to eq(1)
end end
end end
describe '#formatted_count' do
using RSpec::Parameterized::TableSyntax
where(:scope, :count_method, :expected) do
'snippet_titles' | :snippet_titles_count | '1234'
'snippet_blobs' | :snippet_blobs_count | '1234'
'projects' | :limited_projects_count | '1000+'
'unknown' | nil | nil
end
with_them do
it 'returns the expected formatted count' do
expect(results).to receive(count_method).and_return(1234) if count_method
expect(results.formatted_count(scope)).to eq(expected)
end
end
end
end end
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