Commit 47859bb4 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'feature/users-search-results' into 'master'

Add users search results to global search

Closes #25973

See merge request gitlab-org/gitlab-ce!21197
parents e13f2ff6 27ac48c3
...@@ -29,6 +29,7 @@ class SearchController < ApplicationController ...@@ -29,6 +29,7 @@ class SearchController < ApplicationController
@search_objects = search_service.search_objects @search_objects = search_service.search_objects
render_commits if @scope == 'commits' render_commits if @scope == 'commits'
eager_load_user_status if @scope == 'users'
check_single_commit_result check_single_commit_result
end end
...@@ -54,6 +55,12 @@ class SearchController < ApplicationController ...@@ -54,6 +55,12 @@ class SearchController < ApplicationController
@search_objects = prepare_commits_for_rendering(@search_objects) @search_objects = prepare_commits_for_rendering(@search_objects)
end end
def eager_load_user_status
return if Feature.disabled?(:users_search, default_enabled: true)
@search_objects = @search_objects.eager_load(:status) # rubocop:disable CodeReuse/ActiveRecord
end
def check_single_commit_result def check_single_commit_result
if @search_results.single_commit_result? if @search_results.single_commit_result?
only_commit = @search_results.objects('commits').first only_commit = @search_results.objects('commits').first
......
...@@ -364,7 +364,8 @@ module ProjectsHelper ...@@ -364,7 +364,8 @@ module ProjectsHelper
blobs: :download_code, blobs: :download_code,
commits: :download_code, commits: :download_code,
merge_requests: :read_merge_request, merge_requests: :read_merge_request,
notes: [:read_merge_request, :download_code, :read_issue, :read_project_snippet] notes: [:read_merge_request, :download_code, :read_issue, :read_project_snippet],
members: :read_project_member
) )
end end
......
...@@ -201,4 +201,14 @@ module SearchHelper ...@@ -201,4 +201,14 @@ module SearchHelper
def limited_count(count, limit = 1000) def limited_count(count, limit = 1000)
count > limit ? "#{limit}+" : count count > limit ? "#{limit}+" : count
end end
def search_tabs?(tab)
return false if Feature.disabled?(:users_search, default_enabled: true)
if @project
project_search_tabs?(:members)
else
can?(current_user, :read_users_list)
end
end
end end
...@@ -23,7 +23,8 @@ module Search ...@@ -23,7 +23,8 @@ module Search
def allowed_scopes def allowed_scopes
strong_memoize(:allowed_scopes) do strong_memoize(:allowed_scopes) do
%w[issues merge_requests milestones] allowed_scopes = %w[issues merge_requests milestones]
allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true)
end end
end end
......
...@@ -11,6 +11,12 @@ module Search ...@@ -11,6 +11,12 @@ module Search
@group = group @group = group
end end
def execute
Gitlab::GroupSearchResults.new(
current_user, projects, group, params[:search], default_project_filter: default_project_filter
)
end
def projects def projects
return Project.none unless group return Project.none unless group
return @projects if defined? @projects return @projects if defined? @projects
......
...@@ -16,7 +16,12 @@ module Search ...@@ -16,7 +16,12 @@ module Search
end end
def scope def scope
@scope ||= %w[notes issues merge_requests milestones wiki_blobs commits].delete(params[:scope]) { 'blobs' } @scope ||= begin
allowed_scopes = %w[notes issues merge_requests milestones wiki_blobs commits]
allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true)
allowed_scopes.delete(params[:scope]) { 'blobs' }
end
end end
end end
end end
- users = capture_haml do
- if search_tabs?(:members)
%li{ class: active_when(@scope == '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')
.fade-right= icon('angle-right') .fade-right= icon('angle-right')
...@@ -45,6 +53,7 @@ ...@@ -45,6 +53,7 @@
= _("Commits") = _("Commits")
%span.badge.badge-pill %span.badge.badge-pill
= @search_results.commits_count = @search_results.commits_count
= users
- elsif @show_snippets - elsif @show_snippets
%li{ class: active_when(@scope == 'snippet_blobs') } %li{ class: active_when(@scope == 'snippet_blobs') }
...@@ -78,3 +87,4 @@ ...@@ -78,3 +87,4 @@
= _("Milestones") = _("Milestones")
%span.badge.badge-pill %span.badge.badge-pill
= limited_count(@search_results.limited_milestones_count) = limited_count(@search_results.limited_milestones_count)
= users
%ul.content-list
%li
.avatar-cell.d-none.d-sm-block
= user_avatar(user: user, user_name: user.name, css_class: 'd-none d-sm-inline avatar s40')
.user-info
= link_to user_path(user), class: 'd-none d-sm-inline' do
.item-title
= user.name
= user_status(user)
.cgray= user.to_reference
---
title: Add users search results to global search
merge_request: 21197
author: Alexis Reigel
type: added
...@@ -17,7 +17,7 @@ GET /search ...@@ -17,7 +17,7 @@ GET /search
| `scope` | string | yes | The scope to search in | | `scope` | string | yes | The scope to search in |
| `search` | string | yes | The search query | | `search` | string | yes | The search query |
Search the expression within the specified scope. Currently these scopes are supported: projects, issues, merge_requests, milestones, snippet_titles, snippet_blobs. Search the expression within the specified scope. Currently these scopes are supported: projects, issues, merge_requests, milestones, snippet_titles, snippet_blobs, users.
The response depends on the requested scope. The response depends on the requested scope.
...@@ -281,6 +281,27 @@ Example response: ...@@ -281,6 +281,27 @@ Example response:
] ]
``` ```
### Scope: users
```bash
curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/search?scope=users&search=doe
```
Example response:
```json
[
{
"id": 1,
"name": "John Doe1",
"username": "user1",
"state": "active",
"avatar_url": "http://www.gravatar.com/avatar/c922747a93b40d1ea88262bf1aebee62?s=80&d=identicon",
"web_url": "http://localhost/user1"
}
]
```
## Group Search API ## Group Search API
Search within the specified group. Search within the specified group.
...@@ -297,7 +318,7 @@ GET /groups/:id/search ...@@ -297,7 +318,7 @@ GET /groups/:id/search
| `scope` | string | yes | The scope to search in | | `scope` | string | yes | The scope to search in |
| `search` | string | yes | The search query | | `search` | string | yes | The search query |
Search the expression within the specified scope. Currently these scopes are supported: projects, issues, merge_requests, milestones. Search the expression within the specified scope. Currently these scopes are supported: projects, issues, merge_requests, milestones, users.
The response depends on the requested scope. The response depends on the requested scope.
...@@ -499,6 +520,27 @@ Example response: ...@@ -499,6 +520,27 @@ Example response:
] ]
``` ```
### Scope: users
```bash
curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/3/search?scope=users&search=doe
```
Example response:
```json
[
{
"id": 1,
"name": "John Doe1",
"username": "user1",
"state": "active",
"avatar_url": "http://www.gravatar.com/avatar/c922747a93b40d1ea88262bf1aebee62?s=80&d=identicon",
"web_url": "http://localhost/user1"
}
]
```
## Project Search API ## Project Search API
Search within the specified project. Search within the specified project.
...@@ -515,7 +557,7 @@ GET /projects/:id/search ...@@ -515,7 +557,7 @@ GET /projects/:id/search
| `scope` | string | yes | The scope to search in | | `scope` | string | yes | The scope to search in |
| `search` | string | yes | The search query | | `search` | string | yes | The search query |
Search the expression within the specified scope. Currently these scopes are supported: issues, merge_requests, milestones, notes, wiki_blobs, commits, blobs. Search the expression within the specified scope. Currently these scopes are supported: issues, merge_requests, milestones, notes, wiki_blobs, commits, blobs, users.
The response depends on the requested scope. The response depends on the requested scope.
...@@ -828,4 +870,25 @@ Example response: ...@@ -828,4 +870,25 @@ Example response:
] ]
``` ```
### Scope: users
```bash
curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/6/search?scope=users&search=doe
```
Example response:
```json
[
{
"id": 1,
"name": "John Doe1",
"username": "user1",
"state": "active",
"avatar_url": "http://www.gravatar.com/avatar/c922747a93b40d1ea88262bf1aebee62?s=80&d=identicon",
"web_url": "http://localhost/user1"
}
]
```
[ce-41763]: https://gitlab.com/gitlab-org/gitlab-ce/issues/41763 [ce-41763]: https://gitlab.com/gitlab-org/gitlab-ce/issues/41763
...@@ -5,17 +5,17 @@ module API ...@@ -5,17 +5,17 @@ module API
module SearchHelpers module SearchHelpers
def self.global_search_scopes def self.global_search_scopes
# This is a separate method so that EE can redefine it. # This is a separate method so that EE can redefine it.
%w(projects issues merge_requests milestones snippet_titles snippet_blobs) %w(projects issues merge_requests milestones snippet_titles snippet_blobs users)
end end
def self.group_search_scopes def self.group_search_scopes
# This is a separate method so that EE can redefine it. # This is a separate method so that EE can redefine it.
%w(projects issues merge_requests milestones) %w(projects issues merge_requests milestones users)
end end
def self.project_search_scopes def self.project_search_scopes
# This is a separate method so that EE can redefine it. # This is a separate method so that EE can redefine it.
%w(issues merge_requests milestones notes wiki_blobs commits blobs) %w(issues merge_requests milestones notes wiki_blobs commits blobs users)
end end
end end
end end
......
...@@ -17,7 +17,8 @@ module API ...@@ -17,7 +17,8 @@ module API
blobs: Entities::Blob, blobs: Entities::Blob,
wiki_blobs: Entities::Blob, wiki_blobs: Entities::Blob,
snippet_titles: Entities::Snippet, snippet_titles: Entities::Snippet,
snippet_blobs: Entities::Snippet snippet_blobs: Entities::Snippet,
users: Entities::UserBasic
}.freeze }.freeze
def search(additional_params = {}) def search(additional_params = {})
...@@ -51,6 +52,12 @@ module API ...@@ -51,6 +52,12 @@ module API
# Defining this method here as a noop allows us to easily extend it in # Defining this method here as a noop allows us to easily extend it in
# EE, without having to modify this file directly. # EE, without having to modify this file directly.
end end
def check_users_search_allowed!
if params[:scope].to_sym == :users && Feature.disabled?(:users_search, default_enabled: true)
render_api_error!({ error: _("Scope not supported with disabled 'users_search' feature!") }, 400)
end
end
end end
resource :search do resource :search do
...@@ -67,6 +74,7 @@ module API ...@@ -67,6 +74,7 @@ module API
end end
get do get do
verify_search_scope! verify_search_scope!
check_users_search_allowed!
present search, with: entity present search, with: entity
end end
...@@ -87,6 +95,7 @@ module API ...@@ -87,6 +95,7 @@ module API
end end
get ':id/(-/)search' do get ':id/(-/)search' do
verify_search_scope! verify_search_scope!
check_users_search_allowed!
present search(group_id: user_group.id), with: entity present search(group_id: user_group.id), with: entity
end end
...@@ -106,6 +115,8 @@ module API ...@@ -106,6 +115,8 @@ module API
use :pagination use :pagination
end end
get ':id/(-/)search' do get ':id/(-/)search' do
check_users_search_allowed!
present search(project_id: user_project.id), with: entity present search(project_id: user_project.id), with: entity
end end
end end
......
# frozen_string_literal: true
module Gitlab
class GroupSearchResults < SearchResults
def initialize(current_user, limit_projects, group, query, default_project_filter: false, per_page: 20)
super(current_user, limit_projects, query, default_project_filter: default_project_filter, per_page: per_page)
@group = group
end
# rubocop:disable CodeReuse/ActiveRecord
def users
# 1: get all groups the current user has access to
groups = GroupsFinder.new(current_user).execute.joins(:users)
# 2: Get the group's whole hierarchy
group_users = @group.direct_and_indirect_users
# 3: get all users the current user has access to (->
# `SearchResults#users`), which also applies the query.
users = super
# 4: filter for users that belong to the previously selected groups
users
.where(id: group_users.select('id'))
.where(id: groups.select('members.user_id'))
end
# rubocop:enable CodeReuse/ActiveRecord
end
end
...@@ -22,11 +22,17 @@ module Gitlab ...@@ -22,11 +22,17 @@ module Gitlab
paginated_blobs(wiki_blobs, page) paginated_blobs(wiki_blobs, page)
when 'commits' when 'commits'
Kaminari.paginate_array(commits).page(page).per(per_page) Kaminari.paginate_array(commits).page(page).per(per_page)
when 'users'
users.page(page).per(per_page)
else else
super(scope, page, false) super(scope, page, false)
end end
end end
def users
super.where(id: @project.team.members) # rubocop:disable CodeReuse/ActiveRecord
end
def blobs_count def blobs_count
@blobs_count ||= blobs.count @blobs_count ||= blobs.count
end end
......
...@@ -32,6 +32,8 @@ module Gitlab ...@@ -32,6 +32,8 @@ module Gitlab
merge_requests.page(page).per(per_page) merge_requests.page(page).per(per_page)
when 'milestones' when 'milestones'
milestones.page(page).per(per_page) milestones.page(page).per(per_page)
when 'users'
users.page(page).per(per_page)
else else
Kaminari.paginate_array([]).page(page).per(per_page) Kaminari.paginate_array([]).page(page).per(per_page)
end end
...@@ -71,6 +73,12 @@ module Gitlab ...@@ -71,6 +73,12 @@ module Gitlab
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
# rubocop:disable CodeReuse/ActiveRecord
def limited_users_count
@limited_users_count ||= users.limit(count_limit).count
end
# rubocop:enable CodeReuse/ActiveRecord
def single_commit_result? def single_commit_result?
false false
end end
...@@ -79,6 +87,12 @@ module Gitlab ...@@ -79,6 +87,12 @@ module Gitlab
1001 1001
end end
def users
return User.none unless Ability.allowed?(current_user, :read_users_list)
UsersFinder.new(current_user, search: query).execute
end
private private
def projects def projects
......
...@@ -6719,6 +6719,9 @@ msgstr "" ...@@ -6719,6 +6719,9 @@ msgstr ""
msgid "Scope" msgid "Scope"
msgstr "" msgstr ""
msgid "Scope not supported with disabled 'users_search' feature!"
msgstr ""
msgid "Scroll down to <strong>Google Code Project Hosting</strong> and enable the switch on the right." msgid "Scroll down to <strong>Google Code Project Hosting</strong> and enable the switch on the right."
msgstr "" msgstr ""
......
require 'spec_helper'
describe 'User searches for users' do
context 'when on the dashboard' do
it 'finds the user' do
create(:user, username: 'gob_bluth', name: 'Gob Bluth')
sign_in(create(:user))
visit dashboard_projects_path
fill_in 'search', with: 'gob'
click_button 'Go'
expect(page).to have_content('Users 1')
click_on('Users 1')
expect(page).to have_content('Gob Bluth')
expect(page).to have_content('@gob_bluth')
end
end
context 'when on the project page' do
it 'finds the user belonging to the project' do
project = create(:project)
user1 = create(:user, username: 'gob_bluth', name: 'Gob Bluth')
create(:project_member, :developer, user: user1, project: project)
user2 = create(:user, username: 'michael_bluth', name: 'Michael Bluth')
create(:project_member, :developer, user: user2, project: project)
create(:user, username: 'gob_2018', name: 'George Oscar Bluth')
sign_in(user1)
visit projects_path(project)
fill_in 'search', with: 'gob'
click_button 'Go'
expect(page).to have_content('Gob Bluth')
expect(page).to have_content('@gob_bluth')
expect(page).not_to have_content('Michael Bluth')
expect(page).not_to have_content('@michael_bluth')
expect(page).not_to have_content('George Oscar Bluth')
expect(page).not_to have_content('@gob_2018')
end
end
context 'when on the group page' do
it 'finds the user belonging to the group' do
group = create(:group)
user1 = create(:user, username: 'gob_bluth', name: 'Gob Bluth')
create(:group_member, :developer, user: user1, group: group)
user2 = create(:user, username: 'michael_bluth', name: 'Michael Bluth')
create(:group_member, :developer, user: user2, group: group)
create(:user, username: 'gob_2018', name: 'George Oscar Bluth')
sign_in(user1)
visit group_path(group)
fill_in 'search', with: 'gob'
click_button 'Go'
expect(page).to have_content('Gob Bluth')
expect(page).to have_content('@gob_bluth')
expect(page).not_to have_content('Michael Bluth')
expect(page).not_to have_content('@michael_bluth')
expect(page).not_to have_content('George Oscar Bluth')
expect(page).not_to have_content('@gob_2018')
end
end
end
require 'spec_helper'
describe Gitlab::GroupSearchResults do
let(:user) { create(:user) }
describe 'user search' do
let(:group) { create(:group) }
it 'returns the users belonging to the group matching the search query' do
user1 = create(:user, username: 'gob_bluth')
create(:group_member, :developer, user: user1, group: group)
user2 = create(:user, username: 'michael_bluth')
create(:group_member, :developer, user: user2, group: group)
create(:user, username: 'gob_2018')
result = described_class.new(user, anything, group, 'gob').objects('users')
expect(result).to eq [user1]
end
it 'returns the user belonging to the subgroup matching the search query', :nested_groups do
user1 = create(:user, username: 'gob_bluth')
subgroup = create(:group, parent: group)
create(:group_member, :developer, user: user1, group: subgroup)
create(:user, username: 'gob_2018')
result = described_class.new(user, anything, group, 'gob').objects('users')
expect(result).to eq [user1]
end
it 'returns the user belonging to the parent group matching the search query', :nested_groups do
user1 = create(:user, username: 'gob_bluth')
parent_group = create(:group, children: [group])
create(:group_member, :developer, user: user1, group: parent_group)
create(:user, username: 'gob_2018')
result = described_class.new(user, anything, group, 'gob').objects('users')
expect(result).to eq [user1]
end
it 'does not return the user belonging to the private subgroup', :nested_groups do
user1 = create(:user, username: 'gob_bluth')
subgroup = create(:group, :private, parent: group)
create(:group_member, :developer, user: user1, group: subgroup)
create(:user, username: 'gob_2018')
result = described_class.new(user, anything, group, 'gob').objects('users')
expect(result).to eq []
end
it 'does not return the user belonging to an unrelated group' do
user = create(:user, username: 'gob_bluth')
unrelated_group = create(:group)
create(:group_member, :developer, user: user, group: unrelated_group)
result = described_class.new(user, anything, group, 'gob').objects('users')
expect(result).to eq []
end
end
end
...@@ -412,4 +412,36 @@ describe Gitlab::ProjectSearchResults do ...@@ -412,4 +412,36 @@ describe Gitlab::ProjectSearchResults do
end end
end end
end end
describe 'user search' do
it 'returns the user belonging to the project matching the search query' do
project = create(:project)
user1 = create(:user, username: 'gob_bluth')
create(:project_member, :developer, user: user1, project: project)
user2 = create(:user, username: 'michael_bluth')
create(:project_member, :developer, user: user2, project: project)
create(:user, username: 'gob_2018')
result = described_class.new(user, project, 'gob').objects('users')
expect(result).to eq [user1]
end
it 'returns the user belonging to the group matching the search query' do
group = create(:group)
project = create(:project, namespace: group)
user1 = create(:user, username: 'gob_bluth')
create(:group_member, :developer, user: user1, group: group)
create(:user, username: 'gob_2018')
result = described_class.new(user, project, 'gob').objects('users')
expect(result).to eq [user1]
end
end
end end
...@@ -121,6 +121,22 @@ describe Gitlab::SearchResults do ...@@ -121,6 +121,22 @@ describe Gitlab::SearchResults do
results.objects('issues') results.objects('issues')
end end
end end
describe '#users' do
it 'does not call the UsersFinder when the current_user is not allowed to read users list' do
allow(Ability).to receive(:allowed?).and_return(false)
expect(UsersFinder).not_to receive(:new).with(user, search: 'foo').and_call_original
results.objects('users')
end
it 'calls the UsersFinder' do
expect(UsersFinder).to receive(:new).with(user, search: 'foo').and_call_original
results.objects('users')
end
end
end end
it 'does not list issues on private projects' do it 'does not list issues on private projects' do
......
...@@ -77,6 +77,28 @@ describe API::Search do ...@@ -77,6 +77,28 @@ describe API::Search do
it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
end end
context 'for users scope' do
before do
create(:user, name: 'billy')
get api('/search', user), params: { scope: 'users', search: 'billy' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/user/basics'
context 'when users search feature is disabled' do
before do
allow(Feature).to receive(:disabled?).with(:users_search, default_enabled: true).and_return(true)
get api('/search', user), params: { scope: 'users', search: 'billy' }
end
it 'returns 400 error' do
expect(response).to have_gitlab_http_status(400)
end
end
end
context 'for snippet_titles scope' do context 'for snippet_titles scope' do
before do before do
create(:snippet, :public, title: 'awesome snippet', content: 'snippet content') create(:snippet, :public, title: 'awesome snippet', content: 'snippet content')
...@@ -192,6 +214,40 @@ describe API::Search do ...@@ -192,6 +214,40 @@ describe API::Search do
it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
end end
context 'for users scope' do
before do
user = create(:user, name: 'billy')
create(:group_member, :developer, user: user, group: group)
get api("/groups/#{group.id}/search", user), params: { scope: 'users', search: 'billy' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/user/basics'
context 'when users search feature is disabled' do
before do
allow(Feature).to receive(:disabled?).with(:users_search, default_enabled: true).and_return(true)
get api("/groups/#{group.id}/search", user), params: { scope: 'users', search: 'billy' }
end
it 'returns 400 error' do
expect(response).to have_gitlab_http_status(400)
end
end
end
context 'for users scope with group path as id' do
before do
user1 = create(:user, name: 'billy')
create(:group_member, :developer, user: user1, group: group)
get api("/groups/#{CGI.escape(group.full_path)}/search", user), params: { scope: 'users', search: 'billy' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/user/basics'
end
end end
end end
...@@ -269,6 +325,29 @@ describe API::Search do ...@@ -269,6 +325,29 @@ describe API::Search do
it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' it_behaves_like 'response is correct', schema: 'public_api/v4/milestones'
end end
context 'for users scope' do
before do
user1 = create(:user, name: 'billy')
create(:project_member, :developer, user: user1, project: project)
get api("/projects/#{project.id}/search", user), params: { scope: 'users', search: 'billy' }
end
it_behaves_like 'response is correct', schema: 'public_api/v4/user/basics'
context 'when users search feature is disabled' do
before do
allow(Feature).to receive(:disabled?).with(:users_search, default_enabled: true).and_return(true)
get api("/projects/#{project.id}/search", user), params: { scope: 'users', search: 'billy' }
end
it 'returns 400 error' do
expect(response).to have_gitlab_http_status(400)
end
end
end
context 'for notes scope' do context 'for notes scope' do
before do before do
create(:note_on_merge_request, project: project, note: 'awesome note') create(:note_on_merge_request, project: project, note: 'awesome note')
......
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