Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions app/models/queries/users/filters/member_filter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

class Queries::Users::Filters::MemberFilter < Queries::Users::Filters::UserFilter
include Queries::Filters::Shared::ProjectFilter::Optional

def self.key
:member
end

def human_name
I18n.t(:label_member_of_project)
end

def apply_to(query_scope)
case operator
when "="
query_scope.in_project(values)
when "!"
query_scope.not_in_project(values)
when "*"
query_scope.where(id: Member.of_any_project.select(:user_id))
when "!*"
query_scope.where.not(id: Member.of_any_project.select(:user_id))
end
end
end
1 change: 1 addition & 0 deletions app/models/user_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def default_scope
filter Queries::Users::Filters::NameFilter
filter Queries::Users::Filters::AnyNameAttributeFilter
filter Queries::Users::Filters::GroupFilter
filter Queries::Users::Filters::MemberFilter
filter Queries::Users::Filters::StatusFilter
filter Queries::Users::Filters::LoginFilter
filter Queries::Users::Filters::BlockedFilter
Expand Down
1 change: 1 addition & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3325,6 +3325,7 @@ en:
label_me: "me"
label_member_all_admin: "(All roles due to admin status)"
label_member_new: "New member"
label_member_of_project: "Member of project"
label_member_plural: "Members"
label_member_role: "Project role"
label_membership_added: "Member added"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ def form_list_component(form)
::Filters::FilterFormComponent.new(
builder: form,
query: @allocation.candidate_query,
# Membership in the allocation's project is implied, not a criterion to edit.
excluded_filters: [:member],
wrap_with_controller: true,
hidden_input_name: "filters",
output_format: :json,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@

<% if candidate_badge? %>
<span class="op-rm-timeline-bar--badge">
<%= render(Primer::Beta::Label.new) { candidate_label } %>
<%= render(Primer::Beta::Label.new(id: candidate_tooltip_id)) { candidate_count.to_s } %>
<%= render Primer::Alpha::Tooltip.new(
for_id: candidate_tooltip_id,
type: :description,
text: candidate_tooltip
) %>
</span>
<% end %>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,16 @@ module WorkPackageTimeline
class AllocationBarComponent < ApplicationComponent
include AvatarHelper

def initialize(allocation:, visible_principal_ids: nil)
def initialize(allocation:, visible_principal_ids: nil, candidate_count: 0)
super
@allocation = allocation
@visible_principal_ids = visible_principal_ids
@candidate_count = candidate_count
end

private

attr_reader :allocation, :visible_principal_ids
attr_reader :allocation, :visible_principal_ids, :candidate_count

def hours_label
t("resource_management.allocation.hours", value: allocation.allocated_hours.round)
Expand All @@ -70,23 +71,16 @@ def placeholder_label
end
end

# Resolving the candidate query can fail for an incomplete filter; fall back
# to no badge rather than erroring the whole timeline.
def candidate_count
return 0 unless filter_based?

allocation.candidate_query.results.count
rescue StandardError => e
Rails.logger.warn("Resource timeline candidate_count failed: #{e.class}: #{e.message}")
0
end

def candidate_badge?
candidate_count.positive?
end

def candidate_label
t("resource_management.work_package_list.allocated_members.additional", count: candidate_count)
def candidate_tooltip
t("resource_management.work_package_timeline.allocation_bar.matching_members_description", count: candidate_count)
end

def candidate_tooltip_id
"wp-timeline-candidates-#{allocation.id}"
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def principal_matches_filter
end

def principal_selected_by_filter?
model.candidate_query.results.in_project(model.project).exists?(id: model.principal_id)
model.candidate_query.results.exists?(id: model.principal_id)
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ def visible_work_package_ids(allocations)

# Project members the stored filter selects.
def candidates_for(allocation)
allocation.candidate_query.results.in_project(@project).to_a
allocation.candidate_query(project: @project).results.to_a
end

def candidate_principal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def index # rubocop:disable Metrics/AbcSize
allocations = allocations_by_work_package.values.flatten
overbooked = ResourceAllocation.overbooked_ids(allocations)
visible = ResourceAllocation.visible_principal_ids(allocations, current_user)
candidates = ResourceAllocation.candidate_counts(allocations, project: @project)

events = allocations.map do |allocation|
{
Expand All @@ -50,7 +51,7 @@ def index # rubocop:disable Metrics/AbcSize
extendedProps: {
overbooked: overbooked.include?(allocation.id),
editUrl: edit_url_for(allocation),
html: render_bar(allocation, visible)
html: render_bar(allocation, visible, candidates.fetch(allocation.id, 0))
}
}
end
Expand Down Expand Up @@ -133,9 +134,9 @@ def start_of_week_day
Date::DAYNAMES.fetch(first_day % 7).downcase.to_sym
end

def render_bar(allocation, visible_principal_ids)
def render_bar(allocation, visible_principal_ids, candidate_count)
ResourcePlannerViews::WorkPackageTimeline::AllocationBarComponent
.new(allocation:, visible_principal_ids:)
.new(allocation:, visible_principal_ids:, candidate_count:)
.render_in(view_context)
end
end
Expand Down
33 changes: 32 additions & 1 deletion modules/resource_management/app/models/resource_allocation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,22 @@ def self.visible_principal_ids(allocations, user)
Principal.visible(user).where(id: principal_ids).pluck(:id).to_set
end

# Counts the candidates each filter-based allocation selects, keyed by
# allocation id. Allocations commonly share a stored filter, so the candidate
# pool is resolved once per distinct filter rather than once per allocation.
def self.candidate_counts(allocations, project:)
return {} if project.nil?

counts_by_filter = {}

allocations.select(&:filter_based?).to_h do |allocation|
signature = allocation.user_filter.map { |filter| [filter.name, filter.operator, filter.values] }
count = counts_by_filter.fetch(signature) { counts_by_filter[signature] = allocation.candidate_count(project:) }

[allocation.id, count]
end
end

# Users without configured working hours are skipped — their capacity is
# unknown, not zero (mirroring the check made when an allocation is created).
# The users' working hours and booked allocations are each fetched in one
Expand Down Expand Up @@ -190,14 +206,29 @@ def needs_principal_assignment?
!principal_explicit? && principal_id.blank?
end

def candidate_query
# Only project members can be allocated, so the stored criteria are always
# narrowed to the project's members. Callers that already hold the project pass
# it in to avoid loading the entity. Applying the membership filter last means a
# `member` value smuggled into the stored filter is overwritten, not honoured.
def candidate_query(project: self.project)
UserQuery.new.tap do |query|
user_filter.each do |filter|
query.where(filter.field, filter.operator, filter.values)
end

query.where(:member, "=", [project.id.to_s]) if project
end
end

# Resolving the query can fail for an incompletely configured filter; a single
# broken filter must not take down the whole view it is rendered in.
def candidate_count(project: self.project)
candidate_query(project:).results.count
rescue StandardError => e
Rails.logger.warn("Candidate count for resource allocation #{id} failed: #{e.class}: #{e.message}")
0
end

def allocated_hours
return if allocated_time.nil?

Expand Down
6 changes: 5 additions & 1 deletion modules/resource_management/config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ en:
resource_planner: Resource Planner
resource_user_card: User cards
resource_work_package_list: Work packages list
button_next: Next
ee:
features:
resource_management: Resource management
Expand All @@ -52,7 +53,6 @@ en:
Plan and balance your team's workload with resource planners. Allocate people to work packages,
track capacity over time and spot over- and under-allocation at a glance.
title: Resource management
button_next: Next
label_resource_management: Resource management
permission_allocate_user_resources: Allocate user resources
permission_allocate_user_resources_explanation: >-
Expand Down Expand Up @@ -306,6 +306,10 @@ en:
allocate: Allocate
settings: Configure view
work_package_timeline:
allocation_bar:
matching_members_description:
one: "%{count} member of this project matches the allocation's filter criteria."
other: "%{count} members of this project match the allocation's filter criteria."
blank:
add_work_package: Add work package
configure_view: Configure view
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,28 @@
expect(page).to have_text("20h")
expect(page).to have_text(allocation.filter_name)
end

describe "the candidate badge" do
let(:allocation) { create(:resource_allocation, :with_user_filter, allocated_time: 20 * 60) }

it "shows the bare count, with the meaning carried by the tooltip" do
render_inline(described_class.new(allocation:, visible_principal_ids: Set.new, candidate_count: 3))

expect(page).to have_css(".op-rm-timeline-bar--badge", text: "3")
expect(page).to have_css("tool-tip", text: "3 members of this project match the allocation's filter criteria.")
end

it "describes a single match in the singular" do
render_inline(described_class.new(allocation:, visible_principal_ids: Set.new, candidate_count: 1))

expect(page).to have_css(".op-rm-timeline-bar--badge", text: "1")
expect(page).to have_css("tool-tip", text: "1 member of this project matches the allocation's filter criteria.")
end

it "is omitted when nothing matches" do
render_inline(described_class.new(allocation:, visible_principal_ids: Set.new, candidate_count: 0))

expect(page).to have_no_css(".op-rm-timeline-bar--badge")
end
end
end
Loading
Loading