diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb index 3fe9d7f4d7124e57ffb4794281a31fa57e88d37e..c8ca71d03da8a31afae759b1adce89b824e58fdf 100644 --- a/app/models/concerns/timebox.rb +++ b/app/models/concerns/timebox.rb @@ -125,17 +125,6 @@ def search(query) fuzzy_search(query, [:title, :description]) end - # Searches for timeboxes with a matching title. - # - # This method uses ILIKE on PostgreSQL - # - # query - The search query as a String - # - # Returns an ActiveRecord::Relation. - def search_title(query) - fuzzy_search(query, [:title]) - end - def filter_by_state(timeboxes, state) case state when 'closed' then timeboxes.closed diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 868bee9961b05a93efbd1878ec50711cacf934bd..2c95cc2672cce143ca766a3ff231549d2c6cc9a4 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -52,6 +52,17 @@ class Predefined state :active end + # Searches for timeboxes with a matching title. + # + # This method uses ILIKE on PostgreSQL + # + # query - The search query as a String + # + # Returns an ActiveRecord::Relation. + def self.search_title(query) + fuzzy_search(query, [:title]) + end + def self.min_chars_for_partial_matching 2 end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 1b1adff7993a9afb8815b34c71d4957c99b45915..bb338e78a374496f7eece541b1d2df58aae03fe3 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -11062,12 +11062,15 @@ four standard [pagination arguments](#connection-pagination-arguments): | `endDate` **{warning-solid}** | [`Time`](#time) | **Deprecated** in 13.5. Use timeframe.end. | | `id` | [`ID`](#id) | Global ID of the Iteration to look up. | | `iid` | [`ID`](#id) | Internal ID of the Iteration to look up. | +| `in` | [`[IterationSearchableField!]`](#iterationsearchablefield) | Fields in which the fuzzy-search should be performed with the query given in the argument `search`. Defaults to `[title]`. | | `includeAncestors` | [`Boolean`](#boolean) | Whether to include ancestor iterations. Defaults to true. | | `iterationCadenceIds` | [`[IterationsCadenceID!]`](#iterationscadenceid) | Global iteration cadence IDs by which to look up the iterations. | +| `search` | [`String`](#string) | Query used for fuzzy-searching in the fields selected in the argument `in`. | +| `sort` | [`IterationSort`](#iterationsort) | List iterations by sort order. If unspecified, an arbitrary order (subject to change) is used. | | `startDate` **{warning-solid}** | [`Time`](#time) | **Deprecated** in 13.5. Use timeframe.start. | | `state` | [`IterationState`](#iterationstate) | Filter iterations by state. | | `timeframe` | [`Timeframe`](#timeframe) | List items overlapping the given timeframe. | -| `title` | [`String`](#string) | Fuzzy search by title. | +| `title` **{warning-solid}** | [`String`](#string) | **Deprecated** in 15.4. The argument will be removed in 15.4. Please use `search` and `in` fields instead. | ##### `Group.label` @@ -13759,12 +13762,15 @@ four standard [pagination arguments](#connection-pagination-arguments): | `endDate` **{warning-solid}** | [`Time`](#time) | **Deprecated** in 13.5. Use timeframe.end. | | `id` | [`ID`](#id) | Global ID of the Iteration to look up. | | `iid` | [`ID`](#id) | Internal ID of the Iteration to look up. | +| `in` | [`[IterationSearchableField!]`](#iterationsearchablefield) | Fields in which the fuzzy-search should be performed with the query given in the argument `search`. Defaults to `[title]`. | | `includeAncestors` | [`Boolean`](#boolean) | Whether to include ancestor iterations. Defaults to true. | | `iterationCadenceIds` | [`[IterationsCadenceID!]`](#iterationscadenceid) | Global iteration cadence IDs by which to look up the iterations. | +| `search` | [`String`](#string) | Query used for fuzzy-searching in the fields selected in the argument `in`. | +| `sort` | [`IterationSort`](#iterationsort) | List iterations by sort order. If unspecified, an arbitrary order (subject to change) is used. | | `startDate` **{warning-solid}** | [`Time`](#time) | **Deprecated** in 13.5. Use timeframe.start. | | `state` | [`IterationState`](#iterationstate) | Filter iterations by state. | | `timeframe` | [`Timeframe`](#timeframe) | List items overlapping the given timeframe. | -| `title` | [`String`](#string) | Fuzzy search by title. | +| `title` **{warning-solid}** | [`String`](#string) | **Deprecated** in 15.4. The argument will be removed in 15.4. Please use `search` and `in` fields instead. | ##### `Project.jobs` @@ -16997,6 +17003,23 @@ Issue type. | `REQUIREMENT` | Requirement issue type. | | `TEST_CASE` | Test Case issue type. | +### `IterationSearchableField` + +Fields to perform the search in. + +| Value | Description | +| ----- | ----------- | +| `CADENCE_TITLE` | Search in cadence_title field. | +| `TITLE` | Search in title field. | + +### `IterationSort` + +Iteration sort values. + +| Value | Description | +| ----- | ----------- | +| `CADENCE_AND_DUE_DATE_ASC` | Sort by cadence id and due date in ascending order. | + ### `IterationState` State of a GitLab iteration. diff --git a/ee/app/finders/iterations_finder.rb b/ee/app/finders/iterations_finder.rb index 2b98344574a1e7e9809e02b2be08c83f6ad55e64..7020d939809da16cff1dc6cf5a8e1e9c950606f2 100644 --- a/ee/app/finders/iterations_finder.rb +++ b/ee/app/finders/iterations_finder.rb @@ -5,14 +5,18 @@ # params - Hash # parent - The group in which to look-up iterations. # include_ancestors - whether to look-up iterations in group ancestors. -# order - Orders by field default due date asc. # title - Filter by title. +# search - Filter by fuzzy searching the given query in the selected fields. +# in - Array of searchable fields used with search param. # state - Filters by state. +# sort - Items are sorted by due_date and title with id as a tie breaker if unspecified. class IterationsFinder include FinderMethods include TimeFrameFilter + SEARCHABLE_FIELDS = %i(title cadence_title).freeze + attr_reader :params, :current_user def initialize(current_user, params = {}) @@ -30,7 +34,7 @@ def execute(skip_authorization: false) items = by_iid(items) items = by_groups(items) items = by_title(items) - items = by_search_title(items) + items = by_search(items) items = by_state(items) items = by_timeframe(items) items = by_iteration_cadences(items) @@ -72,10 +76,20 @@ def by_title(items) items.with_title(params[:title]) end - def by_search_title(items) - return items unless params[:search_title].present? + def by_search(items) + return items unless params[:search].present? && params[:in].present? + + query = params[:search] + in_title = params[:in].include?(:title) + in_cadence_title = params[:in].include?(:cadence_title) - items.search_title(params[:search_title]) + if in_title && in_cadence_title + items.search_title_or_cadence_title(query) + elsif in_title + items.search_title(query) + elsif in_cadence_title + items.search_cadence_title(query) + end end def by_state(items) @@ -96,6 +110,8 @@ def by_iteration_cadences(items) # rubocop: disable CodeReuse/ActiveRecord def order(items) + return items.sort_by_cadence_id_and_due_date_asc if params[:sort].present? && params[:sort] == :cadence_and_due_date_asc + items.reorder(:due_date).order(:title, { id: :asc }) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/ee/app/graphql/resolvers/iterations_resolver.rb b/ee/app/graphql/resolvers/iterations_resolver.rb index cf7381551173e4463bb5ec1ff08a82fa8ef161ef..0b099770f325e26e7cf7b251a46735366b3b701a 100644 --- a/ee/app/graphql/resolvers/iterations_resolver.rb +++ b/ee/app/graphql/resolvers/iterations_resolver.rb @@ -5,12 +5,23 @@ class IterationsResolver < BaseResolver include Gitlab::Graphql::Authorize::AuthorizeResource include TimeFrameArguments + DEFAULT_IN_FIELD = :title + argument :state, Types::IterationStateEnum, required: false, description: 'Filter iterations by state.' argument :title, GraphQL::Types::String, required: false, - description: 'Fuzzy search by title.' + description: 'Fuzzy search by title.', + deprecated: { reason: 'The argument will be removed in 15.4. Please use `search` and `in` fields instead', milestone: '15.4' } + + argument :search, GraphQL::Types::String, + required: false, + description: 'Query used for fuzzy-searching in the fields selected in the argument `in`.' + + argument :in, [Types::IterationSearchableFieldEnum], + required: false, + description: "Fields in which the fuzzy-search should be performed with the query given in the argument `search`. Defaults to `[#{DEFAULT_IN_FIELD}]`." # rubocop:disable Graphql/IDType argument :id, GraphQL::Types::ID, @@ -29,10 +40,15 @@ class IterationsResolver < BaseResolver required: false, description: 'Global iteration cadence IDs by which to look up the iterations.' + argument :sort, Types::IterationSortEnum, + required: false, + description: 'List iterations by sort order. If unspecified, an arbitrary order (subject to change) is used.' + type Types::IterationType.connection_type, null: true def resolve(**args) validate_timeframe_params!(args) + validate_search_params!(args) authorize! @@ -40,6 +56,8 @@ def resolve(**args) args[:iteration_cadence_ids] = parse_iteration_cadence_ids(args[:iteration_cadence_ids]) args[:include_ancestors] = true if args[:include_ancestors].nil? && args[:iid].nil? + handle_search_params!(args) + iterations = IterationsFinder.new(context[:current_user], iterations_finder_params(args)).execute # Necessary for scopedPath computation in IterationPresenter @@ -50,6 +68,23 @@ def resolve(**args) private + def validate_search_params!(args) + if args[:title].present? && (args[:search].present? || args[:in].present?) + raise Gitlab::Graphql::Errors::ArgumentError, "'title' is deprecated in favor of 'search'. Please use 'search'." + end + + if !args[:search].present? && args[:in].present? + raise Gitlab::Graphql::Errors::ArgumentError, "'search' must be specified when using 'in' argument." + end + end + + def handle_search_params!(args) + return unless args[:search] || args[:title] + + args[:in] = [DEFAULT_IN_FIELD] if args[:in].nil? || args[:in].empty? + args[:search] = args[:title] if args[:title] + end + def iterations_finder_params(args) { parent: parent, @@ -58,7 +93,9 @@ def iterations_finder_params(args) iid: args[:iid], iteration_cadence_ids: args[:iteration_cadence_ids], state: args[:state] || 'all', - search_title: args[:title] + search: args[:search], + in: args[:in], + sort: args[:sort] }.merge(transform_timeframe_parameters(args)) end diff --git a/ee/app/graphql/types/iteration_searchable_field_enum.rb b/ee/app/graphql/types/iteration_searchable_field_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..714f2e81104ebb95c7382e27cf4b95be3848a025 --- /dev/null +++ b/ee/app/graphql/types/iteration_searchable_field_enum.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + class IterationSearchableFieldEnum < BaseEnum + graphql_name 'IterationSearchableField' + description 'Fields to perform the search in' + + IterationsFinder::SEARCHABLE_FIELDS.each do |field| + value field.to_s.upcase, value: field, description: "Search in #{field} field." + end + end +end diff --git a/ee/app/graphql/types/iteration_sort_enum.rb b/ee/app/graphql/types/iteration_sort_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..cd07cea19579c05660b193d6f1749fdfa9ce5979 --- /dev/null +++ b/ee/app/graphql/types/iteration_sort_enum.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + class IterationSortEnum < BaseEnum + graphql_name 'IterationSort' + description 'Iteration sort values' + + value 'CADENCE_AND_DUE_DATE_ASC', 'Sort by cadence id and due date in ascending order.', value: :cadence_and_due_date_asc + end +end diff --git a/ee/app/models/ee/iteration.rb b/ee/app/models/ee/iteration.rb index ffa50321b23f5b7a7d70c2fe921f434c73ede9f7..b8d2a66880c7dfe6aba7d24cc99b40e2a357beb9 100644 --- a/ee/app/models/ee/iteration.rb +++ b/ee/app/models/ee/iteration.rb @@ -60,6 +60,7 @@ def self.by_id(id) after_commit :reset, on: [:update, :create], if: :saved_change_to_start_or_due_date? scope :due_date_order_asc, -> { order(:due_date) } + scope :sort_by_cadence_id_and_due_date_asc, -> { reorder(iterations_cadence_id: :asc).due_date_order_asc.order(id: :asc) } scope :upcoming, -> { with_state(:upcoming) } scope :current, -> { with_state(:current) } scope :closed, -> { with_state(:closed) } @@ -147,6 +148,28 @@ def filter_by_state(iterations, state) else raise ArgumentError, "Unknown state filter: #{state}" end end + + def search_title(query) + fuzzy_search(query, [::Resolvers::IterationsResolver::DEFAULT_IN_FIELD], use_minimum_char_limit: contains_digits?(query)) + end + + def search_cadence_title(query) + cadence_ids = Iterations::Cadence.search_title(query).pluck(:id) + + where(iterations_cadence_id: cadence_ids) + end + + def search_title_or_cadence_title(query) + union_sql = ::Gitlab::SQL::Union.new([search_title(query), search_cadence_title(query)]).to_sql + + ::Iteration.from("(#{union_sql}) #{table_name}") + end + + private + + def contains_digits?(query) + !(query =~ / \d+ /).nil? + end end def display_text diff --git a/ee/app/models/iterations/cadence.rb b/ee/app/models/iterations/cadence.rb index f800441bbdba810d51b1dea83393f88f3ee831ab..72aebc96d7687b92d6cf20214beb8ed631323778 100644 --- a/ee/app/models/iterations/cadence.rb +++ b/ee/app/models/iterations/cadence.rb @@ -36,8 +36,16 @@ class Cadence < ApplicationRecord .where("DATE ((COALESCE(iterations_cadences.last_run_date, DATE('01-01-1970')) + iterations_cadences.duration_in_weeks * INTERVAL '1 week')) <= CURRENT_DATE") end - def self.search_title(query) - fuzzy_search(query, [:title]) + class << self + def search_title(query) + fuzzy_search(query, [::Resolvers::IterationsResolver::DEFAULT_IN_FIELD], use_minimum_char_limit: contains_digit?(query)) + end + + private + + def contains_digit?(query) + !(query =~ / \d+ /).nil? + end end def next_open_iteration(date) diff --git a/ee/lib/api/iterations.rb b/ee/lib/api/iterations.rb index 15260e9ce6b692993e2039dd42fc03ed3c229245..dbc0023b6229559ab2f45892000b21fb74cc4a78 100644 --- a/ee/lib/api/iterations.rb +++ b/ee/lib/api/iterations.rb @@ -23,11 +23,23 @@ def list_iterations_for(parent) end def iterations_finder_params(parent) - { + finder_params = { parent: parent, include_ancestors: params[:include_ancestors], state: params[:state], - search_title: params[:search] + search: nil, + in: nil + } + + finder_params.merge!(search_params) if params[:search] + + finder_params + end + + def search_params + { + search: params[:search], + in: [::Resolvers::IterationsResolver::DEFAULT_IN_FIELD] } end end diff --git a/ee/spec/finders/iterations_finder_spec.rb b/ee/spec/finders/iterations_finder_spec.rb index a4329f221e9a09447f17a1af397669158aaedf58..a6bccdb29aeab636d5043b1c48128dbaf083f4f6 100644 --- a/ee/spec/finders/iterations_finder_spec.rb +++ b/ee/spec/finders/iterations_finder_spec.rb @@ -10,11 +10,11 @@ let_it_be(:iteration_cadence1) { create(:iterations_cadence, group: group, active: true, duration_in_weeks: 1, title: 'one week iterations') } let_it_be(:iteration_cadence2) { create(:iterations_cadence, group: group, active: true, duration_in_weeks: 2, title: 'two week iterations') } let_it_be(:iteration_cadence3) { create(:iterations_cadence, group: root, active: true, duration_in_weeks: 3, title: 'three week iterations') } - let_it_be(:closed_iteration) { create(:closed_iteration, :skip_future_date_validation, iterations_cadence: iteration_cadence2, group: iteration_cadence2.group, start_date: 7.days.ago, due_date: 2.days.ago) } - let_it_be(:started_group_iteration) { create(:current_iteration, :skip_future_date_validation, iterations_cadence: iteration_cadence2, group: iteration_cadence2.group, title: 'one test', start_date: 1.day.ago, due_date: Date.today) } - let_it_be(:upcoming_group_iteration) { create(:iteration, iterations_cadence: iteration_cadence1, group: iteration_cadence1.group, start_date: 1.day.from_now, due_date: 3.days.from_now) } - let_it_be(:root_group_iteration) { create(:current_iteration, iterations_cadence: iteration_cadence3, group: iteration_cadence3.group, start_date: 1.day.ago, due_date: 2.days.from_now) } - let_it_be(:root_closed_iteration) { create(:closed_iteration, iterations_cadence: iteration_cadence3, group: iteration_cadence3.group, start_date: 1.week.ago, due_date: 2.days.ago) } + let_it_be(:closed_iteration) { create(:closed_iteration, :skip_future_date_validation, iterations_cadence: iteration_cadence2, start_date: 7.days.ago, due_date: 2.days.ago) } + let_it_be(:started_group_iteration) { create(:current_iteration, :skip_future_date_validation, iterations_cadence: iteration_cadence2, title: 'one test', start_date: 1.day.ago, due_date: Date.today) } + let_it_be(:upcoming_group_iteration) { create(:iteration, iterations_cadence: iteration_cadence1, title: 'Iteration 1', start_date: 1.day.from_now, due_date: 3.days.from_now) } + let_it_be(:root_group_iteration) { create(:current_iteration, iterations_cadence: iteration_cadence3, start_date: 1.day.ago, due_date: 2.days.from_now) } + let_it_be(:root_closed_iteration) { create(:closed_iteration, iterations_cadence: iteration_cadence3, start_date: 1.week.ago, due_date: 2.days.ago) } let(:parent) { project_1 } let(:params) { { parent: parent, include_ancestors: true } } @@ -132,10 +132,41 @@ expect(subject.to_a).to contain_exactly(started_group_iteration) end - it 'filters by search_title' do - params[:search_title] = 'one t' + context "with search params" do + using RSpec::Parameterized::TableSyntax - expect(subject.to_a).to contain_exactly(started_group_iteration) + shared_examples "search returns correct items" do + before do + params.merge!({ search: search, in: fields_to_search }) + end + + it { is_expected.to contain_exactly(*expected_iterations) } + end + + context 'filters by title' do + let(:all_iterations) { [closed_iteration, started_group_iteration, upcoming_group_iteration, root_group_iteration, root_closed_iteration] } + + where(:search, :fields_to_search, :expected_iterations) do + '' | [] | lazy { all_iterations } + 'iteration' | [] | lazy { all_iterations } + 'iteration' | [:title] | lazy { [upcoming_group_iteration] } + 'iteration' | [:title] | lazy { [upcoming_group_iteration] } + 'iter 1' | [:title] | lazy { [upcoming_group_iteration] } + 'iteration 1' | [:title] | lazy { [upcoming_group_iteration] } + 'iteration test' | [:title] | lazy { [] } + 'one week iter' | [:cadence_title] | lazy { [upcoming_group_iteration] } + 'iteration' | [:cadence_title] | lazy { all_iterations } + 'two week' | [:cadence_title] | lazy { [closed_iteration, started_group_iteration] } + 'iteration test' | [:cadence_title] | lazy { [] } + 'one week' | [:title, :cadence_title] | lazy { [upcoming_group_iteration] } + 'iteration' | [:title, :cadence_title] | lazy { all_iterations } + 'iteration 1' | [:title, :cadence_title] | lazy { [upcoming_group_iteration] } + end + + with_them do + it_behaves_like "search returns correct items" + end + end end it 'filters by ID' do @@ -205,6 +236,22 @@ end end end + + context 'sorting' do + it 'sorts by the default order (due_date, title, id asc) when no param is given' do + expect(subject).to eq([closed_iteration, root_closed_iteration, started_group_iteration, root_group_iteration, upcoming_group_iteration]) + end + + it 'sorts correctly when supported sorting param provided' do + params[:sort] = :cadence_and_due_date_asc + + cadence1_iterations = [upcoming_group_iteration] + cadence2_iterations = [closed_iteration, started_group_iteration] + cadence3_iterations = [root_closed_iteration, root_group_iteration] + + expect(subject).to eq([*cadence1_iterations, *cadence2_iterations, *cadence3_iterations]) + end + end end describe '#find_by' do diff --git a/ee/spec/graphql/resolvers/iterations_resolver_spec.rb b/ee/spec/graphql/resolvers/iterations_resolver_spec.rb index 532247bbc6905fd77893b19ecf159fd4003a24d8..c1628619d9cc4662bf832c84eb524247c3c2e684 100644 --- a/ee/spec/graphql/resolvers/iterations_resolver_spec.rb +++ b/ee/spec/graphql/resolvers/iterations_resolver_spec.rb @@ -15,7 +15,9 @@ iteration_cadence_ids: nil, parent: nil, state: nil, - search_title: nil + search: nil, + in: nil, + sort: nil } end @@ -50,6 +52,60 @@ def resolve_group_iterations(args = {}, obj = group, context = { current_user: c end context 'with parameters' do + context 'search' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:plan_cadence) { create(:iterations_cadence, title: 'plan cadence', group: group) } + let_it_be(:product_cadence) { create(:iterations_cadence, title: 'product management', group: group) } + let_it_be(:plan_iteration1) { create(:iteration, :with_due_date, title: "Iteration 1", iterations_cadence: plan_cadence, start_date: 1.week.ago)} + let_it_be(:plan_iteration2) { create(:iteration, :with_due_date, title: "My iteration", iterations_cadence: plan_cadence, start_date: 2.weeks.ago)} + let_it_be(:product_iteration) { create(:iteration, :with_due_date, iterations_cadence: product_cadence, start_date: 1.week.from_now)} + + let(:all_iterations) { group.iterations } + + context 'with search and in parameters' do + where(:search, :fields_to_search, :expected_iterations) do + '' | [] | lazy { all_iterations } + 'iteration' | nil | lazy { plan_cadence.iterations } + 'iteration' | [] | lazy { plan_cadence.iterations } + 'iteration' | [:title] | lazy { plan_cadence.iterations } + 'iteration' | [:title, :cadence_title] | lazy { plan_cadence.iterations } + 'plan' | [] | lazy { [] } + 'plan' | [:cadence_title] | lazy { plan_cadence.iterations } + end + + with_them do + it "returns correct items" do + expect(resolve_group_iterations({ search: search, in: fields_to_search }).items).to contain_exactly(*expected_iterations) + end + end + end + + context "with the deprecated argument 'title' (to be deprecated in 15.4)" do + [ + { search: "foo" }, + { in: [:title] }, + { in: [:cadence_title] } + ].each do |params| + it "raises an error when 'title' is used with #{params}" do + expect do + resolve_group_iterations({ title: "foo", **params }) + end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, "'title' is deprecated in favor of 'search'. Please use 'search'.") + end + end + + it "raises an error when 'in' is specified but 'search' is not" do + expect do + resolve_group_iterations({ in: [:title] }) + end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, "'search' must be specified when using 'in' argument.") + end + + it "uses 'search' and 'in' arguments to search title" do + expect(resolve_group_iterations({ title: 'iteration' }).items).to contain_exactly(*plan_cadence.iterations) + end + end + end + it 'calls IterationsFinder with correct parameters, using timeframe' do start_date = now end_date = start_date + 1.hour @@ -58,11 +114,11 @@ def resolve_group_iterations(args = {}, obj = group, context = { current_user: c iid = 2 iteration_cadence_ids = ['5'] - params = params_list.merge(id: id, iid: iid, iteration_cadence_ids: iteration_cadence_ids, parent: group, include_ancestors: nil, state: 'closed', start_date: start_date, end_date: end_date, search_title: search) + params = params_list.merge(id: id, iid: iid, iteration_cadence_ids: iteration_cadence_ids, parent: group, include_ancestors: nil, state: 'closed', start_date: start_date, end_date: end_date, search: search, in: [:title]) expect(IterationsFinder).to receive(:new).with(current_user, params).and_call_original - resolve_group_iterations(timeframe: { start: start_date, end: end_date }, state: 'closed', title: search, id: 'gid://gitlab/Iteration/1', iteration_cadence_ids: ['gid://gitlab/Iterations::Cadence/5'], iid: iid) + resolve_group_iterations(timeframe: { start: start_date, end: end_date }, state: 'closed', search: search, id: 'gid://gitlab/Iteration/1', iteration_cadence_ids: ['gid://gitlab/Iterations::Cadence/5'], iid: iid) end it 'calls IterationsFinder with correct parameters, using start and end date' do @@ -73,11 +129,11 @@ def resolve_group_iterations(args = {}, obj = group, context = { current_user: c iid = 2 iteration_cadence_ids = ['5'] - params = params_list.merge(id: id, iid: iid, iteration_cadence_ids: iteration_cadence_ids, parent: group, include_ancestors: nil, state: 'closed', start_date: start_date, end_date: end_date, search_title: search) + params = params_list.merge(id: id, iid: iid, iteration_cadence_ids: iteration_cadence_ids, parent: group, include_ancestors: nil, state: 'closed', start_date: start_date, end_date: end_date, search: search, in: [:title]) expect(IterationsFinder).to receive(:new).with(current_user, params).and_call_original - resolve_group_iterations(start_date: start_date, end_date: end_date, state: 'closed', title: search, id: 'gid://gitlab/Iteration/1', iteration_cadence_ids: ['gid://gitlab/Iterations::Cadence/5'], iid: iid) + resolve_group_iterations(start_date: start_date, end_date: end_date, state: 'closed', search: search, id: 'gid://gitlab/Iteration/1', iteration_cadence_ids: ['gid://gitlab/Iterations::Cadence/5'], iid: iid) end it 'accepts a raw model id for backward compatibility' do diff --git a/ee/spec/models/ee/iteration_spec.rb b/ee/spec/models/ee/iteration_spec.rb index d5c3bf566811e0781f1b019a4015433a7a33cb92..6d4df67fe7a71a00dfc3abb01d131533105240d9 100644 --- a/ee/spec/models/ee/iteration_spec.rb +++ b/ee/spec/models/ee/iteration_spec.rb @@ -538,6 +538,82 @@ end end + context 'search and sorting scopes' do + let_it_be(:group1) { create(:group) } + let_it_be(:group2) { create(:group) } + let_it_be(:subgroup) { create(:group, parent: group1) } + let_it_be(:plan_cadence) { create(:iterations_cadence, title: 'plan cadence', group: group1) } + let_it_be(:product_cadence) { create(:iterations_cadence, title: 'product management', group: subgroup) } + let_it_be(:cadence) { create(:iterations_cadence, title: 'cadence', group: group2) } + let_it_be(:plan_iteration1) { create(:iteration, :with_due_date, title: "Iteration 1", iterations_cadence: plan_cadence, start_date: 1.week.ago)} + let_it_be(:plan_iteration2) { create(:iteration, :with_due_date, title: "My iteration", iterations_cadence: plan_cadence, start_date: 2.weeks.ago)} + let_it_be(:product_iteration) { create(:iteration, :with_due_date, title: "Iteration 2", iterations_cadence: product_cadence, start_date: 1.week.from_now)} + let_it_be(:cadence_iteration) { create(:iteration, :with_due_date, iterations_cadence: cadence, start_date: Date.today)} + + shared_examples "search returns correct records" do + it { is_expected.to contain_exactly(*expected_iterations) } + end + + describe '.search_title' do + where(:query, :expected_iterations) do + 'iter 1' | lazy { [plan_iteration1] } + 'iteration' | lazy { [plan_iteration1, plan_iteration2, product_iteration] } + 'iteration 1' | lazy { [plan_iteration1] } + 'my iteration 1' | lazy { [] } + end + + with_them do + subject { described_class.search_title(query) } + + it_behaves_like "search returns correct records" + end + end + + describe '.search_cadence_title' do + where(:query, :expected_iterations) do + 'plan' | lazy { [plan_iteration1, plan_iteration2] } + 'plan cadence' | lazy { [plan_iteration1, plan_iteration2] } + 'product cadence' | lazy { [] } + 'cadence' | lazy { [plan_iteration1, plan_iteration2, cadence_iteration] } + end + + with_them do + subject { described_class.search_cadence_title(query) } + + it_behaves_like "search returns correct records" + end + end + + describe '.search_title_or_cadence_title' do + where(:query, :expected_iterations) do + # The same test cases used for .search_title + 'iter 1' | lazy { [plan_iteration1] } + 'iteration' | lazy { [plan_iteration1, plan_iteration2, product_iteration] } + 'iteration 1' | lazy { [plan_iteration1] } + 'my iteration 1' | lazy { [] } + # The same test cases used for .search_cadence_title + 'plan' | lazy { [plan_iteration1, plan_iteration2] } + 'plan cadence' | lazy { [plan_iteration1, plan_iteration2] } + 'product cadence' | lazy { [] } + 'cadence' | lazy { [plan_iteration1, plan_iteration2, cadence_iteration] } + # At least one of cadence title or iteration title should contain all of the terms + 'plan iteration' | lazy { [] } + end + + with_them do + subject { described_class.search_title_or_cadence_title(query) } + + it_behaves_like "search returns correct records" + end + end + + describe '.sort_by_cadence_id_and_due_date_asc' do + subject { described_class.all.sort_by_cadence_id_and_due_date_asc } + + it { is_expected.to eq([plan_iteration2, plan_iteration1, product_iteration, cadence_iteration]) } + end + end + context 'time scopes' do let_it_be(:group) { create(:group) } let_it_be(:iteration_cadence) { create(:iterations_cadence, group: group) } diff --git a/ee/spec/requests/api/graphql/iterations/iterations_spec.rb b/ee/spec/requests/api/graphql/iterations/iterations_spec.rb index 48e92a916ca9324a4a13bddcae09e82e4feac0b9..50a910ab9fc5c0e06153a4e1e64d4670faa4496a 100644 --- a/ee/spec/requests/api/graphql/iterations/iterations_spec.rb +++ b/ee/spec/requests/api/graphql/iterations/iterations_spec.rb @@ -11,9 +11,9 @@ let_it_be(:iteration_cadence1) { create(:iterations_cadence, group: group, active: true, duration_in_weeks: 1, title: 'one week iterations') } let_it_be(:iteration_cadence2) { create(:iterations_cadence, group: group, active: true, duration_in_weeks: 2, title: 'two week iterations') } - let_it_be(:current_group_iteration) { create(:iteration, :skip_future_date_validation, iterations_cadence: iteration_cadence1, group: iteration_cadence1.group, title: 'one test', start_date: 1.day.ago, due_date: 1.week.from_now) } - let_it_be(:upcoming_group_iteration) { create(:iteration, iterations_cadence: iteration_cadence2, group: iteration_cadence2.group, start_date: 1.day.from_now, due_date: 2.days.from_now) } - let_it_be(:closed_group_iteration) { create(:iteration, :skip_project_validation, iterations_cadence: iteration_cadence1, group: iteration_cadence1.group, start_date: 3.weeks.ago, due_date: 1.week.ago) } + let_it_be(:current_group_iteration) { create(:iteration, :skip_future_date_validation, iterations_cadence: iteration_cadence1, title: 'one test', start_date: 1.day.ago, due_date: 1.week.from_now) } + let_it_be(:upcoming_group_iteration) { create(:iteration, iterations_cadence: iteration_cadence2, start_date: 1.day.from_now, due_date: 2.days.from_now) } + let_it_be(:closed_group_iteration) { create(:iteration, :skip_project_validation, iterations_cadence: iteration_cadence1, start_date: 3.weeks.ago, due_date: 1.week.ago) } before do group.add_maintainer(user) @@ -47,6 +47,28 @@ describe 'query for iterations by cadence' do context 'with multiple cadences' do + context 'searching by cadence title or iteration title and sorting by cadence and due date ASC' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:past_iteration1) { create(:iteration, :with_due_date, iterations_cadence: iteration_cadence2, start_date: 4.weeks.ago) } + let_it_be(:past_iteration2) { create(:iteration, :with_due_date, iterations_cadence: iteration_cadence2, start_date: 2.weeks.ago) } + + where(:search, :ordered_expected_iterations) do + 'two' | lazy { [past_iteration1, past_iteration2, upcoming_group_iteration] } + 'iteration' | lazy { [closed_group_iteration, current_group_iteration, past_iteration1, past_iteration2, upcoming_group_iteration] } + end + + with_them do + let(:field_queries) { "search: \"#{search}\", in: [TITLE, CADENCE_TITLE], sort: CADENCE_AND_DUE_DATE_ASC" } + + it 'correctly returns ordered items' do + post_graphql(iterations_query(group, field_queries), current_user: user) + + expect(actual_iterations).to eq(expected_iterations(ordered_expected_iterations)) + end + end + end + it 'returns iterations' do post_graphql(iteration_cadence_query(group, [iteration_cadence1.to_global_id, iteration_cadence2.to_global_id]), current_user: user) @@ -103,11 +125,16 @@ def iterations_query(group, field_queries) QUERY end - def expect_iterations_response(*iterations) - actual_iterations = graphql_data['group']['iterations']['nodes'].map { |iteration| iteration['id'] } - expected_iterations = iterations.map { |iteration| iteration.to_global_id.to_s } + def actual_iterations + graphql_data['group']['iterations']['nodes'].map { |iteration| iteration['id'] } + end - expect(actual_iterations).to contain_exactly(*expected_iterations) + def expected_iterations(iterations) + iterations.map { |iteration| iteration.to_global_id.to_s } + end + + def expect_iterations_response(*iterations) + expect(actual_iterations).to contain_exactly(*expected_iterations(iterations)) expect(graphql_errors).to be_nil end end diff --git a/ee/spec/workers/iterations_update_status_worker_spec.rb b/ee/spec/workers/iterations_update_status_worker_spec.rb index 6f67e2ce692404c037f187c4acab4666f95a6592..c2f7fee5a738eb8420253cbcd7b23720612d1b34 100644 --- a/ee/spec/workers/iterations_update_status_worker_spec.rb +++ b/ee/spec/workers/iterations_update_status_worker_spec.rb @@ -12,8 +12,8 @@ describe '#perform' do before do - current_iteration1.update_column(:state_enum, 2) - closed_iteration1.update_column(:state_enum, 1) + current_iteration1.update_column(:state_enum, Iteration::STATE_ENUM_MAP[:current]) + closed_iteration1.update_column(:state_enum, Iteration::STATE_ENUM_MAP[:upcoming]) end it 'schedules an issues roll-over job' do