Commit 99f0621b authored by Steve Abrams's avatar Steve Abrams

Merge branch '296031-assignee-or-filters' into 'master'

Add IssuableFinder support for OR filtering of assignees

See merge request gitlab-org/gitlab!59939
parents b5c7d10c f46f7a7c
...@@ -50,7 +50,7 @@ class IssuableFinder ...@@ -50,7 +50,7 @@ class IssuableFinder
attr_reader :original_params attr_reader :original_params
attr_writer :parent attr_writer :parent
delegate(*%i[assignee milestones], to: :params) delegate(*%i[milestones], to: :params)
class << self class << self
def scalar_params def scalar_params
...@@ -148,7 +148,6 @@ class IssuableFinder ...@@ -148,7 +148,6 @@ class IssuableFinder
# Negates all params found in `negatable_params` # Negates all params found in `negatable_params`
def filter_negated_items(items) def filter_negated_items(items)
items = by_negated_assignee(items)
items = by_negated_label(items) items = by_negated_label(items)
items = by_negated_milestone(items) items = by_negated_milestone(items)
items = by_negated_release(items) items = by_negated_release(items)
...@@ -365,32 +364,21 @@ class IssuableFinder ...@@ -365,32 +364,21 @@ class IssuableFinder
def by_author(items) def by_author(items)
Issuables::AuthorFilter.new( Issuables::AuthorFilter.new(
items,
params: original_params, params: original_params,
or_filters_enabled: or_filters_enabled? or_filters_enabled: or_filters_enabled?
).filter ).filter(items)
end end
def by_assignee(items) def by_assignee(items)
if params.filter_by_no_assignee? assignee_filter.filter(items)
items.unassigned
elsif params.filter_by_any_assignee?
items.assigned
elsif params.assignee
items.assigned_to(params.assignee)
elsif params.assignee_id? || params.assignee_username? # assignee not found
items.none
else
items
end
end end
def by_negated_assignee(items) def assignee_filter
# We want CE users to be able to say "Issues not assigned to either PersonA nor PersonB" strong_memoize(:assignee_filter) do
if not_params.assignees.present? Issuables::AssigneeFilter.new(
items.not_assigned_to(not_params.assignees) params: original_params,
else or_filters_enabled: or_filters_enabled?
items )
end end
end end
......
...@@ -27,14 +27,6 @@ class IssuableFinder ...@@ -27,14 +27,6 @@ class IssuableFinder
params.present? params.present?
end end
def filter_by_no_assignee?
params[:assignee_id].to_s.downcase == FILTER_NONE
end
def filter_by_any_assignee?
params[:assignee_id].to_s.downcase == FILTER_ANY
end
def filter_by_no_label? def filter_by_no_label?
downcased = label_names.map(&:downcase) downcased = label_names.map(&:downcase)
...@@ -156,24 +148,6 @@ class IssuableFinder ...@@ -156,24 +148,6 @@ class IssuableFinder
end end
end end
# rubocop: disable CodeReuse/ActiveRecord
def assignees
strong_memoize(:assignees) do
if assignee_id?
User.where(id: params[:assignee_id])
elsif assignee_username?
User.where(username: params[:assignee_username])
else
User.none
end
end
end
# rubocop: enable CodeReuse/ActiveRecord
def assignee
assignees.first
end
def label_names def label_names
if labels? if labels?
params[:label_name].is_a?(String) ? params[:label_name].split(',') : params[:label_name] params[:label_name].is_a?(String) ? params[:label_name].split(',') : params[:label_name]
......
# frozen_string_literal: true
module Issuables
class AssigneeFilter < BaseFilter
def filter(issuables)
filtered = by_assignee(issuables)
filtered = by_assignee_union(filtered)
by_negated_assignee(filtered)
end
def includes_user?(user)
Array(params[:assignee_ids]).include?(user.id) ||
Array(params[:assignee_id]).include?(user.id) ||
Array(params[:assignee_username]).include?(user.username)
end
private
def by_assignee(issuables)
if filter_by_no_assignee?
issuables.unassigned
elsif filter_by_any_assignee?
issuables.assigned
elsif has_assignee_param?(params)
filter_by_assignees(issuables)
else
issuables
end
end
def by_assignee_union(issuables)
return issuables unless or_filters_enabled? && has_assignee_param?(or_params)
issuables.assigned_to(assignee_ids(or_params))
end
def by_negated_assignee(issuables)
return issuables unless has_assignee_param?(not_params)
issuables.not_assigned_to(assignee_ids(not_params))
end
def filter_by_no_assignee?
params[:assignee_id].to_s.downcase == FILTER_NONE
end
def filter_by_any_assignee?
params[:assignee_id].to_s.downcase == FILTER_ANY
end
def filter_by_assignees(issuables)
assignee_ids = assignee_ids(params)
return issuables.none if assignee_ids.blank?
assignee_ids.each do |assignee_id|
issuables = issuables.assigned_to(assignee_id)
end
issuables
end
def has_assignee_param?(specific_params)
return if specific_params.nil?
specific_params[:assignee_ids].present? || specific_params[:assignee_id].present? || specific_params[:assignee_username].present?
end
def assignee_ids(specific_params)
if specific_params[:assignee_ids].present?
Array(specific_params[:assignee_ids])
elsif specific_params[:assignee_id].present?
Array(specific_params[:assignee_id])
elsif specific_params[:assignee_username].present?
User.by_username(specific_params[:assignee_username]).select(:id)
end
end
end
end
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
module Issuables module Issuables
class AuthorFilter < BaseFilter class AuthorFilter < BaseFilter
def filter def filter(issuables)
filtered = by_author(issuables) filtered = by_author(issuables)
filtered = by_author_union(filtered) filtered = by_author_union(filtered)
by_negated_author(filtered) by_negated_author(filtered)
...@@ -21,7 +21,7 @@ module Issuables ...@@ -21,7 +21,7 @@ module Issuables
end end
def by_author_union(issuables) def by_author_union(issuables)
return issuables unless or_filters_enabled? && or_params&.fetch(:author_username).present? return issuables unless or_filters_enabled? && or_params&.fetch(:author_username, false).present?
issuables.authored(User.by_username(or_params[:author_username])) issuables.authored(User.by_username(or_params[:author_username]))
end end
......
...@@ -2,10 +2,12 @@ ...@@ -2,10 +2,12 @@
module Issuables module Issuables
class BaseFilter class BaseFilter
attr_reader :issuables, :params attr_reader :params
def initialize(issuables, params:, or_filters_enabled: false) FILTER_NONE = 'none'
@issuables = issuables FILTER_ANY = 'any'
def initialize(params:, or_filters_enabled: false)
@params = params @params = params
@or_filters_enabled = or_filters_enabled @or_filters_enabled = or_filters_enabled
end end
......
...@@ -52,7 +52,7 @@ class IssuesFinder < IssuableFinder ...@@ -52,7 +52,7 @@ class IssuesFinder < IssuableFinder
# can always see confidential issues assigned to them. This is just an # can always see confidential issues assigned to them. This is just an
# optimization since a very common usecase of this Finder is to load the # optimization since a very common usecase of this Finder is to load the
# count of issues assigned to the user for the header bar. # count of issues assigned to the user for the header bar.
return Issue.all if current_user && params.assignees.include?(current_user) return Issue.all if current_user && assignee_filter.includes_user?(current_user)
return Issue.where('issues.confidential IS NOT TRUE') if params.user_cannot_see_confidential_issues? return Issue.where('issues.confidential IS NOT TRUE') if params.user_cannot_see_confidential_issues?
......
...@@ -200,7 +200,7 @@ module IssuesHelper ...@@ -200,7 +200,7 @@ module IssuesHelper
jira_integration_path: help_page_url('integration/jira/', anchor: 'view-jira-issues'), jira_integration_path: help_page_url('integration/jira/', anchor: 'view-jira-issues'),
markdown_help_path: help_page_path('user/markdown'), markdown_help_path: help_page_path('user/markdown'),
max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes),
new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.try(:id), milestone_id: finder.milestones.first.try(:id) }), new_issue_path: new_project_issue_path(project, issue: { milestone_id: finder.milestones.first.try(:id) }),
project_import_jira_path: project_import_jira_path(project), project_import_jira_path: project_import_jira_path(project),
project_labels_path: project_labels_path(project, include_ancestor_groups: true, format: :json), project_labels_path: project_labels_path(project, include_ancestor_groups: true, format: :json),
project_milestones_path: project_milestones_path(project, format: :json), project_milestones_path: project_milestones_path(project, format: :json),
......
...@@ -101,20 +101,19 @@ module Issuable ...@@ -101,20 +101,19 @@ module Issuable
scope :unassigned, -> do scope :unassigned, -> do
where("NOT EXISTS (SELECT TRUE FROM #{to_ability_name}_assignees WHERE #{to_ability_name}_id = #{to_ability_name}s.id)") where("NOT EXISTS (SELECT TRUE FROM #{to_ability_name}_assignees WHERE #{to_ability_name}_id = #{to_ability_name}s.id)")
end end
scope :assigned_to, ->(u) do scope :assigned_to, ->(users) do
assignees_table = Arel::Table.new("#{to_ability_name}_assignees") assignees_class = self.reflect_on_association("#{to_ability_name}_assignees").klass
sql = assignees_table.project('true').where(assignees_table[:user_id].in(u.id)).where(Arel::Nodes::SqlLiteral.new("#{to_ability_name}_id = #{to_ability_name}s.id"))
where("EXISTS (#{sql.to_sql})")
end
# rubocop:enable GitlabSecurity/SqlInjection
condition = assignees_class.where(user_id: users).where(Arel.sql("#{to_ability_name}_id = #{to_ability_name}s.id"))
where(condition.arel.exists)
end
scope :not_assigned_to, ->(users) do scope :not_assigned_to, ->(users) do
assignees_table = Arel::Table.new("#{to_ability_name}_assignees") assignees_class = self.reflect_on_association("#{to_ability_name}_assignees").klass
sql = assignees_table.project('true')
.where(assignees_table[:user_id].in(users)) condition = assignees_class.where(user_id: users).where(Arel.sql("#{to_ability_name}_id = #{to_ability_name}s.id"))
.where(Arel::Nodes::SqlLiteral.new("#{to_ability_name}_id = #{to_ability_name}s.id")) where(condition.arel.exists.not)
where(sql.exists.not)
end end
# rubocop:enable GitlabSecurity/SqlInjection
scope :without_particular_labels, ->(label_names) do scope :without_particular_labels, ->(label_names) do
labels_table = Label.arel_table labels_table = Label.arel_table
......
...@@ -15,8 +15,7 @@ ...@@ -15,8 +15,7 @@
= button_tag _("Edit issues"), class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle" = button_tag _("Edit issues"), class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle"
- if show_new_issue_link?(@project) - if show_new_issue_link?(@project)
= link_to _("New issue"), new_project_issue_path(@project, = link_to _("New issue"), new_project_issue_path(@project,
issue: { assignee_id: finder.assignee.try(:id), issue: { milestone_id: finder.milestones.first.try(:id) }),
milestone_id: finder.milestones.first.try(:id) }),
class: "gl-button btn btn-confirm", class: "gl-button btn btn-confirm",
id: "new_issue_link" id: "new_issue_link"
...@@ -43,19 +43,6 @@ module EE ...@@ -43,19 +43,6 @@ module EE
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
override :by_assignee
def by_assignee(items)
if params.assignees.any?
params.assignees.each do |assignee|
items = items.assigned_to(assignee)
end
return items
end
super
end
def by_epic(items) def by_epic(items)
return items unless params.by_epic? return items unless params.by_epic?
......
...@@ -30,19 +30,6 @@ module EE ...@@ -30,19 +30,6 @@ module EE
params[:weight].to_s.downcase == ::IssuableFinder::Params::FILTER_ANY params[:weight].to_s.downcase == ::IssuableFinder::Params::FILTER_ANY
end end
override :assignees
# rubocop: disable CodeReuse/ActiveRecord
def assignees
strong_memoize(:assignees) do
if assignee_ids?
::User.where(id: params[:assignee_ids])
else
super
end
end
end
# rubocop: enable CodeReuse/ActiveRecord
def epics def epics
if params[:include_subepics] if params[:include_subepics]
::Gitlab::ObjectHierarchy.new(::Epic.id_in(params[:epic_id])).base_and_descendants.select(:id) ::Gitlab::ObjectHierarchy.new(::Epic.id_in(params[:epic_id])).base_and_descendants.select(:id)
......
...@@ -85,6 +85,8 @@ RSpec.describe Admin::CredentialsController, type: :request do ...@@ -85,6 +85,8 @@ RSpec.describe Admin::CredentialsController, type: :request do
create(:gpg_key, user: control_user, key: GpgHelpers::User1.public_key) create(:gpg_key, user: control_user, key: GpgHelpers::User1.public_key)
create(:gpg_key, user: control_user, key: GpgHelpers::User1.public_key2) create(:gpg_key, user: control_user, key: GpgHelpers::User1.public_key2)
get admin_credentials_path(filter: 'gpg_keys')
control = ActiveRecord::QueryRecorder.new(skip_cached: false) do control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
get admin_credentials_path(filter: 'gpg_keys') get admin_credentials_path(filter: 'gpg_keys')
end.count end.count
......
...@@ -49,6 +49,11 @@ RSpec.describe IssuesFinder do ...@@ -49,6 +49,11 @@ RSpec.describe IssuesFinder do
let(:expected_issuables) { [issue3, issue4] } let(:expected_issuables) { [issue3, issue4] }
end end
it_behaves_like 'assignee OR filter' do
let(:params) { { or: { assignee_id: [user.id, user2.id] } } }
let(:expected_issuables) { [issue1, issue2, issue3, issue5] }
end
context 'when assignee_id does not exist' do context 'when assignee_id does not exist' do
it_behaves_like 'assignee NOT ID filter' do it_behaves_like 'assignee NOT ID filter' do
let(:params) { { not: { assignee_id: -100 } } } let(:params) { { not: { assignee_id: -100 } } }
...@@ -79,6 +84,11 @@ RSpec.describe IssuesFinder do ...@@ -79,6 +84,11 @@ RSpec.describe IssuesFinder do
let(:expected_issuables) { [issue3, issue4] } let(:expected_issuables) { [issue3, issue4] }
end end
it_behaves_like 'assignee OR filter' do
let(:params) { { or: { assignee_username: [user2.username, user3.username] } } }
let(:expected_issuables) { [issue2, issue3] }
end
context 'when assignee_username does not exist' do context 'when assignee_username does not exist' do
it_behaves_like 'assignee NOT username filter' do it_behaves_like 'assignee NOT username filter' do
before do before do
......
...@@ -312,7 +312,7 @@ RSpec.describe IssuesHelper do ...@@ -312,7 +312,7 @@ RSpec.describe IssuesHelper do
jira_integration_path: help_page_url('integration/jira/', anchor: 'view-jira-issues'), jira_integration_path: help_page_url('integration/jira/', anchor: 'view-jira-issues'),
markdown_help_path: help_page_path('user/markdown'), markdown_help_path: help_page_path('user/markdown'),
max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes),
new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.id, milestone_id: finder.milestones.first.id }), new_issue_path: new_project_issue_path(project, issue: { milestone_id: finder.milestones.first.id }),
project_import_jira_path: project_import_jira_path(project), project_import_jira_path: project_import_jira_path(project),
project_labels_path: project_labels_path(project, include_ancestor_groups: true, format: :json), project_labels_path: project_labels_path(project, include_ancestor_groups: true, format: :json),
project_milestones_path: project_milestones_path(project, format: :json), project_milestones_path: project_milestones_path(project, format: :json),
......
...@@ -24,6 +24,12 @@ RSpec.shared_examples 'assignee NOT username filter' do ...@@ -24,6 +24,12 @@ RSpec.shared_examples 'assignee NOT username filter' do
end end
end end
RSpec.shared_examples 'assignee OR filter' do
it 'returns issuables assigned to the given users' do
expect(issuables).to contain_exactly(*expected_issuables)
end
end
RSpec.shared_examples 'no assignee filter' do RSpec.shared_examples 'no assignee filter' do
let(:params) { { assignee_id: 'None' } } let(:params) { { assignee_id: 'None' } }
......
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