Commit 47d801b1 authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch '235699-graphql-board-issue-filters' into 'master'

GraphQL: Board issue filters

See merge request gitlab-org/gitlab!40602
parents ab089c5e cce68968
......@@ -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.
"""
......@@ -5884,6 +5894,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
......@@ -10296,7 +10321,7 @@ type NamespaceIncreaseStorageTemporarilyPayload {
namespace: Namespace
}
input NegatedBoardEpicIssueInput {
input NegatedBoardIssueInput {
"""
Filter by assignee username
"""
......@@ -10308,9 +10333,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.",
......@@ -16457,6 +16477,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",
......@@ -30834,7 +30877,7 @@
},
{
"kind": "INPUT_OBJECT",
"name": "NegatedBoardEpicIssueInput",
"name": "NegatedBoardIssueInput",
"description": null,
"fields": null,
"inputFields": [
......@@ -30897,8 +30940,8 @@
"defaultValue": null
},
{
"name": "epicId",
"description": "Filter by epic ID",
"name": "myReactionEmoji",
"description": "Filter by reaction emoji",
"type": {
"kind": "SCALAR",
"name": "String",
......@@ -30907,11 +30950,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