Commit a4435d48 authored by Jan Provaznik's avatar Jan Provaznik

Added more tests and minor cleanups

* added resolver spec
* minor fixes in the board list servicer and resolver
* added input arguments for issue filtering
* updated the query to get epics
parent f8f2c905
...@@ -45,8 +45,11 @@ module Boards ...@@ -45,8 +45,11 @@ module Boards
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
def filter(issues) def filter(issues)
# sometimes we want to just fetch all issues that a board can display # when grouping board issues by epics (used in board swimlanes)
return issues if params[:all] # we need to get all issues in the board
# TODO: ignore hidden columns -
# https://gitlab.com/gitlab-org/gitlab/-/issues/233870
return issues if params[:all_lists]
issues = without_board_labels(issues) unless list&.movable? || list&.closed? issues = without_board_labels(issues) unless list&.movable? || list&.closed?
issues = with_list_label(issues) if list&.label? issues = with_list_label(issues) if list&.label?
...@@ -90,6 +93,8 @@ module Boards ...@@ -90,6 +93,8 @@ module Boards
end end
def set_state def set_state
return if params[:all_lists]
params[:state] = list && list.closed? ? 'closed' : 'opened' params[:state] = list && list.closed? ? 'closed' : 'opened'
end end
......
...@@ -997,6 +997,36 @@ type Board { ...@@ -997,6 +997,36 @@ type Board {
""" """
assignee: User assignee: User
"""
Epics associated with board issues.
"""
epics(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Filters applied when selecting issues on the board
"""
issueFilters: BoardEpicIssueInput
"""
Returns the last _n_ elements from the list.
"""
last: Int
): EpicConnection
""" """
Whether or not backlog list is hidden. Whether or not backlog list is hidden.
""" """
...@@ -1093,6 +1123,48 @@ type BoardEdge { ...@@ -1093,6 +1123,48 @@ type BoardEdge {
node: Board node: Board
} }
input BoardEpicIssueInput {
"""
Username of a user assigned to issues
"""
assigneeUsername: [String]
"""
Username of the issues author
"""
authorUsername: String
"""
Epic ID applied to issues
"""
epicId: String
"""
Label applied to issues
"""
labelName: [String]
"""
Milestone applied to issues
"""
milestoneTitle: String
"""
Reaction emoji applied to issues
"""
myReactionEmoji: String
"""
Release applied to issues
"""
releaseTag: String
"""
Weight applied to issues
"""
weight: String
}
""" """
Represents a list for an issue board Represents a list for an issue board
""" """
......
...@@ -2675,6 +2675,69 @@ ...@@ -2675,6 +2675,69 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "epics",
"description": "Epics associated with board issues.",
"args": [
{
"name": "issueFilters",
"description": "Filters applied when selecting issues on the board",
"type": {
"kind": "INPUT_OBJECT",
"name": "BoardEpicIssueInput",
"ofType": null
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "EpicConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "hideBacklogList", "name": "hideBacklogList",
"description": "Whether or not backlog list is hidden.", "description": "Whether or not backlog list is hidden.",
...@@ -2946,6 +3009,105 @@ ...@@ -2946,6 +3009,105 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "INPUT_OBJECT",
"name": "BoardEpicIssueInput",
"description": null,
"fields": null,
"inputFields": [
{
"name": "labelName",
"description": "Label applied to issues",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "milestoneTitle",
"description": "Milestone applied to issues",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "assigneeUsername",
"description": "Username of a user assigned to issues",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "authorUsername",
"description": "Username of the issues author",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "releaseTag",
"description": "Release applied to issues",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "epicId",
"description": "Epic ID applied to issues",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "myReactionEmoji",
"description": "Reaction emoji applied to issues",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "weight",
"description": "Weight applied to issues",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "BoardList", "name": "BoardList",
...@@ -108,7 +108,6 @@ class EpicsFinder < IssuableFinder ...@@ -108,7 +108,6 @@ class EpicsFinder < IssuableFinder
end end
def filter_items(items) def filter_items(items)
items = in_issues(items)
items = by_created_at(items) items = by_created_at(items)
items = by_updated_at(items) items = by_updated_at(items)
items = by_author(items) items = by_author(items)
...@@ -155,15 +154,6 @@ class EpicsFinder < IssuableFinder ...@@ -155,15 +154,6 @@ class EpicsFinder < IssuableFinder
items.iid_starts_with(query) items.iid_starts_with(query)
end end
def in_issues(items)
issues = params[:issues]
# ActiveRecord::Relation.empty? would run a SELECT 1 AS one FROM "issues" LIMIT $1
# ActiveRecord::Relation.blank? or ActiveRecord::Relation.present? would run SELECT "issues".* FROM "issues"
return items if issues.nil? || issues.empty?
items.in_issues(issues.select(:id))
end
def related_groups def related_groups
include_ancestors = params.fetch(:include_ancestor_groups, false) include_ancestors = params.fetch(:include_ancestor_groups, false)
include_descendants = params.fetch(:include_descendant_groups, true) include_descendants = params.fetch(:include_descendant_groups, true)
......
...@@ -23,7 +23,8 @@ module EE ...@@ -23,7 +23,8 @@ module EE
field :epics, ::Types::EpicType.connection_type, null: true, field :epics, ::Types::EpicType.connection_type, null: true,
description: 'Epics associated with board issues.', description: 'Epics associated with board issues.',
resolver: ::Resolvers::BoardGroupings::EpicsResolver resolver: ::Resolvers::BoardGroupings::EpicsResolver,
complexity: 5
end end
end end
end end
......
...@@ -3,36 +3,47 @@ ...@@ -3,36 +3,47 @@
module Resolvers module Resolvers
module BoardGroupings module BoardGroupings
class EpicsResolver < BaseResolver class EpicsResolver < BaseResolver
alias_method :board, :synchronized_object
argument :issue_filters, Types::BoardEpicIssueInputType,
required: false,
description: 'Filters applied when selecting issues on the board'
type Types::EpicType, null: true type Types::EpicType, null: true
def resolve(**args) def resolve(**args)
@board = object.respond_to?(:sync) ? object.sync : object
return Epic.none unless board.present? return Epic.none unless board.present?
return Epic.none unless epic_feature_enabled? return Epic.none unless group.present?
return unless ::Feature.enabled?(:boards_with_swimlanes, group)
list_service = Boards::Issues::ListService.new(board.resource_parent, current_user, { all: true, board_id: board.id })
# get bare issues by removing ordering, grouping and extra selected fields to get just the issues filtered by board scope. Epic.for_ids(board_epic_ids(args[:issue_filters]))
issues = list_service.execute.except(:order).except(:group).except(:select).distinct
# Depending on which level the board is, user can see epics related to issues from various groups in the hierarchy,
# so we need to look-up epics from all groups in the hierarchy.
board_params = { group_id: group.id, include_ancestor_groups: true, include_descendant_groups: true, issues: issues }
EpicsFinder.new(context[:current_user], args.merge(board_params)).execute.limit(10)
end end
private private
attr_accessor :board def board_epic_ids(issue_params)
params = issue_params.to_h.merge(all_lists: true, board_id: board.id)
list_service = Boards::Issues::ListService.new(
board.resource_parent,
current_user,
params
)
def group list_service.execute.in_epics(accessible_epics).distinct_epic_ids
board.project_board? ? board.project.group : board.group end
def accessible_epics
EpicsFinder.new(
context[:current_user],
group_id: group.id,
state: :opened,
include_ancestor_groups: true,
include_descendant_groups: board.group_board?
).execute
end end
def epic_feature_enabled? def group
group.feature_available?(:epics) board.project_board? ? board.project.group : board.group
end end
end end
end end
......
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class BoardEpicIssueInputType < BaseInputObject
graphql_name 'BoardEpicIssueInput'
argument :label_name, GraphQL::STRING_TYPE.to_list_type,
required: false,
description: 'Label applied to issues'
argument :milestone_title, GraphQL::STRING_TYPE,
required: false,
description: 'Milestone applied to issues'
argument :assignee_username, GraphQL::STRING_TYPE.to_list_type,
required: false,
description: 'Username of a user assigned to issues'
argument :author_username, GraphQL::STRING_TYPE,
required: false,
description: 'Username of the issues author'
argument :release_tag, GraphQL::STRING_TYPE,
required: false,
description: 'Release applied to issues'
argument :epic_id, GraphQL::STRING_TYPE,
required: false,
description: 'Epic ID applied to issues'
argument :my_reaction_emoji, GraphQL::STRING_TYPE,
required: false,
description: 'Reaction emoji applied to issues'
argument :weight, GraphQL::STRING_TYPE,
required: false,
description: 'Weight applied to issues'
end
# rubocop: enable Graphql/AuthorizeTypes
end
...@@ -36,6 +36,12 @@ module EE ...@@ -36,6 +36,12 @@ module EE
end end
scope :counts_by_health_status, -> { reorder(nil).group(:health_status).count } scope :counts_by_health_status, -> { reorder(nil).group(:health_status).count }
scope :with_health_status, -> { where.not(health_status: nil) } scope :with_health_status, -> { where.not(health_status: nil) }
scope :distinct_epic_ids, -> do
epic_ids = except(:order, :select).joins(:epic_issue).reselect('epic_issues.epic_id').distinct
epic_ids = epic_ids.group('epic_issues.epic_id') if epic_ids.group_values.present?
epic_ids
end
has_one :epic_issue has_one :epic_issue
has_one :epic, through: :epic_issue has_one :epic, through: :epic_issue
......
---
title: Expose epics related to board issues in GraphQL.
merge_request: 36186
author:
type: added
...@@ -459,26 +459,6 @@ RSpec.describe EpicsFinder do ...@@ -459,26 +459,6 @@ RSpec.describe EpicsFinder do
end end
end end
context 'by issue board' do
let_it_be(:epic1) { create(:epic, group: group, title: "first epic") }
let_it_be(:epic2) { create(:epic, group: group, title: "second epic") }
let_it_be(:label) { create(:group_label, group: group, name: 'some label') }
let_it_be(:project) { create(:project, namespace: group) }
let_it_be(:board) { create(:board, group: group) }
let_it_be(:label_list) { create(:list, board: board, label: label) }
let_it_be(:backlog_list) { create(:backlog_list, board: board) }
let_it_be(:issue) { create(:labeled_issue, project: project, labels: [label]) }
let_it_be(:epic_issue) { create(:epic_issue, epic: epic1, issue: issue) }
it 'returns epics that are in the board' do
params = {
board_id: board.id
}
expect(epics(params)).to contain_exactly(epic1)
end
end
context 'when using group cte for search' do context 'when using group cte for search' do
context 'and two labels more search string are present' do context 'and two labels more search string are present' do
let_it_be(:label1) { create(:label) } let_it_be(:label1) { create(:label) }
......
...@@ -4,6 +4,6 @@ require 'spec_helper' ...@@ -4,6 +4,6 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['Board'] do RSpec.describe GitlabSchema.types['Board'] do
it 'includes the ee specific fields' do it 'includes the ee specific fields' do
expect(described_class).to have_graphql_fields(:weight, :epic_groups) expect(described_class).to have_graphql_fields(:weight, :epics).at_least
end end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::BoardGroupings::EpicsResolver do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:parent_group) { create(:group) }
let_it_be(:group) { create(:group, parent: parent_group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:other_project) { create(:project, group: group) }
let_it_be(:board) { create(:board, project: project) }
let_it_be(:group_board) { create(:board, group: group) }
let_it_be(:label) { create(:label, project: project, name: 'foo') }
let_it_be(:list) { create(:list, board: board, label: label) }
let_it_be(:issue1) { create(:issue, project: project, labels: [label]) }
let_it_be(:issue2) { create(:issue, project: project) }
let_it_be(:issue3) { create(:issue, project: other_project) }
let_it_be(:epic1) { create(:epic, group: parent_group) }
let_it_be(:epic2) { create(:epic, group: group) }
let_it_be(:epic3) { create(:epic, group: group) }
let_it_be(:epic_issue1) { create(:epic_issue, epic: epic1, issue: issue1) }
let_it_be(:epic_issue2) { create(:epic_issue, epic: epic2, issue: issue2) }
let_it_be(:epic_issue3) { create(:epic_issue, epic: epic3, issue: issue3) }
describe '#resolve' do
before do
stub_licensed_features(epics: true)
end
context 'when user can not see epics' do
it 'does not return epics' do
result = resolve_board_epics(board)
expect(result).to match_array([])
end
end
context 'when boards_with_swimlanes is disabled' do
before do
stub_feature_flags(boards_with_swimlanes: false)
end
it 'returns nil' do
result = resolve_board_epics(board)
expect(result).to be_nil
end
end
context 'when user can access the group' do
before do
group.add_developer(current_user)
end
it 'finds all epics for issues in the project board' do
result = resolve_board_epics(board)
expect(result).to match_array([epic1, epic2])
end
it 'finds all epics for issues in the group board' do
result = resolve_board_epics(group_board)
expect(result).to match_array([epic1, epic2, epic3])
end
it 'finds only epics for issues matching issue filters' do
result = resolve_board_epics(group_board, { issue_filters: { label_name: label.title } })
expect(result).to match_array([epic1])
end
end
end
def resolve_board_epics(object, args = {}, context = { current_user: current_user })
resolve(described_class, obj: object, args: args, ctx: context)
end
end
...@@ -129,6 +129,24 @@ RSpec.describe Issue do ...@@ -129,6 +129,24 @@ RSpec.describe Issue do
expect(described_class.in_epics([epic1])).to eq [epic_issue1.issue] expect(described_class.in_epics([epic1])).to eq [epic_issue1.issue]
end end
end end
describe '.distinct_epic_ids' do
it 'returns distinct epic ids' do
expect(described_class.distinct_epic_ids.map(&:epic_id)).to match_array([epic1.id, epic2.id])
end
context 'when issues are grouped by labels' do
let_it_be(:label_link1) { create(:label_link, target: epic_issue1.issue) }
let_it_be(:label_link2) { create(:label_link, target: epic_issue1.issue) }
it 'respects query grouping and returns distinct epic ids' do
ids = described_class.with_label(
[label_link1.label.title, label_link2.label.title]
).distinct_epic_ids.map(&:epic_id)
expect(ids).to eq([epic1.id])
end
end
end
end end
context 'iterations' do context 'iterations' do
......
...@@ -7,13 +7,70 @@ RSpec.describe 'get list of boards' do ...@@ -7,13 +7,70 @@ RSpec.describe 'get list of boards' do
include_context 'group and project boards query context' include_context 'group and project boards query context'
let_it_be(:parent_group) { create(:group) }
let_it_be(:label) { create(:group_label, group: parent_group) }
before do before do
stub_licensed_features(multiple_group_issue_boards: true) stub_licensed_features(multiple_group_issue_boards: true, epics: true)
end
shared_examples 'a board epics query' do
before do
parent_group.add_developer(current_user)
end
def board_epic_query(board)
epic_query = <<~EPIC
epics(issueFilters: {labelName: "#{label.title}"}) {
nodes {
id
title
}
}
EPIC
graphql_query_for(
board_parent_type,
{ 'fullPath' => board_parent.full_path },
query_graphql_field(
'board', { id: global_id_of(board) },
epic_query
)
)
end
it 'returns open epics referenced by issues in the board' do
board = create(:board, resource_parent: board_parent)
issue_project = board_parent.is_a?(Project) ? board_parent : create(:project, group: board_parent)
issue1 = create(:issue, project: issue_project, labels: [label])
issue2 = create(:issue, project: issue_project, labels: [label])
issue3 = create(:issue, project: issue_project)
issue4 = create(:issue, project: issue_project, labels: [label])
epic1 = create(:epic, group: parent_group)
epic2 = create(:epic, group: parent_group)
epic3 = create(:epic, :closed, group: parent_group)
create(:epic_issue, issue: issue1, epic: epic1)
create(:epic_issue, issue: issue2, epic: epic1)
create(:epic_issue, issue: issue3, epic: epic2)
create(:epic_issue, issue: issue4, epic: epic3)
post_graphql(board_epic_query(board), current_user: current_user)
board_titles = board_data['epics']['nodes'].map { |node| node['title'] }
expect(board_titles).to match_array [epic1.title]
end
end
describe 'for a project' do
let_it_be(:board_parent) { create(:project, group: parent_group) }
it_behaves_like 'a board epics query'
end end
describe 'for a group' do describe 'for a group' do
let(:board_parent) { create(:group, :private) } let_it_be(:board_parent) { create(:group, :private, parent: parent_group) }
it_behaves_like 'group and project boards query' it_behaves_like 'group and project boards query'
it_behaves_like 'a board epics query'
end end
end end
...@@ -80,4 +80,17 @@ RSpec.shared_examples 'issues list service' do ...@@ -80,4 +80,17 @@ RSpec.shared_examples 'issues list service' do
expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound) expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound)
end end
end end
context 'when :all_lists is used' do
it 'returns issues from all lists' do
params = { board_id: board.id, all_lists: true }
issues = described_class.new(parent, user, params).execute
expected = [opened_issue2, reopened_issue1, opened_issue1, list1_issue1,
list1_issue2, list1_issue3, list2_issue1, closed_issue1,
closed_issue2, closed_issue3, closed_issue4, closed_issue5]
expect(issues).to match_array(expected)
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