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
SCOPE_PRELOAD_METHOD = {
projects: :with_web_entity_associations,
issues: :with_web_entity_associations
issues: :with_web_entity_associations,
epics: :with_web_entity_associations
}.freeze
track_redis_hll_event :show, name: 'i_search_total', feature: :search_track_unique_users, feature_default_enabled: true
......
......@@ -8,8 +8,16 @@ module ApplicationHelper
# See https://docs.gitlab.com/ee/development/ee_features.html#code-in-app-views
# rubocop: disable CodeReuse/ActiveRecord
def render_if_exists(partial, locals = {})
render(partial, locals) if partial_exists?(partial)
# We allow partial to be nil so that collection views can be passed in
# `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
def partial_exists?(partial)
......
......@@ -25,6 +25,7 @@ module Search
strong_memoize(:allowed_scopes) do
allowed_scopes = %w[issues merge_requests milestones]
allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true)
allowed_scopes
end
end
......
......@@ -30,5 +30,6 @@
= search_filter_link 'issues', _("Issues")
= search_filter_link 'merge_requests', _("Merge requests")
= search_filter_link 'milestones', _("Milestones")
= render_if_exists 'search/epics_filter_link'
= render_if_exists 'search/category_elasticsearch'
= users
......@@ -32,7 +32,7 @@
.term
= render 'shared/projects/list', projects: @search_objects, pipeline_status: false
- else
= render partial: "search/results/#{@scope.singularize}", collection: @search_objects
= render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects
- if @scope != 'projects'
= 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
module SearchHelper
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
def search_filter_input_options(type, placeholder = _('Search or filter results...'))
......@@ -35,6 +35,16 @@ module EE
super + [{ category: "In this project", label: _("Feature Flags"), url: project_feature_flags_path(@project) }]
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.
# The scope used to gather the snippets is too wide and
# we have to process a lot of them, what leads to time outs.
......
......@@ -73,6 +73,8 @@ module EE
scope :has_parent, -> { where.not(parent_id: nil) }
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
where('start_date is not NULL or end_date is not NULL')
.where('start_date is NULL or start_date <= ?', end_date)
......@@ -233,6 +235,10 @@ module EE
items.where("epic_issues.epic_id": ids)
end
def search(query)
fuzzy_search(query, [:title, :description])
end
end
def resource_parent
......
......@@ -2,9 +2,11 @@
module Search
module Elasticsearchable
SCOPES_ONLY_BASIC_SEARCH = %w(users epics).freeze
def use_elasticsearch?
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)
end
......
......@@ -33,6 +33,19 @@ module EE
filters: { confidential: params[:confidential], state: params[:state] }
)
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
......@@ -19,5 +19,9 @@ module EE
super
end
def show_epics?
search_service.allowed_scopes.include?('epics')
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
module SearchResults
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
override :projects
def projects
super.with_compliance_framework_settings
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
# 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
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
subject.objects('projects').map { |project| project.compliance_framework_setting.framework }
end
......
......@@ -232,4 +232,32 @@ RSpec.describe Search::GroupService, :elastic do
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
......@@ -38,3 +38,5 @@ module Gitlab
end
end
end
Gitlab::GroupSearchResults.prepend_if_ee('EE::Gitlab::GroupSearchResults')
......@@ -29,21 +29,12 @@ module Gitlab
def objects(scope, page: nil, per_page: DEFAULT_PER_PAGE, without_count: true, preload_method: nil)
should_preload = preload_method.present?
collection = case scope
when 'projects'
projects
when 'issues'
issues
when 'merge_requests'
merge_requests
when 'milestones'
milestones
when 'users'
users
else
should_preload = false
Kaminari.paginate_array([])
end
collection = collection_for(scope)
if collection.nil?
should_preload = false
collection = Kaminari.paginate_array([])
end
collection = collection.public_send(preload_method) if should_preload # rubocop:disable GitlabSecurity/PublicSend
collection = collection.page(page).per(per_page)
......@@ -118,6 +109,21 @@ module Gitlab
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
limit_projects.search(query)
end
......
......@@ -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}."
msgstr ""
msgid "%{group_name}&%{epic_iid} &middot; opened %{epic_created} by %{author}"
msgstr ""
msgid "%{host} sign-in from new location"
msgstr ""
......@@ -22466,6 +22469,11 @@ msgid_plural "SearchResults|commits"
msgstr[0] ""
msgstr[1] ""
msgid "SearchResults|epic"
msgid_plural "SearchResults|epics"
msgstr[0] ""
msgstr[1] ""
msgid "SearchResults|issue"
msgid_plural "SearchResults|issues"
msgstr[0] ""
......
......@@ -444,7 +444,7 @@ RSpec.describe SearchService do
context 'with :with_api_entity_associations' do
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
......@@ -481,7 +481,7 @@ RSpec.describe SearchService do
end
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
......@@ -496,7 +496,7 @@ RSpec.describe SearchService do
end
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
......
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