Commit b719b6b0 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch '218040-graphql-expose-board-epics' into 'master'

Expose epic groupings in boards

See merge request gitlab-org/gitlab!36186
parents 313d4d0f a4435d48
......@@ -45,6 +45,12 @@ module Boards
# rubocop: enable CodeReuse/ActiveRecord
def filter(issues)
# when grouping board issues by epics (used in board swimlanes)
# 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 = with_list_label(issues) if list&.label?
issues
......@@ -87,6 +93,8 @@ module Boards
end
def set_state
return if params[:all_lists]
params[:state] = list && list.closed? ? 'closed' : 'opened'
end
......
......@@ -997,6 +997,36 @@ type Board {
"""
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.
"""
......@@ -1093,6 +1123,48 @@ type BoardEdge {
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
"""
......
......@@ -2675,6 +2675,69 @@
"isDeprecated": false,
"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",
"description": "Whether or not backlog list is hidden.",
......@@ -2946,6 +3009,105 @@
"enumValues": 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",
"name": "BoardList",
......@@ -43,7 +43,7 @@ class EpicsFinder < IssuableFinder
end
def self.array_params
@array_params ||= { label_name: [] }
@array_params ||= { issues: [], label_name: [] }
end
def self.valid_iid_query?(query)
......
......@@ -20,6 +20,11 @@ module EE
field :weight, type: GraphQL::INT_TYPE, null: true,
description: 'Weight of the board.'
field :epics, ::Types::EpicType.connection_type, null: true,
description: 'Epics associated with board issues.',
resolver: ::Resolvers::BoardGroupings::EpicsResolver,
complexity: 5
end
end
end
......
# frozen_string_literal: true
module Resolvers
module BoardGroupings
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
def resolve(**args)
return Epic.none unless board.present?
return Epic.none unless group.present?
return unless ::Feature.enabled?(:boards_with_swimlanes, group)
Epic.for_ids(board_epic_ids(args[:issue_filters]))
end
private
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
)
list_service.execute.in_epics(accessible_epics).distinct_epic_ids
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
def group
board.project_board? ? board.project.group : board.group
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
end
scope :counts_by_health_status, -> { reorder(nil).group(:health_status).count }
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, through: :epic_issue
......
---
title: Expose epics related to board issues in GraphQL.
merge_request: 36186
author:
type: added
......@@ -4,6 +4,6 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['Board'] do
it 'includes the ee specific fields' do
expect(described_class).to have_graphql_field('weight')
expect(described_class).to have_graphql_fields(:weight, :epics).at_least
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
expect(described_class.in_epics([epic1])).to eq [epic_issue1.issue]
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
context 'iterations' do
......
......@@ -7,13 +7,70 @@ RSpec.describe 'get list of boards' do
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
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
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 'a board epics query'
end
end
......@@ -80,4 +80,17 @@ RSpec.shared_examples 'issues list service' do
expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound)
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
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