Commit 07111104 authored by Dylan Griffith's avatar Dylan Griffith

Merge branch '4745-search-add-epics-scope' into 'master'

Add Epics to Basic Search

Closes #4745

See merge request gitlab-org/gitlab!42456
parents fb7fdc33 68500fd7
...@@ -8,7 +8,8 @@ class SearchController < ApplicationController ...@@ -8,7 +8,8 @@ class SearchController < ApplicationController
SCOPE_PRELOAD_METHOD = { SCOPE_PRELOAD_METHOD = {
projects: :with_web_entity_associations, projects: :with_web_entity_associations,
issues: :with_web_entity_associations issues: :with_web_entity_associations,
epics: :with_web_entity_associations
}.freeze }.freeze
track_redis_hll_event :show, name: 'i_search_total', feature: :search_track_unique_users, feature_default_enabled: true track_redis_hll_event :show, name: 'i_search_total', feature: :search_track_unique_users, feature_default_enabled: true
......
...@@ -8,8 +8,16 @@ module ApplicationHelper ...@@ -8,8 +8,16 @@ module ApplicationHelper
# See https://docs.gitlab.com/ee/development/ee_features.html#code-in-app-views # See https://docs.gitlab.com/ee/development/ee_features.html#code-in-app-views
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def render_if_exists(partial, locals = {}) # We allow partial to be nil so that collection views can be passed in
render(partial, locals) if partial_exists?(partial) # `render partial: 'some/view', collection: @some_collection`
def render_if_exists(partial = nil, **options)
return unless partial_exists?(partial || options[:partial])
if partial.nil?
render(**options)
else
render(partial, options)
end
end end
def partial_exists?(partial) def partial_exists?(partial)
......
...@@ -25,6 +25,7 @@ module Search ...@@ -25,6 +25,7 @@ module Search
strong_memoize(:allowed_scopes) do strong_memoize(:allowed_scopes) do
allowed_scopes = %w[issues merge_requests milestones] allowed_scopes = %w[issues merge_requests milestones]
allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true) allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true)
allowed_scopes
end end
end end
......
...@@ -30,5 +30,6 @@ ...@@ -30,5 +30,6 @@
= search_filter_link 'issues', _("Issues") = search_filter_link 'issues', _("Issues")
= search_filter_link 'merge_requests', _("Merge requests") = search_filter_link 'merge_requests', _("Merge requests")
= search_filter_link 'milestones', _("Milestones") = search_filter_link 'milestones', _("Milestones")
= render_if_exists 'search/epics_filter_link'
= render_if_exists 'search/category_elasticsearch' = render_if_exists 'search/category_elasticsearch'
= users = users
...@@ -32,7 +32,7 @@ ...@@ -32,7 +32,7 @@
.term .term
= render 'shared/projects/list', projects: @search_objects, pipeline_status: false = render 'shared/projects/list', projects: @search_objects, pipeline_status: false
- else - else
= render partial: "search/results/#{@scope.singularize}", collection: @search_objects = render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects
- if @scope != 'projects' - if @scope != 'projects'
= paginate_collection(@search_objects) = paginate_collection(@search_objects)
---
name: epics_search
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/42456
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/250317
group: group::global search
type: development
default_enabled: false
\ No newline at end of file
...@@ -3,7 +3,7 @@ module EE ...@@ -3,7 +3,7 @@ module EE
module SearchHelper module SearchHelper
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
SWITCH_TO_BASIC_SEARCHABLE_TABS = %w[projects issues merge_requests milestones users].freeze SWITCH_TO_BASIC_SEARCHABLE_TABS = %w[projects issues merge_requests milestones users epics].freeze
override :search_filter_input_options override :search_filter_input_options
def search_filter_input_options(type, placeholder = _('Search or filter results...')) def search_filter_input_options(type, placeholder = _('Search or filter results...'))
...@@ -35,6 +35,16 @@ module EE ...@@ -35,6 +35,16 @@ module EE
super + [{ category: "In this project", label: _("Feature Flags"), url: project_feature_flags_path(@project) }] super + [{ category: "In this project", label: _("Feature Flags"), url: project_feature_flags_path(@project) }]
end end
override :search_entries_scope_label
def search_entries_scope_label(scope, count)
case scope
when 'epics'
ns_('SearchResults|epic', 'SearchResults|epics', count)
else
super
end
end
# This is a special case for snippet searches in .com. # This is a special case for snippet searches in .com.
# The scope used to gather the snippets is too wide and # The scope used to gather the snippets is too wide and
# we have to process a lot of them, what leads to time outs. # we have to process a lot of them, what leads to time outs.
......
...@@ -73,6 +73,8 @@ module EE ...@@ -73,6 +73,8 @@ module EE
scope :has_parent, -> { where.not(parent_id: nil) } scope :has_parent, -> { where.not(parent_id: nil) }
scope :iid_starts_with, -> (query) { where("CAST(iid AS VARCHAR) LIKE ?", "#{sanitize_sql_like(query)}%") } scope :iid_starts_with, -> (query) { where("CAST(iid AS VARCHAR) LIKE ?", "#{sanitize_sql_like(query)}%") }
scope :with_web_entity_associations, -> { preload(:author, group: [:ip_restrictions, :route]) }
scope :within_timeframe, -> (start_date, end_date) do scope :within_timeframe, -> (start_date, end_date) do
where('start_date is not NULL or end_date is not NULL') where('start_date is not NULL or end_date is not NULL')
.where('start_date is NULL or start_date <= ?', end_date) .where('start_date is NULL or start_date <= ?', end_date)
...@@ -233,6 +235,10 @@ module EE ...@@ -233,6 +235,10 @@ module EE
items.where("epic_issues.epic_id": ids) items.where("epic_issues.epic_id": ids)
end end
def search(query)
fuzzy_search(query, [:title, :description])
end
end end
def resource_parent def resource_parent
......
...@@ -2,9 +2,11 @@ ...@@ -2,9 +2,11 @@
module Search module Search
module Elasticsearchable module Elasticsearchable
SCOPES_ONLY_BASIC_SEARCH = %w(users epics).freeze
def use_elasticsearch? def use_elasticsearch?
return false if params[:basic_search] return false if params[:basic_search]
return false if params[:scope] == 'users' return false if SCOPES_ONLY_BASIC_SEARCH.include?(params[:scope])
::Gitlab::CurrentSettings.search_using_elasticsearch?(scope: elasticsearchable_scope) ::Gitlab::CurrentSettings.search_using_elasticsearch?(scope: elasticsearchable_scope)
end end
......
...@@ -33,6 +33,19 @@ module EE ...@@ -33,6 +33,19 @@ module EE
filters: { confidential: params[:confidential], state: params[:state] } filters: { confidential: params[:confidential], state: params[:state] }
) )
end end
override :allowed_scopes
def allowed_scopes
strong_memoize(:ee_group_allowed_scopes) do
super.tap do |scopes|
if ::Feature.enabled?(:epics_search) && group.feature_available?(:epics)
scopes << 'epics'
else
scopes
end
end
end
end
end end
end end
end end
...@@ -19,5 +19,9 @@ module EE ...@@ -19,5 +19,9 @@ module EE
super super
end end
def show_epics?
search_service.allowed_scopes.include?('epics')
end
end end
end end
- if search_service.show_epics?
= search_filter_link 'epics', _("Epics")
%div{ class: 'search-result-row gl-pb-3! gl-mt-5 gl-mb-0!' }
%span.gl-display-flex.gl-align-items-center
- if epic.closed?
%span.badge.badge-info.badge-pill.gl-badge.sm= _("Closed")
- else
%span.badge.badge-success.badge-pill.gl-badge.sm= _("Open")
= sprite_icon('eye-slash', css_class: 'gl-text-gray-500 gl-ml-2') if epic.confidential?
= link_to group_epic_path(epic.group, epic), data: { track_event: 'click_text', track_label: 'epic_title', track_property: 'search_result' }, class: 'gl-w-full' do
%span.term.str-truncated.gl-font-weight-bold.gl-ml-2= epic.title
.gl-text-gray-500.gl-my-3
= sprintf(s_('%{group_name}&%{epic_iid} &middot; opened %{epic_created} by %{author}'), { group_name: epic.group.full_name, epic_iid: epic.iid, epic_created: time_ago_with_tooltip(epic.created_at, placement: 'bottom'), author: link_to_member(@project, epic.author, avatar: false) }).html_safe
- if epic.description.present?
.description.term.col-sm-10.gl-px-0
= truncate(epic.description, length: 200)
# frozen_string_literal: true
module EE
module Gitlab
module GroupSearchResults
extend ::Gitlab::Utils::Override
override :epics
def epics
EpicsFinder.new(current_user, issuable_params).execute.search(query)
end
end
end
end
...@@ -5,12 +5,42 @@ module EE ...@@ -5,12 +5,42 @@ module EE
module SearchResults module SearchResults
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
override :formatted_count
def formatted_count(scope)
case scope
when 'epics'
formatted_limited_count(limited_epics_count)
else
super
end
end
def epics
groups_finder = GroupsFinder.new(current_user)
::Epic.in_selected_groups(groups_finder.execute).search(query)
end
private private
override :projects override :projects
def projects def projects
super.with_compliance_framework_settings super.with_compliance_framework_settings
end end
override :collection_for
def collection_for(scope)
case scope
when 'epics'
epics
else
super
end
end
def limited_epics_count
@limited_epics_count ||= limited_count(epics)
end
end end
end end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'User searches for epics', :js do
let!(:user) { create(:user) }
let!(:group) { create(:group) }
let!(:epic1) { create(:epic, title: 'Foo', group: group) }
let!(:epic2) { create(:epic, :closed, :confidential, title: 'Bar', group: group) }
def search_for_epic(search)
fill_in('dashboard_search', with: search)
find('.btn-search').click
select_search_scope('Epics')
end
before do
stub_feature_flags(epics_search: true)
stub_licensed_features(epics: true)
group.add_maintainer(user)
sign_in(user)
visit(search_path(group_id: group.id))
end
include_examples 'top right search form'
it 'finds an epic' do
search_for_epic(epic1.title)
page.within('.results') do
expect(page).to have_link(epic1.title)
expect(page).not_to have_link(epic2.title)
end
end
it 'hides confidential icon for non-confidential epics' do
search_for_epic(epic1.title)
page.within('.results') do
expect(page).not_to have_css('[data-testid="eye-slash-icon"]')
end
end
it 'shows confidential icon for confidential epics' do
search_for_epic(epic2.title)
page.within('.results') do
expect(page).to have_css('[data-testid="eye-slash-icon"]')
end
end
it 'shows correct badge for open epics' do
search_for_epic(epic1.title)
page.within('.results') do
expect(page).to have_css('.badge-success')
expect(page).not_to have_css('.badge-info')
end
end
it 'shows correct badge for closed epics' do
search_for_epic(epic2.title)
page.within('.results') do
expect(page).not_to have_css('.badge-success')
expect(page).to have_css('.badge-info')
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::GroupSearchResults do
let!(:user) { build(:user) }
let!(:group) { create(:group) }
subject { described_class.new(user, query, group: group) }
describe '#epics' do
let(:query) { 'foo' }
let!(:searchable_epic) { create(:epic, title: 'foo', group: group) }
let!(:another_searchable_epic) { create(:epic, title: 'foo 2', group: group) }
let!(:another_epic) { create(:epic) }
before do
create(:group_member, group: group, user: user)
group.add_owner(user)
stub_licensed_features(epics: true)
end
it 'finds epics' do
expect(subject.objects('epics')).to match_array([searchable_epic, another_searchable_epic])
end
end
end
...@@ -24,6 +24,22 @@ RSpec.describe Gitlab::SearchResults do ...@@ -24,6 +24,22 @@ RSpec.describe Gitlab::SearchResults do
end end
end end
describe '#epics' do
let!(:group) { create(:group, :private) }
let!(:searchable_epic) { create(:epic, title: 'foo', group: group) }
let!(:another_group) { create(:group, :private) }
let!(:another_epic) { create(:epic, title: 'foo 2', group: another_group) }
before do
create(:group_member, group: group, user: user)
group.add_owner(user)
end
it 'finds epics' do
expect(subject.objects('epics')).to match_array([searchable_epic])
end
end
def search def search
subject.objects('projects').map { |project| project.compliance_framework_setting.framework } subject.objects('projects').map { |project| project.compliance_framework_setting.framework }
end end
......
...@@ -232,4 +232,32 @@ RSpec.describe Search::GroupService, :elastic do ...@@ -232,4 +232,32 @@ RSpec.describe Search::GroupService, :elastic do
end end
end end
end end
describe '#allowed_scopes' do
context 'epics scope' do
where(:feature_enabled, :epics_available, :epics_allowed) do
false | false | false
true | false | false
false | true | false
true | true | true
end
with_them do
let(:allowed_scopes) { described_class.new(user, group, {}).allowed_scopes }
before do
stub_feature_flags(epics_search: feature_enabled)
stub_licensed_features(epics: epics_available)
end
it 'sets correct allowed_scopes' do
if epics_allowed
expect(allowed_scopes).to include('epics')
else
expect(allowed_scopes).not_to include('epics')
end
end
end
end
end
end end
...@@ -38,3 +38,5 @@ module Gitlab ...@@ -38,3 +38,5 @@ module Gitlab
end end
end end
end end
Gitlab::GroupSearchResults.prepend_if_ee('EE::Gitlab::GroupSearchResults')
...@@ -29,21 +29,12 @@ module Gitlab ...@@ -29,21 +29,12 @@ module Gitlab
def objects(scope, page: nil, per_page: DEFAULT_PER_PAGE, without_count: true, preload_method: nil) def objects(scope, page: nil, per_page: DEFAULT_PER_PAGE, without_count: true, preload_method: nil)
should_preload = preload_method.present? should_preload = preload_method.present?
collection = case scope collection = collection_for(scope)
when 'projects'
projects if collection.nil?
when 'issues' should_preload = false
issues collection = Kaminari.paginate_array([])
when 'merge_requests' end
merge_requests
when 'milestones'
milestones
when 'users'
users
else
should_preload = false
Kaminari.paginate_array([])
end
collection = collection.public_send(preload_method) if should_preload # rubocop:disable GitlabSecurity/PublicSend collection = collection.public_send(preload_method) if should_preload # rubocop:disable GitlabSecurity/PublicSend
collection = collection.page(page).per(per_page) collection = collection.page(page).per(per_page)
...@@ -118,6 +109,21 @@ module Gitlab ...@@ -118,6 +109,21 @@ module Gitlab
private private
def collection_for(scope)
case scope
when 'projects'
projects
when 'issues'
issues
when 'merge_requests'
merge_requests
when 'milestones'
milestones
when 'users'
users
end
end
def projects def projects
limit_projects.search(query) limit_projects.search(query)
end end
......
...@@ -481,6 +481,9 @@ msgstr "" ...@@ -481,6 +481,9 @@ msgstr ""
msgid "%{group_name} uses group managed accounts. You need to create a new GitLab account which will be managed by %{group_name}." msgid "%{group_name} uses group managed accounts. You need to create a new GitLab account which will be managed by %{group_name}."
msgstr "" msgstr ""
msgid "%{group_name}&%{epic_iid} &middot; opened %{epic_created} by %{author}"
msgstr ""
msgid "%{host} sign-in from new location" msgid "%{host} sign-in from new location"
msgstr "" msgstr ""
...@@ -22467,6 +22470,11 @@ msgid_plural "SearchResults|commits" ...@@ -22467,6 +22470,11 @@ msgid_plural "SearchResults|commits"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "SearchResults|epic"
msgid_plural "SearchResults|epics"
msgstr[0] ""
msgstr[1] ""
msgid "SearchResults|issue" msgid "SearchResults|issue"
msgid_plural "SearchResults|issues" msgid_plural "SearchResults|issues"
msgstr[0] "" msgstr[0] ""
......
...@@ -444,7 +444,7 @@ RSpec.describe SearchService do ...@@ -444,7 +444,7 @@ RSpec.describe SearchService do
context 'with :with_api_entity_associations' do context 'with :with_api_entity_associations' do
let(:unredacted_results) { ar_relation(MergeRequest.with_api_entity_associations, readable, unreadable) } let(:unredacted_results) { ar_relation(MergeRequest.with_api_entity_associations, readable, unreadable) }
it_behaves_like "redaction limits N+1 queries", limit: 7 it_behaves_like "redaction limits N+1 queries", limit: 8
end end
end end
...@@ -481,7 +481,7 @@ RSpec.describe SearchService do ...@@ -481,7 +481,7 @@ RSpec.describe SearchService do
end end
context 'with :with_api_entity_associations' do context 'with :with_api_entity_associations' do
it_behaves_like "redaction limits N+1 queries", limit: 12 it_behaves_like "redaction limits N+1 queries", limit: 13
end end
end end
...@@ -496,7 +496,7 @@ RSpec.describe SearchService do ...@@ -496,7 +496,7 @@ RSpec.describe SearchService do
end end
context 'with :with_api_entity_associations' do context 'with :with_api_entity_associations' do
it_behaves_like "redaction limits N+1 queries", limit: 3 it_behaves_like "redaction limits N+1 queries", limit: 4
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