Commit 5a83a8b6 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch '300115-support-negated-issue-filters-gql' into 'master'

Support negated filtering in IssueResolvers

See merge request gitlab-org/gitlab!58154
parents 19c6e0e8 c5f768a4
......@@ -395,8 +395,6 @@ class IssuableFinder
# We want CE users to be able to say "Issues not assigned to either PersonA nor PersonB"
if not_params.assignees.present?
items.not_assigned_to(not_params.assignees)
elsif not_params.assignee_id? || not_params.assignee_username? # assignee not found
items.none
else
items
end
......
......@@ -12,10 +12,10 @@ module IssueResolverArguments
argument :iids, [GraphQL::STRING_TYPE],
required: false,
description: 'List of IIDs of issues. For example, [1, 2].'
argument :label_name, GraphQL::STRING_TYPE.to_list_type,
argument :label_name, [GraphQL::STRING_TYPE, null: true],
required: false,
description: 'Labels applied to this issue.'
argument :milestone_title, GraphQL::STRING_TYPE.to_list_type,
argument :milestone_title, [GraphQL::STRING_TYPE, null: true],
required: false,
description: 'Milestone applied to this issue.'
argument :author_username, GraphQL::STRING_TYPE,
......@@ -55,6 +55,10 @@ module IssueResolverArguments
as: :issue_types,
description: 'Filter issues by the given issue types.',
required: false
argument :not, Types::Issues::NegatedIssueFilterInputType,
description: 'List of negated params.',
prepare: ->(negated_args, ctx) { negated_args.to_h },
required: false
end
def resolve_with_lookahead(**args)
......@@ -69,11 +73,22 @@ module IssueResolverArguments
args[:iids] ||= [args.delete(:iid)].compact if args[:iid]
args[:attempt_project_search_optimizations] = true if args[:search].present?
prepare_assignee_username_params(args)
finder = IssuesFinder.new(current_user, args)
continue_issue_resolve(parent, finder, **args)
end
def ready?(**args)
if args.slice(*mutually_exclusive_assignee_username_args).compact.size > 1
arg_str = mutually_exclusive_assignee_username_args.map { |x| x.to_s.camelize(:lower) }.join(', ')
raise Gitlab::Graphql::Errors::ArgumentError, "only one of [#{arg_str}] arguments is allowed at the same time."
end
super
end
class_methods do
def resolver_complexity(args, child_complexity:)
complexity = super
......@@ -82,4 +97,15 @@ module IssueResolverArguments
complexity
end
end
private
def prepare_assignee_username_params(args)
args[:assignee_username] = args.delete(:assignee_usernames) if args[:assignee_usernames].present?
args[:not][:assignee_username] = args[:not].delete(:assignee_usernames) if args.dig(:not, :assignee_usernames).present?
end
def mutually_exclusive_assignee_username_args
[:assignee_usernames, :assignee_username]
end
end
......@@ -4,7 +4,7 @@ module Types
module Boards
# Common arguments that we can be used to filter boards epics and issues
class BoardIssuableInputBaseType < BaseInputObject
argument :label_name, GraphQL::STRING_TYPE.to_list_type,
argument :label_name, [GraphQL::STRING_TYPE, null: true],
required: false,
description: 'Filter by label name.'
......
......@@ -8,7 +8,7 @@ module Types
required: false,
description: 'Filter by milestone title.'
argument :assignee_username, GraphQL::STRING_TYPE.to_list_type,
argument :assignee_username, [GraphQL::STRING_TYPE, null: true],
required: false,
description: 'Filter by assignee username.'
......
# frozen_string_literal: true
module Types
module Issues
class NegatedIssueFilterInputType < BaseInputObject
graphql_name 'NegatedIssueFilterInput'
argument :iids, [GraphQL::STRING_TYPE],
required: false,
description: 'List of IIDs of issues to exclude. For example, [1, 2].'
argument :label_name, [GraphQL::STRING_TYPE],
required: false,
description: 'Labels not applied to this issue.'
argument :milestone_title, [GraphQL::STRING_TYPE],
required: false,
description: 'Milestone not applied to this issue.'
argument :assignee_usernames, [GraphQL::STRING_TYPE],
required: false,
description: 'Usernames of users not assigned to the issue.'
argument :assignee_id, GraphQL::STRING_TYPE,
required: false,
description: 'ID of a user not assigned to the issues.'
end
end
end
Types::Issues::NegatedIssueFilterInputType.prepend_if_ee('::EE::Types::Issues::NegatedIssueFilterInputType')
---
title: Support negated filtering of issues by iids, label_name, milestone_title, assignee_usernames and assignee_id in GraphQL
merge_request: 58154
author:
type: added
......@@ -19,7 +19,7 @@ module EE
end
def weights?
params[:weight].present? && params[:weight] != ::Issue::WEIGHT_ALL
params[:weight].present? && params[:weight].to_s.casecmp(::Issue::WEIGHT_ALL) != 0
end
def filter_by_no_weight?
......
......@@ -7,13 +7,15 @@ module EE
extend ::Gitlab::Utils::Override
prepended do
argument :iteration_id, ::GraphQL::ID_TYPE.to_list_type,
argument :iteration_id, [::GraphQL::ID_TYPE, null: true],
required: false,
description: 'Iterations applied to the issue.'
argument :epic_id, GraphQL::STRING_TYPE,
required: false,
description: 'ID of an epic associated with the issues, "none" and "any" values are supported.'
argument :weight, GraphQL::STRING_TYPE,
required: false,
description: 'Weight applied to the issue, "none" and "any" values are supported.'
end
private
......
# frozen_string_literal: true
module EE
module Types
module Issues
module NegatedIssueFilterInputType
extend ActiveSupport::Concern
prepended do
argument :epic_id, GraphQL::STRING_TYPE,
required: false,
description: 'ID of an epic not associated with the issues.'
argument :weight, GraphQL::STRING_TYPE,
required: false,
description: 'Weight not applied to the issue.'
end
end
end
end
end
---
title: Support negated filtering of issues by epic_id and weight in GraphQL
merge_request: 58154
author:
type: added
......@@ -11,36 +11,39 @@ RSpec.describe Resolvers::IssuesResolver do
context "with a project" do
describe '#resolve' do
let_it_be(:epic1) { create :epic, group: group }
let_it_be(:epic2) { create :epic, group: group }
let_it_be(:iteration1) { create(:iteration, group: group, start_date: 2.weeks.ago, due_date: 1.week.ago) }
let_it_be(:current_iteration) { create(:iteration, :started, group: group, start_date: Date.today, due_date: 1.day.from_now) }
let_it_be(:issue1) { create :issue, project: project, epic: epic1, iteration: iteration1 }
let_it_be(:issue2) { create :issue, project: project, epic: epic2, weight: 1 }
let_it_be(:issue3) { create :issue, project: project, weight: 3, iteration: current_iteration }
let_it_be(:issue4) { create :issue, :published, project: project }
before do
project.add_developer(current_user)
end
describe 'sorting' do
context 'when sorting by weight' do
let_it_be(:weight_issue1) { create(:issue, project: project, weight: 5) }
let_it_be(:weight_issue2) { create(:issue, project: project, weight: nil) }
let_it_be(:weight_issue3) { create(:issue, project: project, weight: 1) }
let_it_be(:weight_issue4) { create(:issue, project: project, weight: nil) }
it 'sorts issues ascending' do
expect(resolve_issues(sort: :weight_asc).to_a).to eq [weight_issue3, weight_issue1, weight_issue4, weight_issue2]
expect(resolve_issues(sort: :weight_asc).to_a).to eq [issue2, issue3, issue4, issue1]
end
it 'sorts issues descending' do
expect(resolve_issues(sort: :weight_desc).to_a).to eq [weight_issue1, weight_issue3, weight_issue4, weight_issue2]
expect(resolve_issues(sort: :weight_desc).to_a).to eq [issue3, issue2, issue4, issue1]
end
end
context 'when sorting by published' do
let_it_be(:not_published) { create(:issue, project: project) }
let_it_be(:published) { create(:issue, :published, project: project) }
it 'sorts issues ascending' do
expect(resolve_issues(sort: :published_asc).to_a).to eq [not_published, published]
expect(resolve_issues(sort: :published_asc).to_a).to eq [issue3, issue2, issue1, issue4]
end
it 'sorts issues descending' do
expect(resolve_issues(sort: :published_desc).to_a).to eq [published, not_published]
expect(resolve_issues(sort: :published_desc).to_a).to eq [issue4, issue3, issue2, issue1]
end
end
......@@ -64,32 +67,56 @@ RSpec.describe Resolvers::IssuesResolver do
end
describe 'filtering by iteration' do
let_it_be(:iteration1) { create(:iteration, group: group) }
let_it_be(:issue_with_iteration) { create(:issue, project: project, iteration: iteration1) }
let_it_be(:issue_without_iteration) { create(:issue, project: project) }
it 'returns issues with iteration' do
expect(resolve_issues(iteration_id: [iteration1.id])).to contain_exactly(issue_with_iteration)
expect(resolve_issues(iteration_id: [iteration1.id.to_s])).to contain_exactly(issue1)
end
end
describe 'filter by epic' do
let_it_be(:epic) { create :epic, group: group }
let_it_be(:epic2) { create :epic, group: group }
let_it_be(:issue1) { create :issue, project: project, epic: epic }
let_it_be(:issue2) { create :issue, project: project, epic: epic2 }
let_it_be(:issue3) { create :issue, project: project }
it 'returns issues without epic when epic_id is "none"' do
expect(resolve_issues(epic_id: 'none')).to match_array([issue3])
expect(resolve_issues(epic_id: 'none')).to contain_exactly(issue4, issue3)
end
it 'returns issues with any epic when epic_id is "any"' do
expect(resolve_issues(epic_id: 'any')).to match_array([issue1, issue2])
expect(resolve_issues(epic_id: 'any')).to contain_exactly(issue1, issue2)
end
it 'returns issues with any epic when epic_id is specific' do
expect(resolve_issues(epic_id: epic.id)).to match_array([issue1])
expect(resolve_issues(epic_id: epic1.id.to_s)).to contain_exactly(issue1)
end
end
describe 'filter by weight' do
context 'when filtering by any weight' do
it 'only returns issues that have a weight assigned' do
expect(resolve_issues(weight: 'any')).to contain_exactly(issue2, issue3)
end
end
context 'when filtering by no weight' do
it 'only returns issues that have no weight assigned' do
expect(resolve_issues(weight: 'none')).to contain_exactly(issue1, issue4)
end
end
context 'when filtering by specific weight' do
it 'only returns issues that have the specified weight assigned' do
expect(resolve_issues(weight: '3')).to contain_exactly(issue3)
end
end
end
describe 'filtering by negated params' do
describe 'filter by negated epic' do
it 'returns issues without the specified epic_id' do
expect(resolve_issues(not: { epic_id: epic2.id.to_s })).to contain_exactly(issue1, issue3, issue4)
end
end
describe 'filtering by negated weight' do
it 'only returns issues that do not have the specified weight assigned' do
expect(resolve_issues(not: { weight: '3' })).to contain_exactly(issue1, issue2, issue4)
end
end
end
end
......
......@@ -49,6 +49,13 @@ RSpec.describe IssuesFinder do
let(:expected_issuables) { [issue3, issue4] }
end
context 'when assignee_id does not exist' do
it_behaves_like 'assignee NOT ID filter' do
let(:params) { { not: { assignee_id: -100 } } }
let(:expected_issuables) { [issue1, issue2, issue3, issue4, issue5] }
end
end
context 'filter by username' do
let_it_be(:user3) { create(:user) }
......@@ -71,6 +78,17 @@ RSpec.describe IssuesFinder do
let(:params) { { not: { assignee_username: [user.username, user2.username] } } }
let(:expected_issuables) { [issue3, issue4] }
end
context 'when assignee_username does not exist' do
it_behaves_like 'assignee NOT username filter' do
before do
issue2.assignees = [user2]
end
let(:params) { { not: { assignee_username: 'non_existent_username' } } }
let(:expected_issuables) { [issue1, issue2, issue3, issue4, issue5] }
end
end
end
it_behaves_like 'no assignee filter' do
......
......@@ -69,6 +69,14 @@ RSpec.describe Resolvers::IssueStatusCountsResolver do
expect(result.closed).to eq 1
end
context 'when both assignee_username and assignee_usernames are provided' do
it 'raises a mutually exclusive filter error' do
expect do
resolve_issue_status_counts(assignee_usernames: [current_user.username], assignee_username: current_user.username)
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'only one of [assigneeUsernames, assigneeUsername] arguments is allowed at the same time.')
end
end
private
def resolve_issue_status_counts(args = {}, context = { current_user: current_user })
......
......@@ -46,10 +46,6 @@ RSpec.describe Resolvers::IssuesResolver do
expect(resolve_issues(milestone_title: [milestone.title])).to contain_exactly(issue1)
end
it 'filters by assignee_username' do
expect(resolve_issues(assignee_username: [assignee.username])).to contain_exactly(issue2)
end
it 'filters by two assignees' do
assignee2 = create(:user)
issue2.update!(assignees: [assignee, assignee2])
......@@ -78,6 +74,24 @@ RSpec.describe Resolvers::IssuesResolver do
expect(resolve_issues(label_name: [label1.title, label2.title])).to contain_exactly(issue2)
end
describe 'filters by assignee_username' do
it 'filters by assignee_username' do
expect(resolve_issues(assignee_username: [assignee.username])).to contain_exactly(issue2)
end
it 'filters by assignee_usernames' do
expect(resolve_issues(assignee_usernames: [assignee.username])).to contain_exactly(issue2)
end
context 'when both assignee_username and assignee_usernames are provided' do
it 'raises a mutually exclusive filter error' do
expect do
resolve_issues(assignee_usernames: [assignee.username], assignee_username: assignee.username)
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'only one of [assigneeUsernames, assigneeUsername] arguments is allowed at the same time.')
end
end
end
describe 'filters by created_at' do
it 'filters by created_before' do
expect(resolve_issues(created_before: 2.hours.ago)).to contain_exactly(issue1)
......@@ -144,6 +158,29 @@ RSpec.describe Resolvers::IssuesResolver do
end
end
describe 'filters by negated params' do
it 'returns issues without the specified iids' do
expect(resolve_issues(not: { iids: [issue1.iid] })).to contain_exactly(issue2)
end
it 'returns issues without the specified label names' do
expect(resolve_issues(not: { label_name: [label1.title] })).to be_empty
expect(resolve_issues(not: { label_name: [label2.title] })).to contain_exactly(issue1)
end
it 'returns issues without the specified milestone' do
expect(resolve_issues(not: { milestone_title: [milestone.title] })).to contain_exactly(issue2)
end
it 'returns issues without the specified assignee_usernames' do
expect(resolve_issues(not: { assignee_usernames: [assignee.username] })).to contain_exactly(issue1)
end
it 'returns issues without the specified assignee_id' do
expect(resolve_issues(not: { assignee_id: [assignee.id] })).to contain_exactly(issue1)
end
end
describe 'sorting' do
context 'when sorting by created' do
it 'sorts issues ascending' do
......
......@@ -12,6 +12,7 @@ RSpec.describe 'getting an issue list for a project' do
let_it_be(:issues, reload: true) { [issue_a, issue_b] }
let(:issues_data) { graphql_data['project']['issues']['edges'] }
let(:issue_filter_params) { {} }
let(:fields) do
<<~QUERY
......@@ -27,7 +28,7 @@ RSpec.describe 'getting an issue list for a project' do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('issues', {}, fields)
query_graphql_field('issues', issue_filter_params, fields)
)
end
......@@ -50,6 +51,16 @@ RSpec.describe 'getting an issue list for a project' do
expect(issues_data[1]['node']['discussionLocked']).to eq(true)
end
context 'when both assignee_username filters are provided' do
let(:issue_filter_params) { { assignee_username: current_user.username, assignee_usernames: [current_user.username] } }
it 'returns a mutually exclusive param error' do
post_graphql(query, current_user: current_user)
expect_graphql_errors_to_include('only one of [assigneeUsernames, assigneeUsername] arguments is allowed at the same time.')
end
end
context 'when limiting the number of results' do
let(:query) do
<<~GQL
......
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