Commit cce68968 authored by Jan Provaznik's avatar Jan Provaznik Committed by Bob Van Landuyt

Add issue filters for boards in GraphQL

Allows filtering issue boards by same filters
used also for filtering epics.
parent 59c6c59b
......@@ -2,12 +2,20 @@
module Resolvers
class BoardListIssuesResolver < BaseResolver
include BoardIssueFilterable
argument :filters, Types::Boards::BoardIssueInputType,
required: false,
description: 'Filters applied when selecting issues in the board list'
type Types::IssueType, null: true
alias_method :list, :object
def resolve(**args)
service = Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], { board_id: list.board.id, id: list.id })
filter_params = issue_filters(args[:filters]).merge(board_id: list.board.id, id: list.id)
service = Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params)
Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(service.execute)
end
......
# frozen_string_literal: true
module BoardIssueFilterable
extend ActiveSupport::Concern
private
def issue_filters(args)
filters = args.to_h
set_filter_values(filters)
if filters[:not]
filters[:not] = filters[:not].to_h
set_filter_values(filters[:not])
end
filters
end
def set_filter_values(filters)
end
end
::BoardIssueFilterable.prepend_if_ee('::EE::Resolvers::BoardIssueFilterable')
......@@ -41,7 +41,7 @@ module Types
list = self.object
user = context[:current_user]
Boards::Issues::ListService
::Boards::Issues::ListService
.new(list.board.resource_parent, user, board_id: list.board_id, id: list.id)
.metadata
end
......
# frozen_string_literal: true
module Types
module Boards
# rubocop: disable Graphql/AuthorizeTypes
class BoardIssueInputBaseType < BaseInputObject
argument :label_name, GraphQL::STRING_TYPE.to_list_type,
required: false,
description: 'Filter by label name'
argument :milestone_title, GraphQL::STRING_TYPE,
required: false,
description: 'Filter by milestone title'
argument :assignee_username, GraphQL::STRING_TYPE.to_list_type,
required: false,
description: 'Filter by assignee username'
argument :author_username, GraphQL::STRING_TYPE,
required: false,
description: 'Filter by author username'
argument :release_tag, GraphQL::STRING_TYPE,
required: false,
description: 'Filter by release tag'
argument :my_reaction_emoji, GraphQL::STRING_TYPE,
required: false,
description: 'Filter by reaction emoji'
end
# rubocop: enable Graphql/AuthorizeTypes
end
end
Types::Boards::BoardIssueInputBaseType.prepend_if_ee('::EE::Types::Boards::BoardIssueInputBaseType')
# frozen_string_literal: true
module Types
module Boards
# rubocop: disable Graphql/AuthorizeTypes
class NegatedBoardIssueInputType < BoardIssueInputBaseType
end
class BoardIssueInputType < BoardIssueInputBaseType
graphql_name 'BoardIssueInput'
argument :not, NegatedBoardIssueInputType,
required: false,
description: 'List of negated params. Warning: this argument is experimental and a subject to change in future'
argument :search, GraphQL::STRING_TYPE,
required: false,
description: 'Search query for issue title or description'
end
# rubocop: enable Graphql/AuthorizeTypes
end
end
Types::Boards::BoardIssueInputType.prepend_if_ee('::EE::Types::Boards::BoardIssueInputType')
---
title: Add issue filters when listing board issues in GraphQL
merge_request: 40602
author:
type: added
......@@ -1029,7 +1029,7 @@ type Board {
"""
Filters applied when selecting issues on the board
"""
issueFilters: BoardEpicIssueInput
issueFilters: BoardIssueInput
"""
Returns the last _n_ elements from the list.
......@@ -1133,7 +1133,12 @@ type BoardEdge {
node: Board
}
input BoardEpicIssueInput {
"""
Identifier of Board
"""
scalar BoardID
input BoardIssueInput {
"""
Filter by assignee username
"""
......@@ -1145,9 +1150,14 @@ input BoardEpicIssueInput {
authorUsername: String
"""
Filter by epic ID
Filter by epic ID. Incompatible with epicWildcardId
"""
epicId: ID
"""
Filter by epic ID wildcard. Incompatible with epicId
"""
epicId: String
epicWildcardId: EpicWildcardId
"""
Filter by label name
......@@ -1167,7 +1177,7 @@ input BoardEpicIssueInput {
"""
List of negated params. Warning: this argument is experimental and a subject to change in future
"""
not: NegatedBoardEpicIssueInput
not: NegatedBoardIssueInput
"""
Filter by release tag
......@@ -1185,11 +1195,6 @@ input BoardEpicIssueInput {
weight: String
}
"""
Identifier of Board
"""
scalar BoardID
"""
Represents a list for an issue board
"""
......@@ -1223,6 +1228,11 @@ type BoardList {
"""
before: String
"""
Filters applied when selecting issues in the board list
"""
filters: BoardIssueInput
"""
Returns the first _n_ elements from the list.
"""
......@@ -5874,6 +5884,21 @@ type EpicTreeReorderPayload {
errors: [String!]!
}
"""
Epic ID wildcard values
"""
enum EpicWildcardId {
"""
Any epic is assigned
"""
ANY
"""
No epic is assigned
"""
NONE
}
type GeoNode {
"""
The maximum concurrency of container repository sync for this secondary node
......@@ -10226,7 +10251,7 @@ type NamespaceIncreaseStorageTemporarilyPayload {
namespace: Namespace
}
input NegatedBoardEpicIssueInput {
input NegatedBoardIssueInput {
"""
Filter by assignee username
"""
......@@ -10238,9 +10263,9 @@ input NegatedBoardEpicIssueInput {
authorUsername: String
"""
Filter by epic ID
Filter by epic ID. Incompatible with epicWildcardId
"""
epicId: String
epicId: ID
"""
Filter by label name
......
......@@ -2716,7 +2716,7 @@
"description": "Filters applied when selecting issues on the board",
"type": {
"kind": "INPUT_OBJECT",
"name": "BoardEpicIssueInput",
"name": "BoardIssueInput",
"ofType": null
},
"defaultValue": null
......@@ -3041,9 +3041,19 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "BoardID",
"description": "Identifier of Board",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "BoardEpicIssueInput",
"name": "BoardIssueInput",
"description": null,
"fields": null,
"inputFields": [
......@@ -3106,8 +3116,8 @@
"defaultValue": null
},
{
"name": "epicId",
"description": "Filter by epic ID",
"name": "myReactionEmoji",
"description": "Filter by reaction emoji",
"type": {
"kind": "SCALAR",
"name": "String",
......@@ -3116,11 +3126,11 @@
"defaultValue": null
},
{
"name": "myReactionEmoji",
"description": "Filter by reaction emoji",
"name": "epicId",
"description": "Filter by epic ID. Incompatible with epicWildcardId",
"type": {
"kind": "SCALAR",
"name": "String",
"name": "ID",
"ofType": null
},
"defaultValue": null
......@@ -3140,7 +3150,7 @@
"description": "List of negated params. Warning: this argument is experimental and a subject to change in future",
"type": {
"kind": "INPUT_OBJECT",
"name": "NegatedBoardEpicIssueInput",
"name": "NegatedBoardIssueInput",
"ofType": null
},
"defaultValue": null
......@@ -3154,22 +3164,22 @@
"ofType": null
},
"defaultValue": null
},
{
"name": "epicWildcardId",
"description": "Filter by epic ID wildcard. Incompatible with epicId",
"type": {
"kind": "ENUM",
"name": "EpicWildcardId",
"ofType": null
},
"defaultValue": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "BoardID",
"description": "Identifier of Board",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "BoardList",
......@@ -3225,6 +3235,16 @@
"name": "issues",
"description": "Board issues",
"args": [
{
"name": "filters",
"description": "Filters applied when selecting issues in the board list",
"type": {
"kind": "INPUT_OBJECT",
"name": "BoardIssueInput",
"ofType": null
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
......@@ -16437,6 +16457,29 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "EpicWildcardId",
"description": "Epic ID wildcard values",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "NONE",
"description": "No epic is assigned",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "ANY",
"description": "Any epic is assigned",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "Float",
......@@ -30608,7 +30651,7 @@
},
{
"kind": "INPUT_OBJECT",
"name": "NegatedBoardEpicIssueInput",
"name": "NegatedBoardIssueInput",
"description": null,
"fields": null,
"inputFields": [
......@@ -30671,8 +30714,8 @@
"defaultValue": null
},
{
"name": "epicId",
"description": "Filter by epic ID",
"name": "myReactionEmoji",
"description": "Filter by reaction emoji",
"type": {
"kind": "SCALAR",
"name": "String",
......@@ -30681,11 +30724,11 @@
"defaultValue": null
},
{
"name": "myReactionEmoji",
"description": "Filter by reaction emoji",
"name": "epicId",
"description": "Filter by epic ID. Incompatible with epicWildcardId",
"type": {
"kind": "SCALAR",
"name": "String",
"name": "ID",
"ofType": null
},
"defaultValue": null
......@@ -4,7 +4,7 @@
query BoardEE(
$fullPath: ID!
$boardId: ID!
$issueFilters: BoardEpicIssueInput
$issueFilters: BoardIssueInput
$withLists: Boolean = true
$isGroup: Boolean = false
$isProject: Boolean = false
......
# frozen_string_literal: true
module EE
module Resolvers
module BoardIssueFilterable
extend ActiveSupport::Concern
extend ::Gitlab::Utils::Override
override :set_filter_values
def set_filter_values(filters)
epic_id = filters.delete(:epic_id)
epic_wildcard_id = filters.delete(:epic_wildcard_id)
if epic_id && epic_wildcard_id
raise ::Gitlab::Graphql::Errors::ArgumentError, 'Incompatible arguments: epicId, epicWildcardId.'
end
if epic_id
filters[:epic_id] = ::GitlabSchema.parse_gid(epic_id, expected_type: ::Epic).model_id
elsif epic_wildcard_id
filters[:epic_id] = epic_wildcard_id
end
end
end
end
end
# frozen_string_literal: true
module EE
module Types
module Boards
module BoardIssueInputBaseType
extend ActiveSupport::Concern
prepended do
argument :epic_id, GraphQL::ID_TYPE,
required: false,
description: 'Filter by epic ID. Incompatible with epicWildcardId'
argument :weight, GraphQL::STRING_TYPE,
required: false,
description: 'Filter by weight'
end
end
end
end
end
# frozen_string_literal: true
module EE
module Types
module Boards
module BoardIssueInputType
extend ActiveSupport::Concern
prepended do
# NONE/ANY epic filter can not be negated
argument :epic_wildcard_id, ::Types::Boards::EpicWildcardIdEnum,
required: false,
description: 'Filter by epic ID wildcard. Incompatible with epicId'
end
end
end
end
end
......@@ -3,9 +3,11 @@
module Resolvers
module BoardGroupings
class EpicsResolver < BaseResolver
include ::BoardIssueFilterable
alias_method :board, :synchronized_object
argument :issue_filters, Types::BoardEpicIssueInputType,
argument :issue_filters, Types::Boards::BoardIssueInputType,
required: false,
description: 'Filters applied when selecting issues on the board'
......@@ -16,16 +18,15 @@ module Resolvers
return Epic.none unless group.present?
return unless ::Feature.enabled?(:boards_with_swimlanes, group)
Epic.for_ids(board_epic_ids(args[:issue_filters].to_h))
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)
params[:not] = params[:not].to_h if params[:not].present?
params = issue_filters(issue_params).merge(all_lists: true, board_id: board.id)
list_service = Boards::Issues::ListService.new(
list_service = ::Boards::Issues::ListService.new(
board.resource_parent,
current_user,
params
......
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class BoardEpicIssueInputBaseType < BaseInputObject
argument :label_name, GraphQL::STRING_TYPE.to_list_type,
required: false,
description: 'Filter by label name'
argument :milestone_title, GraphQL::STRING_TYPE,
required: false,
description: 'Filter by milestone title'
argument :assignee_username, GraphQL::STRING_TYPE.to_list_type,
required: false,
description: 'Filter by assignee username'
argument :author_username, GraphQL::STRING_TYPE,
required: false,
description: 'Filter by author username'
argument :release_tag, GraphQL::STRING_TYPE,
required: false,
description: 'Filter by release tag'
argument :epic_id, GraphQL::STRING_TYPE,
required: false,
description: 'Filter by epic ID'
argument :my_reaction_emoji, GraphQL::STRING_TYPE,
required: false,
description: 'Filter by reaction emoji'
argument :weight, GraphQL::STRING_TYPE,
required: false,
description: 'Filter by weight'
end
class NegatedBoardEpicIssueInputType < BoardEpicIssueInputBaseType
end
class BoardEpicIssueInputType < BoardEpicIssueInputBaseType
graphql_name 'BoardEpicIssueInput'
argument :not, Types::NegatedBoardEpicIssueInputType,
required: false,
description: 'List of negated params. Warning: this argument is experimental and a subject to change in future'
argument :search, GraphQL::STRING_TYPE,
required: false,
description: 'Search query for issue title or description'
end
# rubocop: enable Graphql/AuthorizeTypes
end
# frozen_string_literal: true
module Types
module Boards
class EpicWildcardIdEnum < BaseEnum
graphql_name 'EpicWildcardId'
description 'Epic ID wildcard values'
value 'NONE', 'No epic is assigned'
value 'ANY', 'Any epic is assigned'
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::BoardListIssuesResolver do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :private) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:board) { create(:board, project: project) }
let_it_be(:label) { create(:label, project: project) }
let_it_be(:list) { create(:list, board: board, label: label) }
let_it_be(:issue) { create(:issue, project: project, labels: [label]) }
let_it_be(:epic) { create(:epic, group: group) }
let_it_be(:epic_issue) { create(:epic_issue, epic: epic, issue: issue) }
describe '#resolve' do
before do
stub_licensed_features(epics: true)
group.add_developer(user)
end
it 'raises an exception if both epic_id and epic_wildcard_id are present' do
expect do
resolve_board_list_issues({ filters: { epic_id: epic.to_global_id, epic_wildcard_id: 'NONE' } })
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
end
it 'accepts epic global id' do
result = resolve_board_list_issues({ filters: { epic_id: epic.to_global_id } }).items
expect(result).to match_array([issue])
end
it 'accepts epic wildcard id' do
result = resolve_board_list_issues({ filters: { epic_wildcard_id: 'NONE' } }).items
expect(result).to match_array([])
end
end
def resolve_board_list_issues(args)
resolve(described_class, obj: list, args: args, ctx: { current_user: user })
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['BoardIssueInput'] do
it 'has specific fields' do
allowed_args = %w(epicId epicWildcardId weight)
expect(described_class.arguments.keys).to include(*allowed_args)
end
end
......@@ -94,6 +94,26 @@ RSpec.describe Resolvers::BoardGroupings::EpicsResolver do
resolve_board_epics(group_board, { issue_filters: { label_name: 'foo', not: { label_name: %w(foo bar) } } })
end
it 'raises an exception if both epic_id and epic_wildcard_id are present' do
expect do
resolve_board_epics(group_board, { issue_filters: { epic_id: epic1.to_global_id, epic_wildcard_id: 'NONE' } })
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
end
it 'accepts epic global id' do
result = resolve_board_epics(
group_board, { issue_filters: { epic_id: epic1.to_global_id } })
expect(result).to match_array([epic1])
end
it 'accepts epic wildcard id' do
result = resolve_board_epics(
group_board, { issue_filters: { epic_wildcard_id: 'NONE' } })
expect(result).to match_array([])
end
end
end
......
......@@ -11,41 +11,59 @@ RSpec.describe Resolvers::BoardListIssuesResolver do
let_it_be(:group) { create(:group, :private) }
shared_examples_for 'group and project board list issues resolver' do
let!(:board) { create(:board, resource_parent: board_parent) }
before do
board_parent.add_developer(user)
end
# auth is handled by the parent object
context 'when authorized' do
let!(:list) { create(:list, board: board, label: label) }
let!(:issue1) { create(:issue, project: project, labels: [label], relative_position: 10) }
let!(:issue2) { create(:issue, project: project, labels: [label, label2], relative_position: 12) }
let!(:issue3) { create(:issue, project: project, labels: [label, label3], relative_position: 10) }
it 'returns the issues in the correct order' do
issue1 = create(:issue, project: project, labels: [label], relative_position: 10)
issue2 = create(:issue, project: project, labels: [label], relative_position: 12)
issue3 = create(:issue, project: project, labels: [label], relative_position: 10)
# by relative_position and then ID
issues = resolve_board_list_issues.items
expect(issues.map(&:id)).to eq [issue3.id, issue1.id, issue2.id]
end
it 'finds only issues matching filters' do
result = resolve_board_list_issues(args: { filters: { label_name: label.title, not: { label_name: label2.title } } }).items
expect(result).to match_array([issue1, issue3])
end
it 'finds only issues matching search param' do
result = resolve_board_list_issues(args: { filters: { search: issue1.title } }).items
expect(result).to match_array([issue1])
end
end
end
describe '#resolve' do
context 'when project boards' do
let_it_be(:label) { create(:label, project: user_project) }
let_it_be(:label2) { create(:label, project: user_project) }
let_it_be(:label3) { create(:label, project: user_project) }
let_it_be(:board) { create(:board, resource_parent: user_project) }
let_it_be(:list) { create(:list, board: board, label: label) }
let(:board_parent) { user_project }
let!(:label) { create(:label, project: project, name: 'project label') }
let(:project) { user_project }
it_behaves_like 'group and project board list issues resolver'
end
context 'when group boards' do
let_it_be(:label) { create(:group_label, group: group) }
let_it_be(:label2) { create(:group_label, group: group) }
let_it_be(:label3) { create(:group_label, group: group) }
let_it_be(:board) { create(:board, resource_parent: group) }
let_it_be(:list) { create(:list, board: board, label: label) }
let(:board_parent) { group }
let!(:label) { create(:group_label, group: group, name: 'group label') }
let!(:project) { create(:project, :private, group: group) }
it_behaves_like 'group and project board list issues resolver'
......
......@@ -2,14 +2,14 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['BoardEpicIssueInput'] do
it { expect(described_class.graphql_name).to eq('BoardEpicIssueInput') }
RSpec.describe GitlabSchema.types['BoardIssueInput'] do
it { expect(described_class.graphql_name).to eq('BoardIssueInput') }
it 'exposes negated issue arguments' do
allowed_args = %w(labelName milestoneTitle assigneeUsername authorUsername
releaseTag epicId myReactionEmoji weight not search)
releaseTag myReactionEmoji not search)
expect(described_class.arguments.keys).to match_array(allowed_args)
expect(described_class.arguments['not'].type).to eq(Types::NegatedBoardEpicIssueInputType)
expect(described_class.arguments.keys).to include(*allowed_args)
expect(described_class.arguments['not'].type).to eq(Types::Boards::NegatedBoardIssueInputType)
end
end
......@@ -30,7 +30,7 @@ RSpec.describe 'get board lists' do
nodes {
lists {
nodes {
issues {
issues(filters: {labelName: "#{label2.title}"}) {
count
nodes {
#{all_graphql_fields_for('issues'.classify)}
......@@ -51,8 +51,8 @@ RSpec.describe 'get board lists' do
shared_examples 'group and project board list issues query' do
let!(:board) { create(:board, resource_parent: board_parent) }
let!(:label_list) { create(:list, board: board, label: label, position: 10) }
let!(:issue1) { create(:issue, project: issue_project, labels: [label], relative_position: 9) }
let!(:issue2) { create(:issue, project: issue_project, labels: [label], relative_position: 2) }
let!(:issue1) { create(:issue, project: issue_project, labels: [label, label2], relative_position: 9) }
let!(:issue2) { create(:issue, project: issue_project, labels: [label, label2], relative_position: 2) }
let!(:issue3) { create(:issue, project: issue_project, labels: [label], relative_position: 9) }
let!(:issue4) { create(:issue, project: issue_project, labels: [label2], relative_position: 432) }
......@@ -72,7 +72,7 @@ RSpec.describe 'get board lists' do
it 'can access the issues' do
post_graphql(query("id: \"#{global_id_of(label_list)}\""), current_user: user)
expect(issue_titles).to eq([issue2.title, issue3.title, issue1.title])
expect(issue_titles).to eq([issue2.title, issue1.title])
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