Resource Allocation info dialog

This commit is contained in:
Klaus Zanders
2026-06-08 10:45:24 +02:00
parent 5c1f221513
commit 9455bdc831
13 changed files with 554 additions and 13 deletions
@@ -0,0 +1,69 @@
<%#-- 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.
++#%>
<%=
render(Primer::Alpha::Dialog.new(id: DIALOG_ID, title:, size: :medium_portrait, position: :right)) do |dialog|
dialog.with_header(variant: :large)
dialog.with_body do
flex_layout do |body|
body.with_row(mb: 3) do
render(ResourcePlannerViews::WorkPackageList::AllocationProgressComponent.new(work_package:, allocations:))
end
allocations.each do |allocation|
body.with_row(border: :bottom, py: 2) do
render(ResourceAllocations::ListItemComponent.new(allocation:, visible: visible_principal?(allocation)))
end
end
end
end
dialog.with_footer(show_divider: true) do
flex_layout(justify_content: :space_between, align_items: :center) do |footer|
footer.with_column do
render Primer::Beta::Button.new(
scheme: :link,
tag: :a,
href: allocate_resource_path,
data: { controller: "async-dialog" }
) do |button|
button.with_leading_visual_icon(icon: :plus)
t("resource_management.work_package_allocations_dialog.allocate_resource")
end
end
footer.with_column do
render(Primer::Beta::Button.new(data: { "close-dialog-id": DIALOG_ID })) do
t(:button_close)
end
end
end
end
end
%>
@@ -0,0 +1,66 @@
# 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.
#++
module ResourceAllocations
# Lists a work package's allocations: the allocation progress summary, one row
# per allocation, and a footer to add another. Allocations whose principal is
# not visible to the current user are still listed, but anonymised.
class ListDialogComponent < ApplicationComponent
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
DIALOG_ID = "work-package-allocations-dialog"
def initialize(project:, work_package:, allocations:, visible_principal_ids:)
super
@project = project
@work_package = work_package
@allocations = allocations
@visible_principal_ids = visible_principal_ids
end
private
attr_reader :project, :work_package, :allocations, :visible_principal_ids
def title
I18n.t("resource_management.work_package_allocations_dialog.title")
end
def visible_principal?(allocation)
allocation.principal_id.nil? || visible_principal_ids.include?(allocation.principal_id)
end
def allocate_resource_path
new_project_resource_allocation_path(project, work_package_id: work_package.id)
end
end
end
@@ -0,0 +1,40 @@
<%#-- 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.
++#%>
<%= flex_layout(align_items: :center) do |row| %>
<%= row.with_column(mr: 2) do %>
<%= render(avatar) %>
<% end %>
<%= row.with_column(flex: 1) do %>
<%= render(Primer::Beta::Text.new(font_size: :small)) { name } %>
<% end %>
<%= row.with_column do %>
<%= render(Primer::Beta::Text.new(font_size: :small, color: :muted)) { hours } %>
<% end %>
<% end %>
@@ -0,0 +1,99 @@
# 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.
#++
module ResourceAllocations
# A single allocation row in the work package allocations dialog: the member's
# avatar and name (or an anonymous placeholder when the principal is not
# visible to the current user) and the allocated hours.
class ListItemComponent < ApplicationComponent
include OpPrimer::ComponentHelpers
include AvatarHelper
AVATAR_SIZE = 24
def initialize(allocation:, visible:)
super
@allocation = allocation
@visible = visible
end
private
attr_reader :allocation
def visible?
@visible
end
def name
if allocation.principal
visible? ? allocation.principal.name : hidden_label
else
allocation.filter_name.presence || unassigned_label
end
end
# Only a visible principal exposes a real avatar (and an identity-revealing
# initials/colour seed). Everything else falls back to a generated avatar
# keyed to the allocation, so a hidden user cannot be correlated.
def avatar
Primer::OpenProject::AvatarWithFallback.new(size: AVATAR_SIZE, **avatar_options)
end
def avatar_options
if allocation.principal && visible?
{
src: avatar_url(allocation.principal),
alt: allocation.principal.name,
unique_id: allocation.principal.id
}
else
{
alt: name,
unique_id: "resource-allocation-#{allocation.id}"
}
end
end
def hours
t("resource_management.work_package_list.allocation.hours",
value: helpers.number_with_precision(allocation.allocated_hours, precision: 1, strip_insignificant_zeros: true))
end
def hidden_label
t("resource_management.work_package_allocations_dialog.hidden_user")
end
def unassigned_label
t("resource_management.work_package_list.allocated_members.unassigned")
end
end
end
@@ -142,8 +142,12 @@ module ResourcePlannerViews::WorkPackageList
end
def see_allocation_item(menu)
menu.with_item(label: t("resource_management.work_package_list.context_menu.see_allocation"),
disabled: true) do |item|
menu.with_item(
label: t("resource_management.work_package_list.context_menu.see_allocation"),
tag: :a,
href: helpers.project_work_package_resource_allocations_path(table.project, work_package),
content_arguments: { data: { controller: "async-dialog" } }
) do |item|
item.with_leading_visual_icon(icon: :hourglass)
end
end
@@ -206,20 +206,10 @@ module ::ResourceManagement
resource_planner: @resource_planner,
work_packages:,
allocations:,
visible_principal_ids: visible_principal_ids(allocations)
visible_principal_ids: ResourceAllocation.visible_principal_ids(allocations.values.flatten, current_user)
)
end
# The principals among the allocations that the current user may see. The
# members column keeps the rest as an anonymous count rather than revealing
# who they are.
def visible_principal_ids(allocations)
principal_ids = allocations.values.flatten.filter_map(&:principal_id).uniq
return Set.new if principal_ids.empty?
Principal.visible(current_user).where(id: principal_ids).pluck(:id).to_set
end
def render_configure_step(view, status: :ok)
update_dialog_title_via_turbo_stream(
ResourcePlannerViews::NewDialogComponent::DIALOG_ID,
@@ -0,0 +1,70 @@
# 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.
#++
module ::ResourceManagement
# Lists the allocations of a single work package in a dialog. Visible to users
# who may view the work package and hold the `view_resource_planners`
# permission in its project.
class WorkPackageResourceAllocationsController < BaseController
include OpTurbo::ComponentStream
menu_item :resource_management
before_action :find_project_by_project_id
before_action :find_work_package
before_action :authorize
def index
respond_with_dialog ResourceAllocations::ListDialogComponent.new(
project: @project,
work_package: @work_package,
allocations:,
visible_principal_ids: ResourceAllocation.visible_principal_ids(allocations, current_user)
)
end
private
def allocations
@allocations ||=
ResourceAllocation.allocated_for_work_packages([@work_package])[@work_package.id] || []
end
# `WorkPackage.visible` enforces the view-work-package permission; a
# non-visible (or out-of-project) id therefore 404s. The
# `view_resource_planners` permission is enforced by `authorize`.
def find_work_package
@work_package = WorkPackage
.visible(current_user)
.where(project: @project)
.find(params.expect(:work_package_id))
end
end
end
@@ -69,6 +69,15 @@ class ResourceAllocation < ApplicationRecord
.group_by(&:entity_id)
end
# The subset of the given allocations' principal ids that `user` may see.
# Used to anonymise members the current user is not allowed to know about.
def self.visible_principal_ids(allocations, user)
principal_ids = allocations.filter_map(&:principal_id).uniq
return Set.new if principal_ids.empty?
Principal.visible(user).where(id: principal_ids).pluck(:id).to_set
end
validates :state, :start_date, :end_date, presence: true
validates :allocated_time,
presence: true,
@@ -129,6 +129,10 @@ en:
caption: >-
Create a view based on users and see their details and allocation in a list of user cards
label: Users card list
work_package_allocations_dialog:
allocate_resource: Allocate resource
hidden_user: Hidden user
title: Allocation
work_package_list:
add_work_package_dialog:
title: Add work package
@@ -69,5 +69,11 @@ Rails.application.routes.draw do
get :refresh_form
end
end
resources :work_packages, only: [] do
resources :resource_allocations,
controller: "resource_management/work_package_resource_allocations",
only: :index
end
end
end
@@ -60,6 +60,7 @@ module OpenProject::ResourceManagement
new_work_package add_work_package
remove_work_package move_work_package
reorder_work_package],
"resource_management/work_package_resource_allocations": %i[index],
"resource_management/menus": %i[show]
},
permissible_on: :project
@@ -0,0 +1,77 @@
# 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.
#++
require "rails_helper"
RSpec.describe ResourceAllocations::ListItemComponent, type: :component do
shared_let(:work_package) { create(:work_package) }
shared_let(:member) { create(:user, firstname: "Sarah", lastname: "Smith") }
let(:visible) { true }
subject(:rendered) do
render_inline(described_class.new(allocation:, visible:))
page
end
before { login_as(create(:admin)) }
context "with a visible assigned member" do
let(:allocation) { create(:resource_allocation, entity: work_package, principal: member, allocated_time: 720) }
it "shows the member's name, avatar and allocated hours" do
expect(rendered).to have_text("Sarah Smith")
expect(rendered).to have_text("12h")
expect(rendered).to have_css("avatar-fallback[data-unique-id='#{member.id}']")
end
end
context "with an assigned member the user may not see" do
let(:allocation) { create(:resource_allocation, entity: work_package, principal: member, allocated_time: 720) }
let(:visible) { false }
it "anonymises the member without revealing the name" do
expect(rendered).to have_no_text("Sarah Smith")
expect(rendered).to have_no_css("avatar-fallback[data-unique-id='#{member.id}']")
expect(rendered).to have_text(I18n.t("resource_management.work_package_allocations_dialog.hidden_user"))
end
end
context "with a filter-based allocation" do
let(:allocation) do
create(:resource_allocation,
entity: work_package, principal_explicit: false, principal: nil, filter_name: "Full stack developer")
end
it "shows the filter name" do
expect(rendered).to have_text("Full stack developer")
end
end
end
@@ -0,0 +1,106 @@
# 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.
#++
require "spec_helper"
RSpec.describe "WorkPackage resource allocations requests", type: :rails_request do
shared_let(:project) { create(:project, enabled_module_names: %w[resource_management work_package_tracking]) }
shared_let(:user) do
create(:user, member_with_permissions: { project => %i[view_resource_planners view_work_packages] })
end
# A project member the current user can see.
shared_let(:assignee) do
create(:user, firstname: "Sarah", lastname: "Smith", member_with_permissions: { project => %i[view_work_packages] })
end
# No shared project or group, so invisible to the (non-admin) current user.
shared_let(:hidden_user) { create(:user, firstname: "Secret", lastname: "Agent") }
shared_let(:work_package) { create(:work_package, project:) }
let(:path) { project_work_package_resource_allocations_path(project, work_package) }
before { login_as(user) }
describe "GET index" do
before do
create(:resource_allocation, entity: work_package, principal: assignee, allocated_time: 720)
create(:resource_allocation, entity: work_package, principal: hidden_user, allocated_time: 300)
create(:resource_allocation,
entity: work_package, principal_explicit: false, principal: nil, filter_name: "Full stack developer")
end
it "renders the allocations dialog" do
get path, as: :turbo_stream
expect(response).to have_http_status(:ok)
expect(response.body).to include(I18n.t("resource_management.work_package_allocations_dialog.title"))
end
it "names the visible member and the filter allocation" do
get path, as: :turbo_stream
expect(response.body).to include("Sarah Smith")
expect(response.body).to include("Full stack developer")
end
it "lists the invisible member anonymously, never revealing the name" do
get path, as: :turbo_stream
expect(response.body).not_to include("Secret Agent")
expect(response.body).to include(I18n.t("resource_management.work_package_allocations_dialog.hidden_user"))
end
end
describe "authorization" do
context "without the view_resource_planners permission" do
shared_let(:other_user) do
create(:user, member_with_permissions: { project => %i[view_work_packages] })
end
before { login_as(other_user) }
it "is forbidden" do
get path, as: :turbo_stream
expect(response).to have_http_status(:forbidden)
end
end
context "when the work package is not visible to the user" do
let(:other_project) { create(:project, enabled_module_names: %w[resource_management]) }
let(:invisible_work_package) { create(:work_package, project: other_project) }
it "returns not found" do
get project_work_package_resource_allocations_path(project, invisible_work_package), as: :turbo_stream
expect(response).to have_http_status(:not_found)
end
end
end
end