Commit 68500fd7 authored by Dmitry Gruzd's avatar Dmitry Gruzd Committed by Dylan Griffith

Add Epics to search

This MR adds epics to basic (postgres) search. It's possible to search
text in the title and description fields
parent be658296
...@@ -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
......
...@@ -471,6 +471,9 @@ msgstr "" ...@@ -471,6 +471,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 ""
...@@ -22466,6 +22469,11 @@ msgid_plural "SearchResults|commits" ...@@ -22466,6 +22469,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