diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 3f189fc73e7ec9e4436cc24c77b30f9a04f6a9b8..1539fce8f5690d2fa5ee9dc338a07918b55dbc00 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -9281,6 +9281,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="boardepicancestorsstartdate"></a>`startDate` **{warning-solid}** | [`Time`](#time) | **Deprecated** in 13.5. Use timeframe.start. | | <a id="boardepicancestorsstate"></a>`state` | [`EpicState`](#epicstate) | Filter epics by state. | | <a id="boardepicancestorstimeframe"></a>`timeframe` | [`Timeframe`](#timeframe) | List items overlapping the given timeframe. | +| <a id="boardepicancestorstoplevelhierarchyonly"></a>`topLevelHierarchyOnly` | [`Boolean`](#boolean) | Filter epics with a top-level hierarchy. | | <a id="boardepicancestorsupdatedafter"></a>`updatedAfter` | [`Time`](#time) | Epics updated after this date. | | <a id="boardepicancestorsupdatedbefore"></a>`updatedBefore` | [`Time`](#time) | Epics updated before this date. | @@ -9318,6 +9319,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="boardepicchildrenstartdate"></a>`startDate` **{warning-solid}** | [`Time`](#time) | **Deprecated** in 13.5. Use timeframe.start. | | <a id="boardepicchildrenstate"></a>`state` | [`EpicState`](#epicstate) | Filter epics by state. | | <a id="boardepicchildrentimeframe"></a>`timeframe` | [`Timeframe`](#timeframe) | List items overlapping the given timeframe. | +| <a id="boardepicchildrentoplevelhierarchyonly"></a>`topLevelHierarchyOnly` | [`Boolean`](#boolean) | Filter epics with a top-level hierarchy. | | <a id="boardepicchildrenupdatedafter"></a>`updatedAfter` | [`Time`](#time) | Epics updated after this date. | | <a id="boardepicchildrenupdatedbefore"></a>`updatedBefore` | [`Time`](#time) | Epics updated before this date. | @@ -10858,6 +10860,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="epicancestorsstartdate"></a>`startDate` **{warning-solid}** | [`Time`](#time) | **Deprecated** in 13.5. Use timeframe.start. | | <a id="epicancestorsstate"></a>`state` | [`EpicState`](#epicstate) | Filter epics by state. | | <a id="epicancestorstimeframe"></a>`timeframe` | [`Timeframe`](#timeframe) | List items overlapping the given timeframe. | +| <a id="epicancestorstoplevelhierarchyonly"></a>`topLevelHierarchyOnly` | [`Boolean`](#boolean) | Filter epics with a top-level hierarchy. | | <a id="epicancestorsupdatedafter"></a>`updatedAfter` | [`Time`](#time) | Epics updated after this date. | | <a id="epicancestorsupdatedbefore"></a>`updatedBefore` | [`Time`](#time) | Epics updated before this date. | @@ -10895,6 +10898,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="epicchildrenstartdate"></a>`startDate` **{warning-solid}** | [`Time`](#time) | **Deprecated** in 13.5. Use timeframe.start. | | <a id="epicchildrenstate"></a>`state` | [`EpicState`](#epicstate) | Filter epics by state. | | <a id="epicchildrentimeframe"></a>`timeframe` | [`Timeframe`](#timeframe) | List items overlapping the given timeframe. | +| <a id="epicchildrentoplevelhierarchyonly"></a>`topLevelHierarchyOnly` | [`Boolean`](#boolean) | Filter epics with a top-level hierarchy. | | <a id="epicchildrenupdatedafter"></a>`updatedAfter` | [`Time`](#time) | Epics updated after this date. | | <a id="epicchildrenupdatedbefore"></a>`updatedBefore` | [`Time`](#time) | Epics updated before this date. | @@ -11629,6 +11633,7 @@ Returns [`Epic`](#epic). | <a id="groupepicstartdate"></a>`startDate` **{warning-solid}** | [`Time`](#time) | **Deprecated** in 13.5. Use timeframe.start. | | <a id="groupepicstate"></a>`state` | [`EpicState`](#epicstate) | Filter epics by state. | | <a id="groupepictimeframe"></a>`timeframe` | [`Timeframe`](#timeframe) | List items overlapping the given timeframe. | +| <a id="groupepictoplevelhierarchyonly"></a>`topLevelHierarchyOnly` | [`Boolean`](#boolean) | Filter epics with a top-level hierarchy. | | <a id="groupepicupdatedafter"></a>`updatedAfter` | [`Time`](#time) | Epics updated after this date. | | <a id="groupepicupdatedbefore"></a>`updatedBefore` | [`Time`](#time) | Epics updated before this date. | @@ -11678,6 +11683,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | <a id="groupepicsstartdate"></a>`startDate` **{warning-solid}** | [`Time`](#time) | **Deprecated** in 13.5. Use timeframe.start. | | <a id="groupepicsstate"></a>`state` | [`EpicState`](#epicstate) | Filter epics by state. | | <a id="groupepicstimeframe"></a>`timeframe` | [`Timeframe`](#timeframe) | List items overlapping the given timeframe. | +| <a id="groupepicstoplevelhierarchyonly"></a>`topLevelHierarchyOnly` | [`Boolean`](#boolean) | Filter epics with a top-level hierarchy. | | <a id="groupepicsupdatedafter"></a>`updatedAfter` | [`Time`](#time) | Epics updated after this date. | | <a id="groupepicsupdatedbefore"></a>`updatedBefore` | [`Time`](#time) | Epics updated before this date. | diff --git a/ee/app/finders/epics_finder.rb b/ee/app/finders/epics_finder.rb index ff290f821491de481a21350cdbb494a0b38d99d4..73b25933a14506d4f00434c4403091b9541d1571 100644 --- a/ee/app/finders/epics_finder.rb +++ b/ee/app/finders/epics_finder.rb @@ -28,6 +28,7 @@ # starts_with_iid: string (containing a number) # confidential: boolean # hierarchy_order: :desc or :acs, default :acs when searched by child_id +# top_level_hierarchy_only: boolean class EpicsFinder < IssuableFinder include TimeFrameFilter @@ -186,9 +187,13 @@ def child_id? # rubocop: disable CodeReuse/ActiveRecord def by_parent(items) - return items unless parent_id? - - items.where(parent_id: params[:parent_id]) + if top_level_only? && !parent_id? + items.where(parent_id: nil) + elsif parent_id? + items.where(parent_id: params[:parent_id]) + else + items + end end # rubocop: enable CodeReuse/ActiveRecord @@ -295,4 +300,8 @@ def include_descendants def include_ancestors @include_ancestors ||= params.fetch(:include_ancestor_groups, false) end + + def top_level_only? + params.fetch(:top_level_hierarchy_only, false) + end end diff --git a/ee/app/graphql/resolvers/epics_resolver.rb b/ee/app/graphql/resolvers/epics_resolver.rb index dec3d5b42d23511dfd3bee9da9bddfb0d79bef78..66c6706534ea90cbbd1fa6d1383937cf5a38aee4 100644 --- a/ee/app/graphql/resolvers/epics_resolver.rb +++ b/ee/app/graphql/resolvers/epics_resolver.rb @@ -77,6 +77,10 @@ class EpicsResolver < BaseResolver required: false, description: 'Negated epic arguments.' + argument :top_level_hierarchy_only, GraphQL::Types::Boolean, + required: false, + description: 'Filter epics with a top-level hierarchy.' + type Types::EpicType, null: true def ready?(**args) diff --git a/ee/spec/finders/epics_finder_spec.rb b/ee/spec/finders/epics_finder_spec.rb index b58edd7750f86f45e84649181c2d9f61dda59789..7ac9086d04c01b9f224b4bc521f70556004a28d4 100644 --- a/ee/spec/finders/epics_finder_spec.rb +++ b/ee/spec/finders/epics_finder_spec.rb @@ -428,6 +428,25 @@ def epics(params = {}) expect(epics(params)).to contain_exactly(epic2) end + + context 'when `top_level_hierarchy_only` param is true' do + let_it_be(:epic6) { create(:epic, group: group) } + + it 'returns only top level epics' do + top_level_epics = epics({ top_level_hierarchy_only: true }) + + expect(top_level_epics).to contain_exactly(epic1, epic6) + expect(top_level_epics.collect(&:parent_id).any?).to be_falsey + end + + context 'when `parent_id` param is present' do + it 'ignores top_level_hierarchy_only param and returns direct children of the parent' do + params = { top_level_hierarchy_only: true, parent_id: epic1.id } + + expect(epics(params)).to contain_exactly(epic2) + end + end + end end context 'by child' do diff --git a/ee/spec/graphql/resolvers/epics_resolver_spec.rb b/ee/spec/graphql/resolvers/epics_resolver_spec.rb index 6379fe16bae838332ebc8bdd2d66daa124637de5..01a0c2b61bede0cae59c85f2518444fb9f53f02b 100644 --- a/ee/spec/graphql/resolvers/epics_resolver_spec.rb +++ b/ee/spec/graphql/resolvers/epics_resolver_spec.rb @@ -352,6 +352,23 @@ expect(epics).to contain_exactly(epic5) end end + + context 'with `top_level_hierarchy_only` param set as `true`' do + let(:args) { { top_level_hierarchy_only: true } } + + let_it_be(:child_epic) { create(:epic, group: group, title: 'child epic', parent: epic1) } + let_it_be(:child_epic2) { create(:epic, group: group, title: 'child epic 2', parent: epic1) } + + it { expect(resolve_epics(args)).to contain_exactly(epic1, epic2) } + + context 'when a parent epic is present' do + subject(:results) { resolve_epics(args, epic1) } + + it 'ignores `top_level_hierarchy_only` param and return all children of the given epic' do + expect(results).to contain_exactly(child_epic, child_epic2) + end + end + end end context 'with negated filters' do diff --git a/ee/spec/requests/api/graphql/group/epics_spec.rb b/ee/spec/requests/api/graphql/group/epics_spec.rb index 5dccb914bcbe973b660352ed23b7817419cf0fb1..8dacd080039f6f32c5772eddacf9aef4d451f395 100644 --- a/ee/spec/requests/api/graphql/group/epics_spec.rb +++ b/ee/spec/requests/api/graphql/group/epics_spec.rb @@ -244,6 +244,26 @@ def global_ids(*epics) end end + context 'with top_level_hierarchy_only argument' do + let_it_be(:child_epic) { create(:epic, group: group, parent: epic2) } + + it 'returns only top level matching epics when set as `true`' do + graphql_query = query({ top_level_hierarchy_only: true }) + + post_graphql(graphql_query, current_user: user) + + expect_array_response([epic2.to_global_id.to_s, epic.to_global_id.to_s]) + end + + it 'returns all matching epics when set as `false' do + graphql_query = query({ top_level_hierarchy_only: false }) + + post_graphql(graphql_query, current_user: user) + + expect_array_response([child_epic.to_global_id.to_s, epic2.to_global_id.to_s, epic.to_global_id.to_s]) + end + end + context 'filter' do context 'with search params' do it 'returns only matching epics' do