diff --git a/modules/resource_management/app/components/resource_allocations/list_dialog_component.html.erb b/modules/resource_management/app/components/resource_allocations/list_dialog_component.html.erb new file mode 100644 index 00000000000..01757fe40f9 --- /dev/null +++ b/modules/resource_management/app/components/resource_allocations/list_dialog_component.html.erb @@ -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 +%> diff --git a/modules/resource_management/app/components/resource_allocations/list_dialog_component.rb b/modules/resource_management/app/components/resource_allocations/list_dialog_component.rb new file mode 100644 index 00000000000..038cc540410 --- /dev/null +++ b/modules/resource_management/app/components/resource_allocations/list_dialog_component.rb @@ -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 diff --git a/modules/resource_management/app/components/resource_allocations/list_item_component.html.erb b/modules/resource_management/app/components/resource_allocations/list_item_component.html.erb new file mode 100644 index 00000000000..061897a15f3 --- /dev/null +++ b/modules/resource_management/app/components/resource_allocations/list_item_component.html.erb @@ -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 %> diff --git a/modules/resource_management/app/components/resource_allocations/list_item_component.rb b/modules/resource_management/app/components/resource_allocations/list_item_component.rb new file mode 100644 index 00000000000..0179dc7e753 --- /dev/null +++ b/modules/resource_management/app/components/resource_allocations/list_item_component.rb @@ -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 diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/row_component.rb b/modules/resource_management/app/components/resource_planner_views/work_package_list/row_component.rb index 2447ab74d4b..c6d55736bf1 100644 --- a/modules/resource_management/app/components/resource_planner_views/work_package_list/row_component.rb +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/row_component.rb @@ -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 diff --git a/modules/resource_management/app/controllers/resource_management/resource_planner_views_controller.rb b/modules/resource_management/app/controllers/resource_management/resource_planner_views_controller.rb index 36a533a347c..6375a3df611 100644 --- a/modules/resource_management/app/controllers/resource_management/resource_planner_views_controller.rb +++ b/modules/resource_management/app/controllers/resource_management/resource_planner_views_controller.rb @@ -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, diff --git a/modules/resource_management/app/controllers/resource_management/work_package_resource_allocations_controller.rb b/modules/resource_management/app/controllers/resource_management/work_package_resource_allocations_controller.rb new file mode 100644 index 00000000000..94ef1480a83 --- /dev/null +++ b/modules/resource_management/app/controllers/resource_management/work_package_resource_allocations_controller.rb @@ -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 diff --git a/modules/resource_management/app/models/resource_allocation.rb b/modules/resource_management/app/models/resource_allocation.rb index c7e11c4279c..2730de7b521 100644 --- a/modules/resource_management/app/models/resource_allocation.rb +++ b/modules/resource_management/app/models/resource_allocation.rb @@ -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, diff --git a/modules/resource_management/config/locales/en.yml b/modules/resource_management/config/locales/en.yml index 043abb6ac38..c89ad9926ec 100644 --- a/modules/resource_management/config/locales/en.yml +++ b/modules/resource_management/config/locales/en.yml @@ -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 diff --git a/modules/resource_management/config/routes.rb b/modules/resource_management/config/routes.rb index 1f8cb18d2e2..1f5d1c3f16f 100644 --- a/modules/resource_management/config/routes.rb +++ b/modules/resource_management/config/routes.rb @@ -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 diff --git a/modules/resource_management/lib/open_project/resource_management/engine.rb b/modules/resource_management/lib/open_project/resource_management/engine.rb index ceb80d80b91..7678bcd16b2 100644 --- a/modules/resource_management/lib/open_project/resource_management/engine.rb +++ b/modules/resource_management/lib/open_project/resource_management/engine.rb @@ -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 diff --git a/modules/resource_management/spec/components/resource_allocations/list_item_component_spec.rb b/modules/resource_management/spec/components/resource_allocations/list_item_component_spec.rb new file mode 100644 index 00000000000..5047810634a --- /dev/null +++ b/modules/resource_management/spec/components/resource_allocations/list_item_component_spec.rb @@ -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 diff --git a/modules/resource_management/spec/requests/work_package_resource_allocations_spec.rb b/modules/resource_management/spec/requests/work_package_resource_allocations_spec.rb new file mode 100644 index 00000000000..1b5dafc00d5 --- /dev/null +++ b/modules/resource_management/spec/requests/work_package_resource_allocations_spec.rb @@ -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