Commit e830cace authored by Luke Duncalfe's avatar Luke Duncalfe

Merge branch '277065-epic-search-in-issues-inconsistent' into 'master'

Allow epics search in specific fields with GraphQL

See merge request gitlab-org/gitlab!64184
parents ab1b772f d16bb2da
# frozen_string_literal: true
module Types
class IssuableSearchableFieldEnum < BaseEnum
graphql_name 'IssuableSearchableField'
description 'Fields to perform the search in'
Issuable::SEARCHABLE_FIELDS.each do |field|
value field.upcase, value: field, description: "Search in #{field} field."
end
end
end
......@@ -31,6 +31,7 @@ module Issuable
TITLE_HTML_LENGTH_MAX = 800
DESCRIPTION_LENGTH_MAX = 1.megabyte
DESCRIPTION_HTML_LENGTH_MAX = 5.megabytes
SEARCHABLE_FIELDS = %w(title description).freeze
STATE_ID_MAP = {
opened: 1,
......@@ -264,15 +265,16 @@ module Issuable
# matched_columns - Modify the scope of the query. 'title', 'description' or joining them with a comma.
#
# Returns an ActiveRecord::Relation.
def full_search(query, matched_columns: 'title,description', use_minimum_char_limit: true)
allowed_columns = [:title, :description]
matched_columns = matched_columns.to_s.split(',').map(&:to_sym)
matched_columns &= allowed_columns
def full_search(query, matched_columns: nil, use_minimum_char_limit: true)
if matched_columns
matched_columns = matched_columns.to_s.split(',')
matched_columns &= SEARCHABLE_FIELDS
matched_columns.map!(&:to_sym)
end
# Matching title or description if the matched_columns did not contain any allowed columns.
matched_columns = [:title, :description] if matched_columns.empty?
search_columns = matched_columns.presence || [:title, :description]
fuzzy_search(query, matched_columns, use_minimum_char_limit: use_minimum_char_limit)
fuzzy_search(query, search_columns, use_minimum_char_limit: use_minimum_char_limit)
end
def simple_sorts
......
......@@ -7399,6 +7399,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="boardepicancestorsiid"></a>`iid` | [`ID`](#id) | IID of the epic, e.g., "1". |
| <a id="boardepicancestorsiidstartswith"></a>`iidStartsWith` | [`String`](#string) | Filter epics by IID for autocomplete. |
| <a id="boardepicancestorsiids"></a>`iids` | [`[ID!]`](#id) | List of IIDs of epics, e.g., `[1, 2]`. |
| <a id="boardepicancestorsin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument. |
| <a id="boardepicancestorsincludeancestorgroups"></a>`includeAncestorGroups` | [`Boolean`](#boolean) | Include epics from ancestor groups. |
| <a id="boardepicancestorsincludedescendantgroups"></a>`includeDescendantGroups` | [`Boolean`](#boolean) | Include epics from descendant groups. |
| <a id="boardepicancestorslabelname"></a>`labelName` | [`[String!]`](#string) | Filter epics by labels. |
......@@ -7431,6 +7432,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="boardepicchildreniid"></a>`iid` | [`ID`](#id) | IID of the epic, e.g., "1". |
| <a id="boardepicchildreniidstartswith"></a>`iidStartsWith` | [`String`](#string) | Filter epics by IID for autocomplete. |
| <a id="boardepicchildreniids"></a>`iids` | [`[ID!]`](#id) | List of IIDs of epics, e.g., `[1, 2]`. |
| <a id="boardepicchildrenin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument. |
| <a id="boardepicchildrenincludeancestorgroups"></a>`includeAncestorGroups` | [`Boolean`](#boolean) | Include epics from ancestor groups. |
| <a id="boardepicchildrenincludedescendantgroups"></a>`includeDescendantGroups` | [`Boolean`](#boolean) | Include epics from descendant groups. |
| <a id="boardepicchildrenlabelname"></a>`labelName` | [`[String!]`](#string) | Filter epics by labels. |
......@@ -8621,6 +8623,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="epicancestorsiid"></a>`iid` | [`ID`](#id) | IID of the epic, e.g., "1". |
| <a id="epicancestorsiidstartswith"></a>`iidStartsWith` | [`String`](#string) | Filter epics by IID for autocomplete. |
| <a id="epicancestorsiids"></a>`iids` | [`[ID!]`](#id) | List of IIDs of epics, e.g., `[1, 2]`. |
| <a id="epicancestorsin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument. |
| <a id="epicancestorsincludeancestorgroups"></a>`includeAncestorGroups` | [`Boolean`](#boolean) | Include epics from ancestor groups. |
| <a id="epicancestorsincludedescendantgroups"></a>`includeDescendantGroups` | [`Boolean`](#boolean) | Include epics from descendant groups. |
| <a id="epicancestorslabelname"></a>`labelName` | [`[String!]`](#string) | Filter epics by labels. |
......@@ -8653,6 +8656,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="epicchildreniid"></a>`iid` | [`ID`](#id) | IID of the epic, e.g., "1". |
| <a id="epicchildreniidstartswith"></a>`iidStartsWith` | [`String`](#string) | Filter epics by IID for autocomplete. |
| <a id="epicchildreniids"></a>`iids` | [`[ID!]`](#id) | List of IIDs of epics, e.g., `[1, 2]`. |
| <a id="epicchildrenin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument. |
| <a id="epicchildrenincludeancestorgroups"></a>`includeAncestorGroups` | [`Boolean`](#boolean) | Include epics from ancestor groups. |
| <a id="epicchildrenincludedescendantgroups"></a>`includeDescendantGroups` | [`Boolean`](#boolean) | Include epics from descendant groups. |
| <a id="epicchildrenlabelname"></a>`labelName` | [`[String!]`](#string) | Filter epics by labels. |
......@@ -9257,6 +9261,7 @@ Returns [`Epic`](#epic).
| <a id="groupepiciid"></a>`iid` | [`ID`](#id) | IID of the epic, e.g., "1". |
| <a id="groupepiciidstartswith"></a>`iidStartsWith` | [`String`](#string) | Filter epics by IID for autocomplete. |
| <a id="groupepiciids"></a>`iids` | [`[ID!]`](#id) | List of IIDs of epics, e.g., `[1, 2]`. |
| <a id="groupepicin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument. |
| <a id="groupepicincludeancestorgroups"></a>`includeAncestorGroups` | [`Boolean`](#boolean) | Include epics from ancestor groups. |
| <a id="groupepicincludedescendantgroups"></a>`includeDescendantGroups` | [`Boolean`](#boolean) | Include epics from descendant groups. |
| <a id="groupepiclabelname"></a>`labelName` | [`[String!]`](#string) | Filter epics by labels. |
......@@ -9301,6 +9306,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="groupepicsiid"></a>`iid` | [`ID`](#id) | IID of the epic, e.g., "1". |
| <a id="groupepicsiidstartswith"></a>`iidStartsWith` | [`String`](#string) | Filter epics by IID for autocomplete. |
| <a id="groupepicsiids"></a>`iids` | [`[ID!]`](#id) | List of IIDs of epics, e.g., `[1, 2]`. |
| <a id="groupepicsin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument. |
| <a id="groupepicsincludeancestorgroups"></a>`includeAncestorGroups` | [`Boolean`](#boolean) | Include epics from ancestor groups. |
| <a id="groupepicsincludedescendantgroups"></a>`includeDescendantGroups` | [`Boolean`](#boolean) | Include epics from descendant groups. |
| <a id="groupepicslabelname"></a>`labelName` | [`[String!]`](#string) | Filter epics by labels. |
......@@ -14410,6 +14416,15 @@ Health status of an issue or epic.
| <a id="healthstatusneedsattention"></a>`needsAttention` | |
| <a id="healthstatusontrack"></a>`onTrack` | |
### `IssuableSearchableField`
Fields to perform the search in.
| Value | Description |
| ----- | ----------- |
| <a id="issuablesearchablefielddescription"></a>`DESCRIPTION` | Search in description field. |
| <a id="issuablesearchablefieldtitle"></a>`TITLE` | Search in title field. |
### `IssuableSeverity`
Incident severity.
......
......@@ -21,6 +21,10 @@ module Resolvers
required: false,
description: 'Search query for epic title or description.'
argument :in, [Types::IssuableSearchableFieldEnum],
required: false,
description: 'Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.'
argument :sort, Types::EpicSortEnum,
required: false,
description: 'List epics by sort order.'
......@@ -68,6 +72,7 @@ module Resolvers
def ready?(**args)
validate_timeframe_params!(args)
validate_starts_with_iid!(args)
validate_search_in_params!(args)
super(**args)
end
......@@ -108,6 +113,7 @@ module Resolvers
transformed = args.dup
transformed[:group_id] = group
transformed[:iids] ||= [args[:iid]].compact
transformed[:in] = args[:in].join(',') if args[:in].present?
transformed.merge(transform_timeframe_parameters(args)).merge(relative_param)
end
......@@ -157,5 +163,12 @@ module Resolvers
raise Gitlab::Graphql::Errors::ArgumentError, 'Invalid `iidStartsWith` query'
end
end
def validate_search_in_params!(args)
return unless args[:in].present? && args[:search].blank?
raise Gitlab::Graphql::Errors::ArgumentError,
'`search` should be present when including the `in` argument'
end
end
end
......@@ -115,6 +115,37 @@ RSpec.describe Resolvers::EpicsResolver do
expect(epics).to match_array([epic2, epic3])
end
context 'with in param' do
it 'returns an error if param search is missing' do
error_message = "`search` should be present when including the `in` argument"
expect { resolve_epics(in: ['title']) }
.to raise_error(Gitlab::Graphql::Errors::ArgumentError, error_message)
end
it 'filters epics by description only' do
epics_with_text = resolve_epics(search: 'text', in: ['description'])
epics_with_created = resolve_epics(search: 'created', in: ['description'])
expect(epics_with_created).to be_empty
expect(epics_with_text).to match_array([epic2, epic3])
end
it 'filters epics by title only' do
epics_with_text = resolve_epics(search: 'text', in: ['title'])
epics_with_created = resolve_epics(search: 'created', in: ['title'])
expect(epics_with_created).to match_array([epic1, epic2])
expect(epics_with_text).to be_empty
end
it 'filters epics by title and description' do
epic4 = create(:epic, group: group, title: 'fourth text', description: ['description'])
epics = resolve_epics(search: 'text', in: %w(title description))
expect(epics).to match_array([epic2, epic3, epic4])
end
end
end
context 'with author_username' do
......
......@@ -213,6 +213,17 @@ RSpec.describe 'Epics through GroupQuery' do
expect_array_response([epic.to_global_id.to_s])
end
end
context 'with search params' do
it 'returns only matching epics' do
filter_params = { search: 'bar', in: [:DESCRIPTION] }
graphql_query = query(filter_params)
post_graphql(graphql_query, current_user: user)
expect_array_response([epic2.to_global_id.to_s])
end
end
end
context 'when error requests' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::IssuableSearchableFieldEnum do
specify { expect(described_class.graphql_name).to eq('IssuableSearchableField') }
it 'exposes all the issuable searchable fields' do
expect(described_class.values.keys).to contain_exactly(
*Issuable::SEARCHABLE_FIELDS.map(&:upcase)
)
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