From c9d4cf293883bac3ed1fd998de6e89fcd8588c9d Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 3 Jun 2026 16:58:33 +0200 Subject: [PATCH 01/24] Add ProgressBar for Allocation Column --- .../allocation_progress_component.html.erb | 61 ++++++++++ .../allocation_progress_component.rb | 114 ++++++++++++++++++ .../work_package_list/content_component.rb | 2 +- .../work_package_list/row_component.rb | 2 +- .../app/models/resource_allocation.rb | 16 +++ .../app/models/resource_work_package_list.rb | 7 ++ .../resource_management/config/locales/en.yml | 4 + .../allocation_progress_component_spec.rb | 112 +++++++++++++++++ .../spec/models/resource_allocation_spec.rb | 38 ++++++ 9 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 modules/resource_management/app/components/resource_planner_views/work_package_list/allocation_progress_component.html.erb create mode 100644 modules/resource_management/app/components/resource_planner_views/work_package_list/allocation_progress_component.rb create mode 100644 modules/resource_management/spec/components/resource_planner_views/work_package_list/allocation_progress_component_spec.rb diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/allocation_progress_component.html.erb b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocation_progress_component.html.erb new file mode 100644 index 00000000000..2a7bfe38653 --- /dev/null +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocation_progress_component.html.erb @@ -0,0 +1,61 @@ +<%#-- 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. + +++#%> + +<% if work_scheduled? %> + <%= flex_layout do |column| %> + <%= column.with_row(mb: 1) do %> + <%= flex_layout(justify_content: :space_between, align_items: :center) do |line| %> + <%= line.with_column do %> + <%= render(Primer::Beta::Text.new(font_size: :small)) { summary } %> + <% end %> + <%= line.with_column do %> + <%= render(Primer::Beta::Text.new(font_size: :small, color: :muted)) { percentage_label } %> + <% end %> + <% end %> + <% end %> + <%= column.with_row do %> + <%= render(Primer::Beta::ProgressBar.new) do |bar| %> + <% bar.with_item(percentage: bar_percentage, bg: bar_color, aria: { label: percentage_label }) %> + <% end %> + <% end %> + <% end %> +<% else %> + <%= render Primer::Beta::Octicon.new( + icon: :"alert-fill", + color: :danger, + id: no_work_tooltip_id, + "aria-label": no_work_message + ) %> + <%= render Primer::Alpha::Tooltip.new( + for_id: no_work_tooltip_id, + type: :description, + text: no_work_message, + direction: :e + ) %> +<% end %> diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/allocation_progress_component.rb b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocation_progress_component.rb new file mode 100644 index 00000000000..ac555b2c345 --- /dev/null +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocation_progress_component.rb @@ -0,0 +1,114 @@ +# 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 ResourcePlannerViews::WorkPackageList + # Renders how much of a work package's scheduled work is covered by resource + # allocations: an "12h / 35h" label (allocated / scheduled), a percentage, + # and a colored bar. Relies on `allocated_time_total` being preloaded onto + # the work package by `ResourceAllocation.with_allocated_time`. + class AllocationProgressComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + def initialize(work_package:) + super + + @work_package = work_package + end + + # Without scheduled work there is nothing to allocate against (and no + # sensible denominator), so fall back to the muted placeholder. + def work_scheduled? + scheduled_hours.positive? + end + + private + + attr_reader :work_package + + def allocated_minutes + return 0 unless work_package.respond_to?(:allocated_time_total) + + work_package.allocated_time_total.to_i + end + + def allocated_hours + allocated_minutes / 60.0 + end + + # Total work: the rolled-up value for a parent, falling back to the work + # package's own estimate for a leaf (where `derived_estimated_hours` is nil). + def scheduled_hours + (work_package.derived_estimated_hours || work_package.estimated_hours).to_f + end + + # Share of the scheduled work covered by allocations. Capped at 100 for the + # bar width; the raw value still drives the label and over-allocation color. + def ratio + ((allocated_hours / scheduled_hours) * 100).round + end + + def bar_percentage + ratio.clamp(0, 100) + end + + def bar_color + if ratio > 100 + :danger_emphasis + elsif ratio == 100 + :success_emphasis + else + :accent_emphasis + end + end + + def summary + t("resource_management.work_package_list.allocation.summary", + allocated: hours_label(allocated_hours), + scheduled: hours_label(scheduled_hours)) + end + + def percentage_label + helpers.number_to_percentage(ratio, precision: 0) + end + + def hours_label(hours) + t("resource_management.work_package_list.allocation.hours", + value: helpers.number_with_precision(hours, precision: 1, strip_insignificant_zeros: true)) + end + + def no_work_message + t("resource_management.work_package_list.allocation.no_work") + end + + def no_work_tooltip_id + "allocation-no-work-#{work_package.id}" + end + end +end diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.rb b/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.rb index e582a72c6fb..1742200a574 100644 --- a/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.rb +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.rb @@ -41,7 +41,7 @@ module ResourcePlannerViews::WorkPackageList private def work_packages - @view.effective_query&.results&.work_packages || WorkPackage.none + @view.allocated_work_packages 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 8bcd81e4607..4c0fb504a5d 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 @@ -99,7 +99,7 @@ module ResourcePlannerViews::WorkPackageList end def allocation - render(Primer::Beta::Text.new(color: :muted)) { allocation_placeholder } + render(AllocationProgressComponent.new(work_package:)) end def allocated_members diff --git a/modules/resource_management/app/models/resource_allocation.rb b/modules/resource_management/app/models/resource_allocation.rb index dc6d794dae9..4b8cd1cccd0 100644 --- a/modules/resource_management/app/models/resource_allocation.rb +++ b/modules/resource_management/app/models/resource_allocation.rb @@ -58,6 +58,22 @@ class ResourceAllocation < ApplicationRecord scope :needs_principal_assignment, -> { where(principal_explicit: false, principal_id: nil) } scope :for_principal, ->(principal) { where(principal:) } + # Augments a WorkPackage relation with `allocated_time_total` (in minutes): + # the summed `allocated_time` of every `allocated` allocation belonging to + # each work package. A correlated subquery keeps the augmentation in a single + # query (no N+1) without multiplying rows the way a join + group by would. + def self.with_allocated_time(work_package_scope) + subquery = allocated + .where(entity_type: "WorkPackage") + .where("#{table_name}.entity_id = #{WorkPackage.table_name}.id") + .select(Arel.sql("COALESCE(SUM(#{table_name}.allocated_time), 0)")) + + work_package_scope.select( + WorkPackage.arel_table[Arel.star], + Arel.sql("(#{subquery.to_sql}) AS allocated_time_total") + ) + end + validates :state, :start_date, :end_date, presence: true validates :allocated_time, presence: true, diff --git a/modules/resource_management/app/models/resource_work_package_list.rb b/modules/resource_management/app/models/resource_work_package_list.rb index 608e509b1a1..eee49562578 100644 --- a/modules/resource_management/app/models/resource_work_package_list.rb +++ b/modules/resource_management/app/models/resource_work_package_list.rb @@ -63,6 +63,13 @@ class ResourceWorkPackageList < PersistedView effective_query&.manually_sorted? || false end + # The work packages of this view, each carrying `allocated_time_total` loaded + # straight from the database for the allocation progress column. + def allocated_work_packages + base = effective_query&.results&.work_packages || WorkPackage.none + ResourceAllocation.with_allocated_time(base) + end + private def manual_mode?(filter_mode) diff --git a/modules/resource_management/config/locales/en.yml b/modules/resource_management/config/locales/en.yml index 57d68a0434f..91cb4c05de8 100644 --- a/modules/resource_management/config/locales/en.yml +++ b/modules/resource_management/config/locales/en.yml @@ -132,6 +132,10 @@ en: work_package_list: add_work_package_dialog: title: Add work package + allocation: + hours: "%{value}h" + no_work: No work planned on this work package + summary: "%{allocated} / %{scheduled}" allocation_placeholder: — blank: description: There are no work packages matching this view's filters yet. diff --git a/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocation_progress_component_spec.rb b/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocation_progress_component_spec.rb new file mode 100644 index 00000000000..df7fd2ab81e --- /dev/null +++ b/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocation_progress_component_spec.rb @@ -0,0 +1,112 @@ +# 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 ResourcePlannerViews::WorkPackageList::AllocationProgressComponent, type: :component do + # Loads the work package through the same select augmentation the view uses, + # so `allocated_time_total` is present exactly as it is at render time. + # `derived_hours` mimics a parent's rolled-up total work; `own_hours` mimics a + # leaf whose work lives in `estimated_hours` (where derived stays nil). + def loaded_work_package(allocated_minutes:, derived_hours: nil, own_hours: nil) + work_package = create(:work_package) + work_package.update_columns(derived_estimated_hours: derived_hours, estimated_hours: own_hours) + if allocated_minutes.positive? + create(:resource_allocation, entity: work_package, allocated_time: allocated_minutes) + end + + ResourceAllocation + .with_allocated_time(WorkPackage.where(id: work_package.id)) + .first + end + + subject(:rendered) do + render_inline(described_class.new(work_package:)) + page + end + + context "when allocations cover only part of the scheduled work" do + let(:work_package) { loaded_work_package(derived_hours: 35, allocated_minutes: 12 * 60) } + + it "renders allocated / scheduled, the coverage percentage and a partial accent bar" do + expect(rendered).to have_text("12h / 35h") + expect(rendered).to have_text("34%") + expect(rendered).to have_css(".Progress-item.color-bg-accent-emphasis[style*='width: 34%']") + end + end + + context "when the work package is a leaf with only its own estimated work" do + let(:work_package) { loaded_work_package(own_hours: 35, allocated_minutes: 12 * 60) } + + it "falls back to estimated_hours for the total work" do + expect(rendered).to have_text("12h / 35h") + expect(rendered).to have_text("34%") + end + end + + context "when allocations exactly cover the scheduled work" do + let(:work_package) { loaded_work_package(derived_hours: 80, allocated_minutes: 80 * 60) } + + it "renders a full success bar at 100%" do + expect(rendered).to have_text("100%") + expect(rendered).to have_css(".Progress-item.color-bg-success-emphasis[style*='width: 100%']") + end + end + + context "when allocations exceed the scheduled work" do + let(:work_package) { loaded_work_package(derived_hours: 20, allocated_minutes: 40 * 60) } + + it "renders a danger bar capped at 100% while the label shows the real ratio" do + expect(rendered).to have_text("40h / 20h") + expect(rendered).to have_text("200%") + expect(rendered).to have_css(".Progress-item.color-bg-danger-emphasis[style*='width: 100%']") + end + end + + context "when nothing is allocated yet" do + let(:work_package) { loaded_work_package(derived_hours: 100, allocated_minutes: 0) } + + it "renders an empty accent bar at 0%" do + expect(rendered).to have_text("0h / 100h") + expect(rendered).to have_text("0%") + expect(rendered).to have_css(".Progress-item[style*='width: 0%']") + end + end + + context "when the work package has no scheduled work" do + let(:work_package) { loaded_work_package(allocated_minutes: 12 * 60) } + + it "renders a danger alert icon instead of a bar" do + expect(rendered).to have_no_css(".Progress-item") + expect(rendered).to have_css(".octicon-alert-fill") + expect(rendered).to have_text(I18n.t("resource_management.work_package_list.allocation.no_work")) + end + end +end diff --git a/modules/resource_management/spec/models/resource_allocation_spec.rb b/modules/resource_management/spec/models/resource_allocation_spec.rb index 4496de26357..cd1f1480f3c 100644 --- a/modules/resource_management/spec/models/resource_allocation_spec.rb +++ b/modules/resource_management/spec/models/resource_allocation_spec.rb @@ -604,4 +604,42 @@ RSpec.describe ResourceAllocation do end end end + + describe ".with_allocated_time" do + shared_let(:work_package) { create(:work_package) } + shared_let(:other_work_package) { create(:work_package) } + + def total + described_class + .with_allocated_time(WorkPackage.where(id: work_package.id)) + .first + .allocated_time_total + end + + it "exposes the summed allocated_time of the work package's allocations" do + create(:resource_allocation, entity: work_package, allocated_time: 120) + create(:resource_allocation, entity: work_package, allocated_time: 300) + + expect(total).to eq(420) + end + + it "counts only allocations in the 'allocated' state" do + create(:resource_allocation, entity: work_package, allocated_time: 120) + create(:resource_allocation, :requested, entity: work_package, allocated_time: 999) + create(:resource_allocation, :rejected, entity: work_package, allocated_time: 999) + create(:resource_allocation, :canceled, entity: work_package, allocated_time: 999) + + expect(total).to eq(120) + end + + it "does not bleed allocations from other work packages" do + create(:resource_allocation, entity: other_work_package, allocated_time: 500) + + expect(total).to eq(0) + end + + it "is zero when the work package has no allocations" do + expect(total).to eq(0) + end + end end From 34609d6b9f3b65f19621737196bda13fc93072b1 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 3 Jun 2026 17:29:51 +0200 Subject: [PATCH 02/24] Implement Allocated Users column --- .../content_component.rb | 8 +- .../allocated_members_component.html.erb | 46 +++++++ .../allocated_members_component.rb | 108 ++++++++++++++++ .../allocation_progress_component.rb | 16 +-- .../content_component.html.erb | 3 +- .../work_package_list/content_component.rb | 8 +- .../work_package_list/row_component.rb | 10 +- .../work_package_list/table_component.rb | 9 +- .../resource_planner_views_controller.rb | 34 +++-- .../app/models/resource_allocation.rb | 23 ++-- .../app/models/resource_work_package_list.rb | 8 +- .../resource_planner_views/show.html.erb | 2 +- .../resource_management/config/locales/en.yml | 3 + .../allocated_members_component_spec.rb | 122 ++++++++++++++++++ .../allocation_progress_component_spec.rb | 39 +++--- .../work_package_list/row_component_spec.rb | 14 +- .../spec/models/resource_allocation_spec.rb | 35 ++--- 17 files changed, 397 insertions(+), 91 deletions(-) create mode 100644 modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.html.erb create mode 100644 modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.rb create mode 100644 modules/resource_management/spec/components/resource_planner_views/work_package_list/allocated_members_component_spec.rb diff --git a/modules/resource_management/app/components/resource_planner_views/content_component.rb b/modules/resource_management/app/components/resource_planner_views/content_component.rb index f770036c471..6cd1dcd0efa 100644 --- a/modules/resource_management/app/components/resource_planner_views/content_component.rb +++ b/modules/resource_management/app/components/resource_planner_views/content_component.rb @@ -32,12 +32,14 @@ module ResourcePlannerViews class ContentComponent < ApplicationComponent include OpTurbo::Streamable - def initialize(view:, project:, resource_planner:) + def initialize(view:, project:, resource_planner:, work_packages: [], allocations: {}) super @view = view @project = project @resource_planner = resource_planner + @work_packages = work_packages + @allocations = allocations end private @@ -48,7 +50,9 @@ module ResourcePlannerViews ResourcePlannerViews::WorkPackageList::ContentComponent.new( view: @view, project: @project, - resource_planner: @resource_planner + resource_planner: @resource_planner, + work_packages: @work_packages, + allocations: @allocations ) end end diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.html.erb b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.html.erb new file mode 100644 index 00000000000..ae53904f77d --- /dev/null +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.html.erb @@ -0,0 +1,46 @@ +<%#-- 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 |container| %> + <%= container.with_column do %> + <%= render(Primer::OpenProject::AvatarStack.new(tooltipped: true, body_arguments: { label: tooltip_label })) do |stack| %> + <% avatar_options.each do |options| %> + <% stack.with_avatar_with_fallback(**options) %> + <% end %> + <% end %> + <% end %> + <%= container.with_column(ml: 2) do %> + <%= render(Primer::Beta::Text.new(font_size: :small)) { lead_name } %> + <% end %> + <% if additional? %> + <%= container.with_column(ml: 1) do %> + <%= render(Primer::Beta::Text.new(font_size: :small, color: :muted)) { additional_label } %> + <% end %> + <% end %> +<% end %> diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.rb b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.rb new file mode 100644 index 00000000000..32ad9bcd9f1 --- /dev/null +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.rb @@ -0,0 +1,108 @@ +# 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 ResourcePlannerViews::WorkPackageList + # Renders the members allocated to a work package as an avatar stack (with the + # stack's built-in "+N" overflow). An allocation with an assigned principal + # shows that user's avatar; a filter-based allocation with no principal yet + # shows a generated avatar derived from its filter name. + class AllocatedMembersComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include AvatarHelper + + AVATAR_SIZE = 20 + + def initialize(allocations:) + super + + @allocations = allocations + end + + def render? + allocations.any? + end + + private + + attr_reader :allocations + + def avatar_options + allocations.map { |allocation| avatar_options_for(allocation) } + end + + # The name shown beside the stack. The stack's own overflow indicator is not + # numeric, so the count of the remaining members is spelled out separately. + def lead_name + member_name(allocations.first) + end + + def additional_count + allocations.size - 1 + end + + def additional? + additional_count.positive? + end + + def additional_label + t("resource_management.work_package_list.allocated_members.additional", count: additional_count) + end + + # A real principal resolves to their avatar (falling back to generated + # initials when they have no image); otherwise the fallback generates a + # deterministic avatar from the member name. + def avatar_options_for(allocation) + user = allocation.principal + + { + src: (avatar_url(user) if user), + alt: member_name(allocation), + unique_id: user&.id || "resource-allocation-#{allocation.id}", + size: AVATAR_SIZE + } + end + + # Shown in the stack's hover tooltip, since the names are not rendered inline. + def tooltip_label + allocations.map { |allocation| member_name(allocation) }.join(", ") + end + + # The assigned user's name, the filter name for an unassigned filter + # allocation, or a generic label for an allocation that lost its principal + # (e.g. the assigned user was deleted) — so the avatar always has a label. + def member_name(allocation) + allocation.principal&.name.presence || allocation.filter_name.presence || unassigned_label + 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/allocation_progress_component.rb b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocation_progress_component.rb index ac555b2c345..aa96cbc019d 100644 --- a/modules/resource_management/app/components/resource_planner_views/work_package_list/allocation_progress_component.rb +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocation_progress_component.rb @@ -31,15 +31,15 @@ module ResourcePlannerViews::WorkPackageList # Renders how much of a work package's scheduled work is covered by resource # allocations: an "12h / 35h" label (allocated / scheduled), a percentage, - # and a colored bar. Relies on `allocated_time_total` being preloaded onto - # the work package by `ResourceAllocation.with_allocated_time`. + # and a colored bar. The allocations are the ones loaded once for the page. class AllocationProgressComponent < ApplicationComponent include OpPrimer::ComponentHelpers - def initialize(work_package:) + def initialize(work_package:, allocations:) super @work_package = work_package + @allocations = allocations end # Without scheduled work there is nothing to allocate against (and no @@ -50,16 +50,10 @@ module ResourcePlannerViews::WorkPackageList private - attr_reader :work_package - - def allocated_minutes - return 0 unless work_package.respond_to?(:allocated_time_total) - - work_package.allocated_time_total.to_i - end + attr_reader :work_package, :allocations def allocated_hours - allocated_minutes / 60.0 + allocations.sum { |allocation| allocation.allocated_time.to_i } / 60.0 end # Total work: the rolled-up value for a parent, falling back to the work diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.html.erb b/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.html.erb index 57e448c2ae3..42fd87f5b54 100644 --- a/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.html.erb +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.html.erb @@ -39,7 +39,8 @@ See COPYRIGHT and LICENSE files for more details. rows: work_packages, view: @view, project: @project, - resource_planner: @resource_planner + resource_planner: @resource_planner, + allocations: @allocations ) %> <% if @view.manually_picked? %> diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.rb b/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.rb index 1742200a574..e56f0ca6e03 100644 --- a/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.rb +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.rb @@ -30,18 +30,18 @@ module ResourcePlannerViews::WorkPackageList class ContentComponent < ApplicationComponent - def initialize(view:, project:, resource_planner:) + def initialize(view:, project:, resource_planner:, work_packages: [], allocations: {}) super @view = view @project = project @resource_planner = resource_planner + @work_packages = work_packages + @allocations = allocations end private - def work_packages - @view.allocated_work_packages - end + attr_reader :work_packages, :allocations 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 4c0fb504a5d..ae7f9aa2074 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 @@ -99,11 +99,13 @@ module ResourcePlannerViews::WorkPackageList end def allocation - render(AllocationProgressComponent.new(work_package:)) + render(AllocationProgressComponent.new(work_package:, allocations:)) end def allocated_members - render(Primer::Beta::Text.new(color: :muted)) { allocation_placeholder } + return render(Primer::Beta::Text.new(color: :muted)) { allocation_placeholder } if allocations.empty? + + render(AllocatedMembersComponent.new(allocations:)) end def button_links @@ -112,6 +114,10 @@ module ResourcePlannerViews::WorkPackageList private + def allocations + @allocations ||= table.allocations_for(work_package) + end + def allocation_placeholder I18n.t("resource_management.work_package_list.allocation_placeholder") end diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.rb b/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.rb index 73f8f8ba042..7f88c2f1e4c 100644 --- a/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.rb +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.rb @@ -36,16 +36,23 @@ module ResourcePlannerViews::WorkPackageList attr_reader :view, :project, :resource_planner - def initialize(view:, project:, resource_planner:, **) + def initialize(view:, project:, resource_planner:, allocations: {}, **) super(**) @view = view @project = project @resource_planner = resource_planner + @allocations = allocations end def manual? = view.manually_picked? + # The allocations of a work package, taken from the page-wide map the + # controller loaded; shared by the allocation progress and members columns. + def allocations_for(work_package) + @allocations[work_package.id] || [] + end + main_column :subject def sortable? = false 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 3c3721ec5ae..dba9a9a76dd 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 @@ -46,7 +46,9 @@ module ::ResourceManagement only: %i[new_work_package add_work_package remove_work_package move_work_package reorder_work_package] - def show; end + def show + @content_component = work_package_list_content + end def new if params[:view_class_name].present? @@ -188,12 +190,22 @@ module ::ResourceManagement end def replace_work_package_list - replace_via_turbo_stream( - component: ResourcePlannerViews::ContentComponent.new( - view: @view, - project: @project, - resource_planner: @resource_planner - ) + replace_via_turbo_stream(component: work_package_list_content) + end + + # Loads the view's work packages and their allocations in one place so the + # allocation columns (progress bar and members) share a single query rather + # than each issuing their own. + def work_package_list_content(view = @view) + work_packages = view.is_a?(ResourceWorkPackageList) ? view.work_packages.to_a : [] + allocations = ResourceAllocation.allocated_for_work_packages(work_packages) + + ResourcePlannerViews::ContentComponent.new( + view:, + project: @project, + resource_planner: @resource_planner, + work_packages:, + allocations: ) end @@ -242,13 +254,7 @@ module ::ResourceManagement selected_view: view ) ) - replace_via_turbo_stream( - component: ResourcePlannerViews::ContentComponent.new( - view:, - project: @project, - resource_planner: @resource_planner - ) - ) + replace_via_turbo_stream(component: work_package_list_content(view)) close_dialog_via_turbo_stream("##{ResourcePlannerViews::EditDialogComponent::DIALOG_ID}") respond_with_turbo_streams end diff --git a/modules/resource_management/app/models/resource_allocation.rb b/modules/resource_management/app/models/resource_allocation.rb index 4b8cd1cccd0..c7e11c4279c 100644 --- a/modules/resource_management/app/models/resource_allocation.rb +++ b/modules/resource_management/app/models/resource_allocation.rb @@ -58,20 +58,15 @@ class ResourceAllocation < ApplicationRecord scope :needs_principal_assignment, -> { where(principal_explicit: false, principal_id: nil) } scope :for_principal, ->(principal) { where(principal:) } - # Augments a WorkPackage relation with `allocated_time_total` (in minutes): - # the summed `allocated_time` of every `allocated` allocation belonging to - # each work package. A correlated subquery keeps the augmentation in a single - # query (no N+1) without multiplying rows the way a join + group by would. - def self.with_allocated_time(work_package_scope) - subquery = allocated - .where(entity_type: "WorkPackage") - .where("#{table_name}.entity_id = #{WorkPackage.table_name}.id") - .select(Arel.sql("COALESCE(SUM(#{table_name}.allocated_time), 0)")) - - work_package_scope.select( - WorkPackage.arel_table[Arel.star], - Arel.sql("(#{subquery.to_sql}) AS allocated_time_total") - ) + # The `allocated` allocations for the given work packages, grouped by work + # package id and with principals eager-loaded. Loaded once per page so the + # allocation columns (progress bar and members) share a single query. + def self.allocated_for_work_packages(work_packages) + allocated + .where(entity_type: "WorkPackage", entity_id: work_packages.map(&:id)) + .includes(:principal) + .order(:id) + .group_by(&:entity_id) end validates :state, :start_date, :end_date, presence: true diff --git a/modules/resource_management/app/models/resource_work_package_list.rb b/modules/resource_management/app/models/resource_work_package_list.rb index eee49562578..c4128c4b6bd 100644 --- a/modules/resource_management/app/models/resource_work_package_list.rb +++ b/modules/resource_management/app/models/resource_work_package_list.rb @@ -63,11 +63,9 @@ class ResourceWorkPackageList < PersistedView effective_query&.manually_sorted? || false end - # The work packages of this view, each carrying `allocated_time_total` loaded - # straight from the database for the allocation progress column. - def allocated_work_packages - base = effective_query&.results&.work_packages || WorkPackage.none - ResourceAllocation.with_allocated_time(base) + # The work packages selected by this view's query. + def work_packages + effective_query&.results&.work_packages || WorkPackage.none end private diff --git a/modules/resource_management/app/views/resource_management/resource_planner_views/show.html.erb b/modules/resource_management/app/views/resource_management/resource_planner_views/show.html.erb index 5ba10e34226..65f51c3eb4c 100644 --- a/modules/resource_management/app/views/resource_management/resource_planner_views/show.html.erb +++ b/modules/resource_management/app/views/resource_management/resource_planner_views/show.html.erb @@ -41,4 +41,4 @@ See COPYRIGHT and LICENSE files for more details. end %> <%= render(ResourcePlanners::SubViewsComponent.new(resource_planner: @resource_planner, selected_view: @view)) %> -<%= render(ResourcePlannerViews::ContentComponent.new(view: @view, project: @project, resource_planner: @resource_planner)) %> +<%= render(@content_component) %> diff --git a/modules/resource_management/config/locales/en.yml b/modules/resource_management/config/locales/en.yml index 91cb4c05de8..7d300ae426c 100644 --- a/modules/resource_management/config/locales/en.yml +++ b/modules/resource_management/config/locales/en.yml @@ -132,6 +132,9 @@ en: work_package_list: add_work_package_dialog: title: Add work package + allocated_members: + additional: "+%{count}" + unassigned: Unassigned allocation: hours: "%{value}h" no_work: No work planned on this work package diff --git a/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocated_members_component_spec.rb b/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocated_members_component_spec.rb new file mode 100644 index 00000000000..111aa70f9b3 --- /dev/null +++ b/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocated_members_component_spec.rb @@ -0,0 +1,122 @@ +# 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 ResourcePlannerViews::WorkPackageList::AllocatedMembersComponent, type: :component do + shared_let(:work_package) { create(:work_package) } + shared_let(:assignee) { create(:user, firstname: "Michael", lastname: "Johnson") } + + def assigned_allocation(principal) + create(:resource_allocation, entity: work_package, principal:) + end + + def filter_allocation(name) + create(:resource_allocation, + entity: work_package, + principal_explicit: false, + principal: nil, + filter_name: name) + end + + subject(:rendered) do + render_inline(described_class.new(allocations:)) + page + end + + before { login_as(create(:admin)) } + + context "with a single assigned member" do + let(:allocations) { [assigned_allocation(assignee)] } + + it "renders an avatar stack with the member's name and no extra count" do + expect(rendered).to have_css(".AvatarStack") + expect(rendered).to have_css("avatar-fallback[data-unique-id='#{assignee.id}'][data-alt-text='Michael Johnson']") + expect(rendered).to have_text("Michael Johnson") + expect(rendered).to have_no_text("+") + end + end + + context "with several members" do + let(:others) { create_list(:user, 2) } + let(:allocations) { [assigned_allocation(assignee), *others.map { |u| assigned_allocation(u) }] } + + it "stacks an avatar per member and shows the lead name with a +N count of the rest" do + expect(rendered).to have_css("avatar-fallback", count: 3) + expect(rendered).to have_text("Michael Johnson") + expect(rendered).to have_text("+2") + end + end + + context "with exactly two members" do + let(:allocations) { [assigned_allocation(assignee), assigned_allocation(create(:user))] } + + it "shows the lead name and a +1 count" do + expect(rendered).to have_css("avatar-fallback", count: 2) + expect(rendered).to have_text("Michael Johnson") + expect(rendered).to have_text("+1") + end + end + + context "with a filter-based allocation that has no assigned user" do + let(:allocations) { [filter_allocation("Full stack Developer (DE-EN)")] } + + it "renders a generated avatar keyed to the allocation, labelled with the filter name" do + expect(rendered).to have_css("avatar-fallback[data-unique-id='resource-allocation-#{allocations.first.id}']") + expect(rendered).to have_css("avatar-fallback[data-alt-text='Full stack Developer (DE-EN)']") + expect(rendered).to have_text("Full stack Developer (DE-EN)") + end + end + + context "with an allocation whose principal was removed" do + # Mimics `dependent: :nullify` after the assigned user is deleted: an + # explicit allocation left without a principal and without a filter name. + let(:allocations) do + allocation = assigned_allocation(assignee) + allocation.update_column(:principal_id, nil) + [allocation] + end + + it "renders a generated 'Unassigned' avatar instead of raising" do + label = I18n.t("resource_management.work_package_list.allocated_members.unassigned") + expect(rendered).to have_css("avatar-fallback[data-alt-text='#{label}']") + expect(rendered).to have_text(label) + end + end + + context "without any allocations" do + let(:allocations) { [] } + + it "renders nothing" do + expect(rendered).to have_no_css(".AvatarStack") + expect(rendered).to have_no_css("avatar-fallback") + end + end +end diff --git a/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocation_progress_component_spec.rb b/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocation_progress_component_spec.rb index df7fd2ab81e..43d7473b1ba 100644 --- a/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocation_progress_component_spec.rb +++ b/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocation_progress_component_spec.rb @@ -31,31 +31,29 @@ require "rails_helper" RSpec.describe ResourcePlannerViews::WorkPackageList::AllocationProgressComponent, type: :component do - # Loads the work package through the same select augmentation the view uses, - # so `allocated_time_total` is present exactly as it is at render time. # `derived_hours` mimics a parent's rolled-up total work; `own_hours` mimics a # leaf whose work lives in `estimated_hours` (where derived stays nil). - def loaded_work_package(allocated_minutes:, derived_hours: nil, own_hours: nil) - work_package = create(:work_package) - work_package.update_columns(derived_estimated_hours: derived_hours, estimated_hours: own_hours) - if allocated_minutes.positive? - create(:resource_allocation, entity: work_package, allocated_time: allocated_minutes) + def work_package_with(derived_hours: nil, own_hours: nil) + create(:work_package).tap do |wp| + wp.update_columns(derived_estimated_hours: derived_hours, estimated_hours: own_hours) end + end - ResourceAllocation - .with_allocated_time(WorkPackage.where(id: work_package.id)) - .first + # Allocations are only summed by their `allocated_time`, so stubbed records suffice. + def allocations_totaling(*hours) + hours.map { |value| build_stubbed(:resource_allocation, allocated_time: (value * 60).to_i) } end subject(:rendered) do - render_inline(described_class.new(work_package:)) + render_inline(described_class.new(work_package:, allocations:)) page end context "when allocations cover only part of the scheduled work" do - let(:work_package) { loaded_work_package(derived_hours: 35, allocated_minutes: 12 * 60) } + let(:work_package) { work_package_with(derived_hours: 35) } + let(:allocations) { allocations_totaling(8, 4) } - it "renders allocated / scheduled, the coverage percentage and a partial accent bar" do + it "sums the allocations and renders allocated / scheduled with a partial accent bar" do expect(rendered).to have_text("12h / 35h") expect(rendered).to have_text("34%") expect(rendered).to have_css(".Progress-item.color-bg-accent-emphasis[style*='width: 34%']") @@ -63,7 +61,8 @@ RSpec.describe ResourcePlannerViews::WorkPackageList::AllocationProgressComponen end context "when the work package is a leaf with only its own estimated work" do - let(:work_package) { loaded_work_package(own_hours: 35, allocated_minutes: 12 * 60) } + let(:work_package) { work_package_with(own_hours: 35) } + let(:allocations) { allocations_totaling(12) } it "falls back to estimated_hours for the total work" do expect(rendered).to have_text("12h / 35h") @@ -72,7 +71,8 @@ RSpec.describe ResourcePlannerViews::WorkPackageList::AllocationProgressComponen end context "when allocations exactly cover the scheduled work" do - let(:work_package) { loaded_work_package(derived_hours: 80, allocated_minutes: 80 * 60) } + let(:work_package) { work_package_with(derived_hours: 80) } + let(:allocations) { allocations_totaling(80) } it "renders a full success bar at 100%" do expect(rendered).to have_text("100%") @@ -81,7 +81,8 @@ RSpec.describe ResourcePlannerViews::WorkPackageList::AllocationProgressComponen end context "when allocations exceed the scheduled work" do - let(:work_package) { loaded_work_package(derived_hours: 20, allocated_minutes: 40 * 60) } + let(:work_package) { work_package_with(derived_hours: 20) } + let(:allocations) { allocations_totaling(40) } it "renders a danger bar capped at 100% while the label shows the real ratio" do expect(rendered).to have_text("40h / 20h") @@ -91,7 +92,8 @@ RSpec.describe ResourcePlannerViews::WorkPackageList::AllocationProgressComponen end context "when nothing is allocated yet" do - let(:work_package) { loaded_work_package(derived_hours: 100, allocated_minutes: 0) } + let(:work_package) { work_package_with(derived_hours: 100) } + let(:allocations) { [] } it "renders an empty accent bar at 0%" do expect(rendered).to have_text("0h / 100h") @@ -101,7 +103,8 @@ RSpec.describe ResourcePlannerViews::WorkPackageList::AllocationProgressComponen end context "when the work package has no scheduled work" do - let(:work_package) { loaded_work_package(allocated_minutes: 12 * 60) } + let(:work_package) { work_package_with } + let(:allocations) { allocations_totaling(12) } it "renders a danger alert icon instead of a bar" do expect(rendered).to have_no_css(".Progress-item") diff --git a/modules/resource_management/spec/components/resource_planner_views/work_package_list/row_component_spec.rb b/modules/resource_management/spec/components/resource_planner_views/work_package_list/row_component_spec.rb index fbce73b9970..68659046aea 100644 --- a/modules/resource_management/spec/components/resource_planner_views/work_package_list/row_component_spec.rb +++ b/modules/resource_management/spec/components/resource_planner_views/work_package_list/row_component_spec.rb @@ -42,9 +42,10 @@ RSpec.describe ResourcePlannerViews::WorkPackageList::RowComponent, type: :compo let(:view) do ResourceWorkPackageList.create!(name: "List", parent: resource_planner, project:, principal: user, query:) end + let(:allocations) { {} } let(:table) do ResourcePlannerViews::WorkPackageList::TableComponent.new( - rows: work_packages, view:, project:, resource_planner: + rows: work_packages, view:, project:, resource_planner:, allocations: ) end @@ -88,6 +89,17 @@ RSpec.describe ResourcePlannerViews::WorkPackageList::RowComponent, type: :compo end end + context "with members allocated to the work package" do + let(:query) { automatic_query } + let(:member) { create(:user, firstname: "Michael", lastname: "Johnson") } + let(:allocation) { create(:resource_allocation, entity: work_packages.first, principal: member) } + let(:allocations) { { work_packages.first.id => [allocation] } } + + it "renders the allocated members' avatar stack instead of the placeholder" do + expect(rendered).to have_css("avatar-fallback[data-unique-id='#{member.id}']") + end + end + context "with a manually hand-picked view" do let(:query) { manual_query } diff --git a/modules/resource_management/spec/models/resource_allocation_spec.rb b/modules/resource_management/spec/models/resource_allocation_spec.rb index cd1f1480f3c..6437cdd0249 100644 --- a/modules/resource_management/spec/models/resource_allocation_spec.rb +++ b/modules/resource_management/spec/models/resource_allocation_spec.rb @@ -605,41 +605,42 @@ RSpec.describe ResourceAllocation do end end - describe ".with_allocated_time" do + describe ".allocated_for_work_packages" do shared_let(:work_package) { create(:work_package) } shared_let(:other_work_package) { create(:work_package) } - def total - described_class - .with_allocated_time(WorkPackage.where(id: work_package.id)) - .first - .allocated_time_total + def allocations_for(*work_packages) + described_class.allocated_for_work_packages(work_packages) end - it "exposes the summed allocated_time of the work package's allocations" do - create(:resource_allocation, entity: work_package, allocated_time: 120) - create(:resource_allocation, entity: work_package, allocated_time: 300) + it "groups the work package's allocations by entity id" do + first = create(:resource_allocation, entity: work_package, allocated_time: 120) + second = create(:resource_allocation, entity: work_package, allocated_time: 300) - expect(total).to eq(420) + expect(allocations_for(work_package)).to eq(work_package.id => [first, second]) end - it "counts only allocations in the 'allocated' state" do - create(:resource_allocation, entity: work_package, allocated_time: 120) + it "includes only allocations in the 'allocated' state" do + allocated = create(:resource_allocation, entity: work_package, allocated_time: 120) create(:resource_allocation, :requested, entity: work_package, allocated_time: 999) create(:resource_allocation, :rejected, entity: work_package, allocated_time: 999) create(:resource_allocation, :canceled, entity: work_package, allocated_time: 999) - expect(total).to eq(120) + expect(allocations_for(work_package)).to eq(work_package.id => [allocated]) end - it "does not bleed allocations from other work packages" do + it "covers several work packages and omits those without allocations" do + mine = create(:resource_allocation, entity: work_package, allocated_time: 120) create(:resource_allocation, entity: other_work_package, allocated_time: 500) - expect(total).to eq(0) + result = allocations_for(work_package, other_work_package) + + expect(result[work_package.id]).to eq([mine]) + expect(result).to have_key(other_work_package.id) end - it "is zero when the work package has no allocations" do - expect(total).to eq(0) + it "is empty when the work packages have no allocations" do + expect(allocations_for(work_package)).to eq({}) end end end From 5c1f2215138c882644bc0904ab33b4a3d9e46fe9 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 3 Jun 2026 17:43:19 +0200 Subject: [PATCH 03/24] Only show visible users --- .../content_component.rb | 6 ++- .../allocated_members_component.html.erb | 30 +++++++------- .../allocated_members_component.rb | 39 ++++++++++++++++--- .../content_component.html.erb | 3 +- .../work_package_list/content_component.rb | 5 ++- .../work_package_list/row_component.rb | 2 +- .../work_package_list/table_component.rb | 5 ++- .../resource_planner_views_controller.rb | 13 ++++++- .../resource_management/config/locales/en.yml | 3 ++ .../allocated_members_component_spec.rb | 33 +++++++++++++++- 10 files changed, 111 insertions(+), 28 deletions(-) diff --git a/modules/resource_management/app/components/resource_planner_views/content_component.rb b/modules/resource_management/app/components/resource_planner_views/content_component.rb index 6cd1dcd0efa..8d645e01531 100644 --- a/modules/resource_management/app/components/resource_planner_views/content_component.rb +++ b/modules/resource_management/app/components/resource_planner_views/content_component.rb @@ -32,7 +32,7 @@ module ResourcePlannerViews class ContentComponent < ApplicationComponent include OpTurbo::Streamable - def initialize(view:, project:, resource_planner:, work_packages: [], allocations: {}) + def initialize(view:, project:, resource_planner:, work_packages: [], allocations: {}, visible_principal_ids: nil) super @view = view @@ -40,6 +40,7 @@ module ResourcePlannerViews @resource_planner = resource_planner @work_packages = work_packages @allocations = allocations + @visible_principal_ids = visible_principal_ids end private @@ -52,7 +53,8 @@ module ResourcePlannerViews project: @project, resource_planner: @resource_planner, work_packages: @work_packages, - allocations: @allocations + allocations: @allocations, + visible_principal_ids: @visible_principal_ids ) end end diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.html.erb b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.html.erb index ae53904f77d..230047048d5 100644 --- a/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.html.erb +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.html.erb @@ -27,20 +27,24 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= flex_layout(align_items: :center) do |container| %> - <%= container.with_column do %> - <%= render(Primer::OpenProject::AvatarStack.new(tooltipped: true, body_arguments: { label: tooltip_label })) do |stack| %> - <% avatar_options.each do |options| %> - <% stack.with_avatar_with_fallback(**options) %> +<% if named? %> + <%= flex_layout(align_items: :center) do |container| %> + <%= container.with_column do %> + <%= render(Primer::OpenProject::AvatarStack.new(tooltipped: true, body_arguments: { label: tooltip_label })) do |stack| %> + <% avatar_options.each do |options| %> + <% stack.with_avatar_with_fallback(**options) %> + <% end %> + <% end %> + <% end %> + <%= container.with_column(ml: 2) do %> + <%= render(Primer::Beta::Text.new(font_size: :small)) { lead_name } %> + <% end %> + <% if additional? %> + <%= container.with_column(ml: 1) do %> + <%= render(Primer::Beta::Text.new(font_size: :small, color: :muted)) { additional_label } %> <% end %> <% end %> <% end %> - <%= container.with_column(ml: 2) do %> - <%= render(Primer::Beta::Text.new(font_size: :small)) { lead_name } %> - <% end %> - <% if additional? %> - <%= container.with_column(ml: 1) do %> - <%= render(Primer::Beta::Text.new(font_size: :small, color: :muted)) { additional_label } %> - <% end %> - <% end %> +<% else %> + <%= render(Primer::Beta::Text.new(font_size: :small, color: :muted)) { anonymous_label } %> <% end %> diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.rb b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.rb index 32ad9bcd9f1..abf9f26f44b 100644 --- a/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.rb +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.rb @@ -39,30 +39,53 @@ module ResourcePlannerViews::WorkPackageList AVATAR_SIZE = 20 - def initialize(allocations:) + # `visible_principal_ids` are the principals the current user may see. Members + # whose principal is not in that set are still counted, but never named or + # given a (potentially identity-revealing) avatar. A nil set means no + # restriction. + def initialize(allocations:, visible_principal_ids: nil) super @allocations = allocations + @visible_principal_ids = visible_principal_ids end def render? allocations.any? end + # Whether at least one member can be named — a visible user or a filter + # placeholder. When false, every member is hidden and only a count is shown. + def named? + identifiable.any? + end + private - attr_reader :allocations + attr_reader :allocations, :visible_principal_ids + + # Members we may reveal: filter placeholders (no user) and allocations whose + # principal is visible to the current user. + def identifiable + @identifiable ||= allocations.select do |allocation| + allocation.principal_id.nil? || + visible_principal_ids.nil? || + visible_principal_ids.include?(allocation.principal_id) + end + end def avatar_options - allocations.map { |allocation| avatar_options_for(allocation) } + identifiable.map { |allocation| avatar_options_for(allocation) } end # The name shown beside the stack. The stack's own overflow indicator is not # numeric, so the count of the remaining members is spelled out separately. def lead_name - member_name(allocations.first) + member_name(identifiable.first) end + # Members beyond the named lead, including those hidden from this user, so + # hidden allocations are surfaced as a count rather than silently dropped. def additional_count allocations.size - 1 end @@ -75,6 +98,11 @@ module ResourcePlannerViews::WorkPackageList t("resource_management.work_package_list.allocated_members.additional", count: additional_count) end + # Shown when none of the members may be named: a bare count of the members. + def anonymous_label + t("resource_management.work_package_list.allocated_members.other_users", count: allocations.size) + end + # A real principal resolves to their avatar (falling back to generated # initials when they have no image); otherwise the fallback generates a # deterministic avatar from the member name. @@ -90,8 +118,9 @@ module ResourcePlannerViews::WorkPackageList end # Shown in the stack's hover tooltip, since the names are not rendered inline. + # Only names members we may reveal. def tooltip_label - allocations.map { |allocation| member_name(allocation) }.join(", ") + identifiable.map { |allocation| member_name(allocation) }.join(", ") end # The assigned user's name, the filter name for an unassigned filter diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.html.erb b/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.html.erb index 42fd87f5b54..47672e7c80e 100644 --- a/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.html.erb +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.html.erb @@ -40,7 +40,8 @@ See COPYRIGHT and LICENSE files for more details. view: @view, project: @project, resource_planner: @resource_planner, - allocations: @allocations + allocations: @allocations, + visible_principal_ids: @visible_principal_ids ) %> <% if @view.manually_picked? %> diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.rb b/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.rb index e56f0ca6e03..b5ca7009642 100644 --- a/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.rb +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.rb @@ -30,7 +30,7 @@ module ResourcePlannerViews::WorkPackageList class ContentComponent < ApplicationComponent - def initialize(view:, project:, resource_planner:, work_packages: [], allocations: {}) + def initialize(view:, project:, resource_planner:, work_packages: [], allocations: {}, visible_principal_ids: nil) super @view = view @@ -38,10 +38,11 @@ module ResourcePlannerViews::WorkPackageList @resource_planner = resource_planner @work_packages = work_packages @allocations = allocations + @visible_principal_ids = visible_principal_ids end private - attr_reader :work_packages, :allocations + attr_reader :work_packages, :allocations, :visible_principal_ids 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 ae7f9aa2074..2447ab74d4b 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 @@ -105,7 +105,7 @@ module ResourcePlannerViews::WorkPackageList def allocated_members return render(Primer::Beta::Text.new(color: :muted)) { allocation_placeholder } if allocations.empty? - render(AllocatedMembersComponent.new(allocations:)) + render(AllocatedMembersComponent.new(allocations:, visible_principal_ids: table.visible_principal_ids)) end def button_links diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.rb b/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.rb index 7f88c2f1e4c..deb89e6b636 100644 --- a/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.rb +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.rb @@ -34,15 +34,16 @@ module ResourcePlannerViews::WorkPackageList mobile_columns :subject, :priority - attr_reader :view, :project, :resource_planner + attr_reader :view, :project, :resource_planner, :visible_principal_ids - def initialize(view:, project:, resource_planner:, allocations: {}, **) + def initialize(view:, project:, resource_planner:, allocations: {}, visible_principal_ids: nil, **) super(**) @view = view @project = project @resource_planner = resource_planner @allocations = allocations + @visible_principal_ids = visible_principal_ids end def manual? = view.manually_picked? 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 dba9a9a76dd..36a533a347c 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 @@ -205,10 +205,21 @@ module ::ResourceManagement project: @project, resource_planner: @resource_planner, work_packages:, - allocations: + allocations:, + visible_principal_ids: visible_principal_ids(allocations) ) 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/config/locales/en.yml b/modules/resource_management/config/locales/en.yml index 7d300ae426c..043abb6ac38 100644 --- a/modules/resource_management/config/locales/en.yml +++ b/modules/resource_management/config/locales/en.yml @@ -134,6 +134,9 @@ en: title: Add work package allocated_members: additional: "+%{count}" + other_users: + one: "%{count} user" + other: "%{count} users" unassigned: Unassigned allocation: hours: "%{value}h" diff --git a/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocated_members_component_spec.rb b/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocated_members_component_spec.rb index 111aa70f9b3..d308c876401 100644 --- a/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocated_members_component_spec.rb +++ b/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocated_members_component_spec.rb @@ -46,8 +46,11 @@ RSpec.describe ResourcePlannerViews::WorkPackageList::AllocatedMembersComponent, filter_name: name) end + # nil means no visibility restriction (every principal is visible). + let(:visible_principal_ids) { nil } + subject(:rendered) do - render_inline(described_class.new(allocations:)) + render_inline(described_class.new(allocations:, visible_principal_ids:)) page end @@ -111,6 +114,34 @@ RSpec.describe ResourcePlannerViews::WorkPackageList::AllocatedMembersComponent, end end + context "with a member the current user cannot see" do + let(:hidden_user) { create(:user, firstname: "Hidden", lastname: "Person") } + let(:allocations) { [assigned_allocation(assignee), assigned_allocation(hidden_user)] } + let(:visible_principal_ids) { Set[assignee.id] } + + it "names only the visible member but still counts the hidden one" do + expect(rendered).to have_css("avatar-fallback[data-unique-id='#{assignee.id}']") + expect(rendered).to have_text("Michael Johnson") + expect(rendered).to have_text("+1") + end + + it "does not reveal the hidden member's avatar or name" do + expect(rendered).to have_no_css("avatar-fallback[data-unique-id='#{hidden_user.id}']") + expect(rendered).to have_no_text("Hidden Person") + end + end + + context "when every member is hidden from the current user" do + let(:allocations) { [assigned_allocation(assignee), assigned_allocation(create(:user))] } + let(:visible_principal_ids) { Set.new } + + it "shows an anonymous count instead of names or avatars" do + expect(rendered).to have_no_css("avatar-fallback") + expect(rendered).to have_no_text("Michael Johnson") + expect(rendered).to have_text("2 users") + end + end + context "without any allocations" do let(:allocations) { [] } From 9455bdc831f11b07be15a1b79a01afc9fc99453e Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Mon, 8 Jun 2026 10:45:24 +0200 Subject: [PATCH 04/24] Resource Allocation info dialog --- .../list_dialog_component.html.erb | 69 ++++++++++++ .../list_dialog_component.rb | 66 +++++++++++ .../list_item_component.html.erb | 40 +++++++ .../list_item_component.rb | 99 ++++++++++++++++ .../work_package_list/row_component.rb | 8 +- .../resource_planner_views_controller.rb | 12 +- ...package_resource_allocations_controller.rb | 70 ++++++++++++ .../app/models/resource_allocation.rb | 9 ++ .../resource_management/config/locales/en.yml | 4 + modules/resource_management/config/routes.rb | 6 + .../resource_management/engine.rb | 1 + .../list_item_component_spec.rb | 77 +++++++++++++ .../work_package_resource_allocations_spec.rb | 106 ++++++++++++++++++ 13 files changed, 554 insertions(+), 13 deletions(-) create mode 100644 modules/resource_management/app/components/resource_allocations/list_dialog_component.html.erb create mode 100644 modules/resource_management/app/components/resource_allocations/list_dialog_component.rb create mode 100644 modules/resource_management/app/components/resource_allocations/list_item_component.html.erb create mode 100644 modules/resource_management/app/components/resource_allocations/list_item_component.rb create mode 100644 modules/resource_management/app/controllers/resource_management/work_package_resource_allocations_controller.rb create mode 100644 modules/resource_management/spec/components/resource_allocations/list_item_component_spec.rb create mode 100644 modules/resource_management/spec/requests/work_package_resource_allocations_spec.rb 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 From 74a113800cb91a5fe4cbcc1a2f85b50a9a1295cc Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 9 Jun 2026 15:41:50 +0200 Subject: [PATCH 05/24] Use large portrait dialog for the dialog --- .../resource_allocations/list_dialog_component.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 01757fe40f9..95c4a6a72a6 100644 --- 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 @@ -28,7 +28,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= - render(Primer::Alpha::Dialog.new(id: DIALOG_ID, title:, size: :medium_portrait, position: :right)) do |dialog| + render(Primer::Alpha::Dialog.new(id: DIALOG_ID, title:, size: :large, classes: "Overlay--size-large-portrait")) do |dialog| dialog.with_header(variant: :large) dialog.with_body do From 95f78830233558b4db4f86be70d0628fe033cdca Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 10 Jun 2026 14:19:05 +0200 Subject: [PATCH 06/24] Move AllocationProgressComponent to shared ResourceAllocations namespace --- .../list_dialog_component.html.erb | 2 +- .../resource_allocations/list_item_component.rb | 2 +- .../progress_component.html.erb} | 0 .../progress_component.rb} | 13 +++++++------ .../work_package_list/row_component.rb | 2 +- modules/resource_management/config/locales/en.yml | 8 ++++---- .../progress_component_spec.rb} | 4 ++-- 7 files changed, 16 insertions(+), 15 deletions(-) rename modules/resource_management/app/components/{resource_planner_views/work_package_list/allocation_progress_component.html.erb => resource_allocations/progress_component.html.erb} (100%) rename modules/resource_management/app/components/{resource_planner_views/work_package_list/allocation_progress_component.rb => resource_allocations/progress_component.rb} (89%) rename modules/resource_management/spec/components/{resource_planner_views/work_package_list/allocation_progress_component_spec.rb => resource_allocations/progress_component_spec.rb} (96%) 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 index 95c4a6a72a6..05752ce60d1 100644 --- 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 @@ -34,7 +34,7 @@ See COPYRIGHT and LICENSE files for more details. dialog.with_body do flex_layout do |body| body.with_row(mb: 3) do - render(ResourcePlannerViews::WorkPackageList::AllocationProgressComponent.new(work_package:, allocations:)) + render(ResourceAllocations::ProgressComponent.new(work_package:, allocations:)) end allocations.each do |allocation| 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 index 0179dc7e753..d8af1a7cb16 100644 --- a/modules/resource_management/app/components/resource_allocations/list_item_component.rb +++ b/modules/resource_management/app/components/resource_allocations/list_item_component.rb @@ -84,7 +84,7 @@ module ResourceAllocations end def hours - t("resource_management.work_package_list.allocation.hours", + t("resource_management.allocation.hours", value: helpers.number_with_precision(allocation.allocated_hours, precision: 1, strip_insignificant_zeros: true)) end diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/allocation_progress_component.html.erb b/modules/resource_management/app/components/resource_allocations/progress_component.html.erb similarity index 100% rename from modules/resource_management/app/components/resource_planner_views/work_package_list/allocation_progress_component.html.erb rename to modules/resource_management/app/components/resource_allocations/progress_component.html.erb diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/allocation_progress_component.rb b/modules/resource_management/app/components/resource_allocations/progress_component.rb similarity index 89% rename from modules/resource_management/app/components/resource_planner_views/work_package_list/allocation_progress_component.rb rename to modules/resource_management/app/components/resource_allocations/progress_component.rb index aa96cbc019d..fd1afa43a8d 100644 --- a/modules/resource_management/app/components/resource_planner_views/work_package_list/allocation_progress_component.rb +++ b/modules/resource_management/app/components/resource_allocations/progress_component.rb @@ -28,11 +28,12 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module ResourcePlannerViews::WorkPackageList +module ResourceAllocations # Renders how much of a work package's scheduled work is covered by resource # allocations: an "12h / 35h" label (allocated / scheduled), a percentage, - # and a colored bar. The allocations are the ones loaded once for the page. - class AllocationProgressComponent < ApplicationComponent + # and a colored bar. Shared by the work package list column and the + # allocations dialog. + class ProgressComponent < ApplicationComponent include OpPrimer::ComponentHelpers def initialize(work_package:, allocations:) @@ -83,7 +84,7 @@ module ResourcePlannerViews::WorkPackageList end def summary - t("resource_management.work_package_list.allocation.summary", + t("resource_management.allocation.summary", allocated: hours_label(allocated_hours), scheduled: hours_label(scheduled_hours)) end @@ -93,12 +94,12 @@ module ResourcePlannerViews::WorkPackageList end def hours_label(hours) - t("resource_management.work_package_list.allocation.hours", + t("resource_management.allocation.hours", value: helpers.number_with_precision(hours, precision: 1, strip_insignificant_zeros: true)) end def no_work_message - t("resource_management.work_package_list.allocation.no_work") + t("resource_management.allocation.no_work") end def no_work_tooltip_id 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 c6d55736bf1..35ec40f36e3 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 @@ -99,7 +99,7 @@ module ResourcePlannerViews::WorkPackageList end def allocation - render(AllocationProgressComponent.new(work_package:, allocations:)) + render(ResourceAllocations::ProgressComponent.new(work_package:, allocations:)) end def allocated_members diff --git a/modules/resource_management/config/locales/en.yml b/modules/resource_management/config/locales/en.yml index c89ad9926ec..0d572a24cf1 100644 --- a/modules/resource_management/config/locales/en.yml +++ b/modules/resource_management/config/locales/en.yml @@ -63,6 +63,10 @@ en: make_private: Make private make_public: Make public unfavorite: Remove from favorites + allocation: + hours: "%{value}h" + no_work: No work planned on this work package + summary: "%{allocated} / %{scheduled}" allocate_resource_dialog: kind: filter: @@ -142,10 +146,6 @@ en: one: "%{count} user" other: "%{count} users" unassigned: Unassigned - allocation: - hours: "%{value}h" - no_work: No work planned on this work package - summary: "%{allocated} / %{scheduled}" allocation_placeholder: — blank: description: There are no work packages matching this view's filters yet. diff --git a/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocation_progress_component_spec.rb b/modules/resource_management/spec/components/resource_allocations/progress_component_spec.rb similarity index 96% rename from modules/resource_management/spec/components/resource_planner_views/work_package_list/allocation_progress_component_spec.rb rename to modules/resource_management/spec/components/resource_allocations/progress_component_spec.rb index 43d7473b1ba..b898ce8e321 100644 --- a/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocation_progress_component_spec.rb +++ b/modules/resource_management/spec/components/resource_allocations/progress_component_spec.rb @@ -30,7 +30,7 @@ require "rails_helper" -RSpec.describe ResourcePlannerViews::WorkPackageList::AllocationProgressComponent, type: :component do +RSpec.describe ResourceAllocations::ProgressComponent, type: :component do # `derived_hours` mimics a parent's rolled-up total work; `own_hours` mimics a # leaf whose work lives in `estimated_hours` (where derived stays nil). def work_package_with(derived_hours: nil, own_hours: nil) @@ -109,7 +109,7 @@ RSpec.describe ResourcePlannerViews::WorkPackageList::AllocationProgressComponen it "renders a danger alert icon instead of a bar" do expect(rendered).to have_no_css(".Progress-item") expect(rendered).to have_css(".octicon-alert-fill") - expect(rendered).to have_text(I18n.t("resource_management.work_package_list.allocation.no_work")) + expect(rendered).to have_text(I18n.t("resource_management.allocation.no_work")) end end end From c43ced62503cd4d58602bd359aec5dc39c3a73af Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 10 Jun 2026 14:20:37 +0200 Subject: [PATCH 07/24] Render dialog allocation times as duration pills --- .../resource_allocations/list_item_component.html.erb | 2 +- .../components/resource_allocations/list_item_component.rb | 6 +++--- .../resource_allocations/list_item_component_spec.rb | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) 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 index 061897a15f3..50a0d294f04 100644 --- 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 @@ -35,6 +35,6 @@ See COPYRIGHT and LICENSE files for more details. <%= 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 } %> + <%= render(Primer::Beta::Label.new(size: :large)) { duration } %> <% 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 index d8af1a7cb16..69bcbb9ed3b 100644 --- a/modules/resource_management/app/components/resource_allocations/list_item_component.rb +++ b/modules/resource_management/app/components/resource_allocations/list_item_component.rb @@ -83,9 +83,9 @@ module ResourceAllocations end end - def hours - t("resource_management.allocation.hours", - value: helpers.number_with_precision(allocation.allocated_hours, precision: 1, strip_insignificant_zeros: true)) + # Formatted per the instance's duration format setting, e.g. "2d 4h". + def duration + DurationConverter.output(allocation.allocated_hours) end def hidden_label 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 index 5047810634a..f0d3255c25d 100644 --- 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 @@ -48,7 +48,7 @@ RSpec.describe ResourceAllocations::ListItemComponent, type: :component do 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(".Label", text: "12h") expect(rendered).to have_css("avatar-fallback[data-unique-id='#{member.id}']") end end From 563f2b0d58c2928d76d86a5b9c62cb5ec46f4956 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 10 Jun 2026 14:21:45 +0200 Subject: [PATCH 08/24] Use person-add icon for unassigned allocations and the allocate link --- .../list_dialog_component.html.erb | 2 +- .../list_item_component.html.erb | 2 +- .../list_item_component.rb | 18 ++++++++++++------ .../list_item_component_spec.rb | 4 +++- 4 files changed, 17 insertions(+), 9 deletions(-) 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 index 05752ce60d1..a289a495379 100644 --- 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 @@ -54,7 +54,7 @@ See COPYRIGHT and LICENSE files for more details. href: allocate_resource_path, data: { controller: "async-dialog" } ) do |button| - button.with_leading_visual_icon(icon: :plus) + button.with_leading_visual_icon(icon: :"person-add") t("resource_management.work_package_allocations_dialog.allocate_resource") 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 index 50a0d294f04..eb1198a9709 100644 --- 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 @@ -29,7 +29,7 @@ See COPYRIGHT and LICENSE files for more details. <%= flex_layout(align_items: :center) do |row| %> <%= row.with_column(mr: 2) do %> - <%= render(avatar) %> + <%= render(leading_visual) %> <% end %> <%= row.with_column(flex: 1) do %> <%= render(Primer::Beta::Text.new(font_size: :small)) { name } %> 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 index 69bcbb9ed3b..072e73d72d5 100644 --- a/modules/resource_management/app/components/resource_allocations/list_item_component.rb +++ b/modules/resource_management/app/components/resource_allocations/list_item_component.rb @@ -61,15 +61,21 @@ module ResourceAllocations 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) + # An allocation without a user yet (filter placeholder or lost principal) + # shows a person-add icon instead of an avatar. + def leading_visual + if allocation.principal + Primer::OpenProject::AvatarWithFallback.new(size: AVATAR_SIZE, **avatar_options) + else + Primer::Beta::Octicon.new(icon: :"person-add", color: :muted, "aria-hidden": true) + end end + # Only a visible principal exposes a real avatar (and an identity-revealing + # initials/colour seed). A hidden one falls back to a generated avatar keyed + # to the allocation, so the user cannot be correlated. def avatar_options - if allocation.principal && visible? + if visible? { src: avatar_url(allocation.principal), alt: allocation.principal.name, 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 index f0d3255c25d..cbda753c13b 100644 --- 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 @@ -70,8 +70,10 @@ RSpec.describe ResourceAllocations::ListItemComponent, type: :component do entity: work_package, principal_explicit: false, principal: nil, filter_name: "Full stack developer") end - it "shows the filter name" do + it "shows the filter name with a person-add icon instead of an avatar" do expect(rendered).to have_text("Full stack developer") + expect(rendered).to have_css(".octicon-person-add") + expect(rendered).to have_no_css("avatar-fallback") end end end From b78e7329e0f903fb75a808bcac8cd1ae1657d36d Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 10 Jun 2026 14:23:44 +0200 Subject: [PATCH 09/24] Wrap dialog allocation list in a BorderBox via streamable ListComponent --- .../list_component.html.erb | 47 +++++++++++++++ .../resource_allocations/list_component.rb | 58 +++++++++++++++++++ .../list_dialog_component.html.erb | 12 +--- .../list_dialog_component.rb | 9 +-- 4 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 modules/resource_management/app/components/resource_allocations/list_component.html.erb create mode 100644 modules/resource_management/app/components/resource_allocations/list_component.rb diff --git a/modules/resource_management/app/components/resource_allocations/list_component.html.erb b/modules/resource_management/app/components/resource_allocations/list_component.html.erb new file mode 100644 index 00000000000..830f71bf0be --- /dev/null +++ b/modules/resource_management/app/components/resource_allocations/list_component.html.erb @@ -0,0 +1,47 @@ +<%#-- 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. + +++#%> + +<%= component_wrapper do %> + <%= flex_layout do |body| %> + <%= body.with_row(mb: 3) do %> + <%= render(ResourceAllocations::ProgressComponent.new(work_package:, allocations:)) %> + <% end %> + <% if allocations.any? %> + <%= body.with_row do %> + <%= render(Primer::Beta::BorderBox.new) do |box| %> + <% allocations.each do |allocation| %> + <% box.with_row do %> + <%= render(ResourceAllocations::ListItemComponent.new(allocation:, visible: visible_principal?(allocation))) %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/modules/resource_management/app/components/resource_allocations/list_component.rb b/modules/resource_management/app/components/resource_allocations/list_component.rb new file mode 100644 index 00000000000..a547f254c23 --- /dev/null +++ b/modules/resource_management/app/components/resource_allocations/list_component.rb @@ -0,0 +1,58 @@ +# 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 + # The body of the work package allocations dialog: the allocation progress + # summary and one row per allocation. Streamable so the dialog content can be + # refreshed after an allocation changes. Allocations whose principal is not + # visible to the current user are still listed, but anonymised. + class ListComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + 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 visible_principal?(allocation) + allocation.principal_id.nil? || visible_principal_ids.include?(allocation.principal_id) + end + end +end 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 index a289a495379..8257d9fb05c 100644 --- 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 @@ -32,17 +32,7 @@ See COPYRIGHT and LICENSE files for more details. dialog.with_header(variant: :large) dialog.with_body do - flex_layout do |body| - body.with_row(mb: 3) do - render(ResourceAllocations::ProgressComponent.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 + render(ResourceAllocations::ListComponent.new(project:, work_package:, allocations:, visible_principal_ids:)) end dialog.with_footer(show_divider: true) do 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 index 038cc540410..524dfb593d1 100644 --- a/modules/resource_management/app/components/resource_allocations/list_dialog_component.rb +++ b/modules/resource_management/app/components/resource_allocations/list_dialog_component.rb @@ -29,9 +29,8 @@ #++ 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. + # The dialog shell around a work package's allocation list, with a footer to + # allocate another resource. class ListDialogComponent < ApplicationComponent include OpTurbo::Streamable include OpPrimer::ComponentHelpers @@ -55,10 +54,6 @@ module ResourceAllocations 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 From 8100f22c6f04d05af3125d6ce0a85b0e49016d5f Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 10 Jun 2026 14:32:15 +0200 Subject: [PATCH 10/24] Flag overbooked allocations with a warning icon in the dialog --- .../list_component.html.erb | 6 ++- .../resource_allocations/list_component.rb | 9 +++- .../list_dialog_component.html.erb | 2 +- .../list_dialog_component.rb | 5 ++- .../list_item_component.html.erb | 16 +++++++ .../list_item_component.rb | 15 ++++++- ...package_resource_allocations_controller.rb | 3 +- .../app/models/resource_allocation.rb | 36 +++++++++++++++ .../resource_allocations/availability.rb | 6 ++- .../resource_management/config/locales/en.yml | 1 + .../list_item_component_spec.rb | 13 +++++- .../spec/models/resource_allocation_spec.rb | 45 +++++++++++++++++++ .../work_package_resource_allocations_spec.rb | 18 ++++++++ 13 files changed, 165 insertions(+), 10 deletions(-) diff --git a/modules/resource_management/app/components/resource_allocations/list_component.html.erb b/modules/resource_management/app/components/resource_allocations/list_component.html.erb index 830f71bf0be..a0b7af1020e 100644 --- a/modules/resource_management/app/components/resource_allocations/list_component.html.erb +++ b/modules/resource_management/app/components/resource_allocations/list_component.html.erb @@ -37,7 +37,11 @@ See COPYRIGHT and LICENSE files for more details. <%= render(Primer::Beta::BorderBox.new) do |box| %> <% allocations.each do |allocation| %> <% box.with_row do %> - <%= render(ResourceAllocations::ListItemComponent.new(allocation:, visible: visible_principal?(allocation))) %> + <%= render ResourceAllocations::ListItemComponent.new( + allocation:, + visible: visible_principal?(allocation), + overbooked: overbooked?(allocation) + ) %> <% end %> <% end %> <% end %> diff --git a/modules/resource_management/app/components/resource_allocations/list_component.rb b/modules/resource_management/app/components/resource_allocations/list_component.rb index a547f254c23..898e13c7e25 100644 --- a/modules/resource_management/app/components/resource_allocations/list_component.rb +++ b/modules/resource_management/app/components/resource_allocations/list_component.rb @@ -38,21 +38,26 @@ module ResourceAllocations include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(project:, work_package:, allocations:, visible_principal_ids:) + def initialize(project:, work_package:, allocations:, visible_principal_ids:, overbooked_ids: Set.new) super @project = project @work_package = work_package @allocations = allocations @visible_principal_ids = visible_principal_ids + @overbooked_ids = overbooked_ids end private - attr_reader :project, :work_package, :allocations, :visible_principal_ids + attr_reader :project, :work_package, :allocations, :visible_principal_ids, :overbooked_ids def visible_principal?(allocation) allocation.principal_id.nil? || visible_principal_ids.include?(allocation.principal_id) end + + def overbooked?(allocation) + overbooked_ids.include?(allocation.id) + end end end 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 index 8257d9fb05c..9e193a5d6e7 100644 --- 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 @@ -32,7 +32,7 @@ See COPYRIGHT and LICENSE files for more details. dialog.with_header(variant: :large) dialog.with_body do - render(ResourceAllocations::ListComponent.new(project:, work_package:, allocations:, visible_principal_ids:)) + render(ResourceAllocations::ListComponent.new(project:, work_package:, allocations:, visible_principal_ids:, overbooked_ids:)) end dialog.with_footer(show_divider: true) do 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 index 524dfb593d1..20d563ca899 100644 --- a/modules/resource_management/app/components/resource_allocations/list_dialog_component.rb +++ b/modules/resource_management/app/components/resource_allocations/list_dialog_component.rb @@ -37,18 +37,19 @@ module ResourceAllocations DIALOG_ID = "work-package-allocations-dialog" - def initialize(project:, work_package:, allocations:, visible_principal_ids:) + def initialize(project:, work_package:, allocations:, visible_principal_ids:, overbooked_ids: Set.new) super @project = project @work_package = work_package @allocations = allocations @visible_principal_ids = visible_principal_ids + @overbooked_ids = overbooked_ids end private - attr_reader :project, :work_package, :allocations, :visible_principal_ids + attr_reader :project, :work_package, :allocations, :visible_principal_ids, :overbooked_ids def title I18n.t("resource_management.work_package_allocations_dialog.title") 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 index eb1198a9709..a2f118121fb 100644 --- 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 @@ -37,4 +37,20 @@ See COPYRIGHT and LICENSE files for more details. <%= row.with_column do %> <%= render(Primer::Beta::Label.new(size: :large)) { duration } %> <% end %> + <% if overbooked? %> + <%= row.with_column(ml: 2, classes: "d-flex") do %> + <%= render Primer::Beta::Octicon.new( + icon: :"alert-fill", + color: :danger, + id: overbooked_icon_id, + "aria-label": overbooked_message + ) %> + <%= render Primer::Alpha::Tooltip.new( + for_id: overbooked_icon_id, + type: :description, + text: overbooked_message, + direction: :s + ) %> + <% end %> + <% 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 index 072e73d72d5..e52858da441 100644 --- a/modules/resource_management/app/components/resource_allocations/list_item_component.rb +++ b/modules/resource_management/app/components/resource_allocations/list_item_component.rb @@ -38,11 +38,12 @@ module ResourceAllocations AVATAR_SIZE = 24 - def initialize(allocation:, visible:) + def initialize(allocation:, visible:, overbooked: false) super @allocation = allocation @visible = visible + @overbooked = overbooked end private @@ -53,6 +54,10 @@ module ResourceAllocations @visible end + def overbooked? + @overbooked + end + def name if allocation.principal visible? ? allocation.principal.name : hidden_label @@ -94,6 +99,14 @@ module ResourceAllocations DurationConverter.output(allocation.allocated_hours) end + def overbooked_icon_id + "resource-allocation-overbooked-#{allocation.id}" + end + + def overbooked_message + t("resource_management.work_package_allocations_dialog.overbooked") + end + def hidden_label t("resource_management.work_package_allocations_dialog.hidden_user") end 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 index 94ef1480a83..123424e24e6 100644 --- 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 @@ -46,7 +46,8 @@ module ::ResourceManagement project: @project, work_package: @work_package, allocations:, - visible_principal_ids: ResourceAllocation.visible_principal_ids(allocations, current_user) + visible_principal_ids: ResourceAllocation.visible_principal_ids(allocations, current_user), + overbooked_ids: ResourceAllocation.overbooked_ids(allocations) ) end diff --git a/modules/resource_management/app/models/resource_allocation.rb b/modules/resource_management/app/models/resource_allocation.rb index 2730de7b521..da15a7813cd 100644 --- a/modules/resource_management/app/models/resource_allocation.rb +++ b/modules/resource_management/app/models/resource_allocation.rb @@ -78,6 +78,42 @@ class ResourceAllocation < ApplicationRecord Principal.visible(user).where(id: principal_ids).pluck(:id).to_set end + # The ids of the given allocations that fall into a range in which their + # assigned user is overbooked. 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 query; only the per-user capacity + # calendar still queries per checked user. + def self.overbooked_ids(allocations) + checkable = overbooking_checkable_principals(allocations) + return Set.new if checkable.empty? + + booked = allocated.for_principal(checkable).group_by(&:principal_id) + overbooked = checkable.flat_map { |principal| overbooked_ids_of(principal, booked.fetch(principal.id, [])) } + + overbooked.to_set & allocations.map(&:id) + end + + # The ids of all allocations falling into a range in which the user is + # overbooked, given the user's booked allocations. + def self.overbooked_ids_of(principal, booked) + ResourceAllocations::Availability + .new(user: principal, allocations: booked) + .overbooked_ranges + .flat_map { |range| range.items.map(&:id) } + end + private_class_method :overbooked_ids_of + + # The given allocations' assigned users whose capacity is known, i.e. who + # have working hours configured, fetched in a single query. + def self.overbooking_checkable_principals(allocations) + principals = allocations.filter_map(&:principal).uniq + checkable_ids = UserWorkingHours.for_user(principals).distinct.pluck(:user_id).to_set + + principals.select { |principal| checkable_ids.include?(principal.id) } + end + private_class_method :overbooking_checkable_principals + validates :state, :start_date, :end_date, presence: true validates :allocated_time, presence: true, diff --git a/modules/resource_management/app/services/resource_allocations/availability.rb b/modules/resource_management/app/services/resource_allocations/availability.rb index 908e77ddd09..d228210b854 100644 --- a/modules/resource_management/app/services/resource_allocations/availability.rb +++ b/modules/resource_management/app/services/resource_allocations/availability.rb @@ -40,8 +40,12 @@ module ResourceAllocations # checking a not-yet-persisted allocation. CANDIDATE_ID = :candidate - def initialize(user:) + # `allocations` takes the user's `allocated` allocations when the caller + # already loaded them (e.g. in bulk for several users); they are queried + # lazily otherwise. + def initialize(user:, allocations: nil) @user = user + @allocations = allocations end def overbooked? diff --git a/modules/resource_management/config/locales/en.yml b/modules/resource_management/config/locales/en.yml index 0d572a24cf1..169f511edca 100644 --- a/modules/resource_management/config/locales/en.yml +++ b/modules/resource_management/config/locales/en.yml @@ -136,6 +136,7 @@ en: work_package_allocations_dialog: allocate_resource: Allocate resource hidden_user: Hidden user + overbooked: The user is allocated beyond their working hours during this period. title: Allocation work_package_list: add_work_package_dialog: 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 index cbda753c13b..f5558ab7f53 100644 --- 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 @@ -35,9 +35,10 @@ RSpec.describe ResourceAllocations::ListItemComponent, type: :component do shared_let(:member) { create(:user, firstname: "Sarah", lastname: "Smith") } let(:visible) { true } + let(:overbooked) { false } subject(:rendered) do - render_inline(described_class.new(allocation:, visible:)) + render_inline(described_class.new(allocation:, visible:, overbooked:)) page end @@ -50,6 +51,16 @@ RSpec.describe ResourceAllocations::ListItemComponent, type: :component do expect(rendered).to have_text("Sarah Smith") expect(rendered).to have_css(".Label", text: "12h") expect(rendered).to have_css("avatar-fallback[data-unique-id='#{member.id}']") + expect(rendered).to have_no_css(".octicon-alert-fill") + end + end + + context "with an overbooked assigned member" do + let(:allocation) { create(:resource_allocation, entity: work_package, principal: member, allocated_time: 720) } + let(:overbooked) { true } + + it "shows a danger warning icon" do + expect(rendered).to have_css(".octicon-alert-fill#resource-allocation-overbooked-#{allocation.id}") end end diff --git a/modules/resource_management/spec/models/resource_allocation_spec.rb b/modules/resource_management/spec/models/resource_allocation_spec.rb index 6437cdd0249..fd54dea341f 100644 --- a/modules/resource_management/spec/models/resource_allocation_spec.rb +++ b/modules/resource_management/spec/models/resource_allocation_spec.rb @@ -643,4 +643,49 @@ RSpec.describe ResourceAllocation do expect(allocations_for(work_package)).to eq({}) end end + + describe ".overbooked_ids" do + shared_let(:work_package) { create(:work_package) } + shared_let(:assignee) { create(:user) } + + # The factory books Mon-Fri 2026-01-05..09 with 40 hours — exactly the + # capacity of a full-time (8h/day) week. + def allocation(**attributes) + create(:resource_allocation, entity: work_package, principal: assignee, **attributes) + end + + def overbooked_allocation + allocation(start_date: Date.new(2026, 2, 2), end_date: Date.new(2026, 2, 2), allocated_time: 16 * 60) + end + + context "when the assigned user has working hours configured" do + shared_let(:working_hours) { create(:user_working_hours, user: assignee, valid_from: Date.new(2025, 1, 1)) } + + it "returns the ids of the allocations in overbooked ranges" do + fitting = allocation + overbooked = overbooked_allocation + + expect(described_class.overbooked_ids([fitting, overbooked])).to eq(Set[overbooked.id]) + end + + it "is empty when every allocation fits" do + fitting = allocation + + expect(described_class.overbooked_ids([fitting])).to be_empty + end + end + + context "when the assigned user has no working hours configured" do + it "is empty, since the user's capacity is unknown rather than zero" do + expect(described_class.overbooked_ids([overbooked_allocation])).to be_empty + end + end + + it "ignores filter-based allocations without an assigned user" do + filter = create(:resource_allocation, + entity: work_package, principal_explicit: false, principal: nil, filter_name: "Developer") + + expect(described_class.overbooked_ids([filter])).to be_empty + 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 index 1b5dafc00d5..5f2fe9ff8bd 100644 --- a/modules/resource_management/spec/requests/work_package_resource_allocations_spec.rb +++ b/modules/resource_management/spec/requests/work_package_resource_allocations_spec.rb @@ -75,6 +75,24 @@ RSpec.describe "WorkPackage resource allocations requests", type: :rails_request expect(response.body).not_to include("Secret Agent") expect(response.body).to include(I18n.t("resource_management.work_package_allocations_dialog.hidden_user")) end + + context "when an assigned user is overbooked" do + let!(:overbooked_allocation) do + create(:resource_allocation, + entity: work_package, principal: assignee, + start_date: Date.new(2026, 2, 2), end_date: Date.new(2026, 2, 2), allocated_time: 16 * 60) + end + + before do + create(:user_working_hours, user: assignee, valid_from: Date.new(2025, 1, 1)) + end + + it "flags the overbooked allocation with a warning" do + get path, as: :turbo_stream + + expect(response.body).to include("resource-allocation-overbooked-#{overbooked_allocation.id}") + end + end end describe "authorization" do From 50c1277d97f35eb8a4e436bd83f7aa8119582131 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 10 Jun 2026 14:40:11 +0200 Subject: [PATCH 11/24] Allow editing and deleting allocations from the dialog row menu --- .../allocation_step/form_component.html.erb | 4 +- .../allocation_step/form_component.rb | 23 +++- .../edit_dialog_component.html.erb | 69 ++++++++++++ .../edit_dialog_component.rb | 60 +++++++++++ .../list_component.html.erb | 4 +- .../resource_allocations/list_component.rb | 6 ++ .../list_item_component.html.erb | 5 + .../list_item_component.rb | 52 ++++++++- .../resource_allocations_controller.rb | 100 ++++++++++++++++-- .../resource_management/config/locales/en.yml | 7 ++ .../list_item_component_spec.rb | 32 +++++- .../requests/resource_allocations_spec.rb | 99 +++++++++++++++++ .../work_package_resource_allocations_spec.rb | 26 +++++ 13 files changed, 470 insertions(+), 17 deletions(-) create mode 100644 modules/resource_management/app/components/resource_allocations/edit_dialog_component.html.erb create mode 100644 modules/resource_management/app/components/resource_allocations/edit_dialog_component.rb diff --git a/modules/resource_management/app/components/resource_allocations/allocation_step/form_component.html.erb b/modules/resource_management/app/components/resource_allocations/allocation_step/form_component.html.erb index 39ab9a45490..962fd2f7db2 100644 --- a/modules/resource_management/app/components/resource_allocations/allocation_step/form_component.html.erb +++ b/modules/resource_management/app/components/resource_allocations/allocation_step/form_component.html.erb @@ -3,8 +3,8 @@ primer_form_with( model: @allocation, scope: :resource_allocation, - url: project_resource_allocations_path(@project), - method: :post, + url: form_url, + method: form_method, html: { data: { turbo_stream: true, diff --git a/modules/resource_management/app/components/resource_allocations/allocation_step/form_component.rb b/modules/resource_management/app/components/resource_allocations/allocation_step/form_component.rb index 817af14a69f..a0bac5ec018 100644 --- a/modules/resource_management/app/components/resource_allocations/allocation_step/form_component.rb +++ b/modules/resource_management/app/components/resource_allocations/allocation_step/form_component.rb @@ -35,11 +35,16 @@ module ResourceAllocations include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(allocation:, project:, allocation_kind:) + # `dialog_id` names the dialog hosting the form (autocompleter dropdowns + # attach to it): the create wizard's by default, the edit dialog's when + # editing a persisted allocation. + def initialize(allocation:, project:, allocation_kind:, + dialog_id: ResourceAllocations::NewDialogComponent::DIALOG_ID) super @allocation = allocation @project = project @allocation_kind = allocation_kind + @dialog_id = dialog_id end def wrapper_key @@ -48,12 +53,24 @@ module ResourceAllocations private + attr_reader :dialog_id + def filter_based? @allocation_kind.to_s == "filter" end - def dialog_id - ResourceAllocations::NewDialogComponent::DIALOG_ID + # A persisted allocation submits an update to itself; a new one goes + # through the create flow (with its confirmation step). + def form_url + if @allocation.persisted? + project_resource_allocation_path(@project, @allocation) + else + project_resource_allocations_path(@project) + end + end + + def form_method + @allocation.persisted? ? :patch : :post end def form_list_component(form) diff --git a/modules/resource_management/app/components/resource_allocations/edit_dialog_component.html.erb b/modules/resource_management/app/components/resource_allocations/edit_dialog_component.html.erb new file mode 100644 index 00000000000..c681882f144 --- /dev/null +++ b/modules/resource_management/app/components/resource_allocations/edit_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: :large, + classes: "Overlay--size-large-portrait", + data: { "keep-open-on-submit": true } + ) + ) do |dialog| + dialog.with_header(variant: :large) + + dialog.with_body(classes: "Overlay-body_autocomplete_height") do + render( + ResourceAllocations::AllocationStep::FormComponent.new( + allocation:, + project:, + allocation_kind:, + dialog_id: DIALOG_ID + ) + ) + end + + dialog.with_footer do + component_collection do |buttons| + buttons.with_component( + Primer::Beta::Button.new(data: { "close-dialog-id": DIALOG_ID }, mr: 1) + ) { I18n.t(:button_cancel) } + + buttons.with_component( + Primer::Beta::Button.new( + scheme: :primary, + form: ResourceAllocations::NewDialogComponent::FORM_ID, + type: :submit + ) + ) { I18n.t("resource_management.edit_allocation_dialog.submit") } + end + end + end +%> diff --git a/modules/resource_management/app/components/resource_allocations/edit_dialog_component.rb b/modules/resource_management/app/components/resource_allocations/edit_dialog_component.rb new file mode 100644 index 00000000000..83bade15b00 --- /dev/null +++ b/modules/resource_management/app/components/resource_allocations/edit_dialog_component.rb @@ -0,0 +1,60 @@ +# 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-step dialog to edit a persisted allocation, reusing the create + # wizard's allocation form (which submits an update for persisted records). + class EditDialogComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + DIALOG_ID = "edit-resource-allocation-dialog" + + def initialize(project:, allocation:) + super + + @project = project + @allocation = allocation + end + + private + + attr_reader :project, :allocation + + def allocation_kind + allocation.principal_explicit? ? "principal" : "filter" + end + + def title + I18n.t("resource_management.edit_allocation_dialog.title") + end + end +end diff --git a/modules/resource_management/app/components/resource_allocations/list_component.html.erb b/modules/resource_management/app/components/resource_allocations/list_component.html.erb index a0b7af1020e..1d604c51067 100644 --- a/modules/resource_management/app/components/resource_allocations/list_component.html.erb +++ b/modules/resource_management/app/components/resource_allocations/list_component.html.erb @@ -39,8 +39,10 @@ See COPYRIGHT and LICENSE files for more details. <% box.with_row do %> <%= render ResourceAllocations::ListItemComponent.new( allocation:, + project:, visible: visible_principal?(allocation), - overbooked: overbooked?(allocation) + overbooked: overbooked?(allocation), + editable: editable? ) %> <% end %> <% end %> diff --git a/modules/resource_management/app/components/resource_allocations/list_component.rb b/modules/resource_management/app/components/resource_allocations/list_component.rb index 898e13c7e25..3fb62e7ba05 100644 --- a/modules/resource_management/app/components/resource_allocations/list_component.rb +++ b/modules/resource_management/app/components/resource_allocations/list_component.rb @@ -59,5 +59,11 @@ module ResourceAllocations def overbooked?(allocation) overbooked_ids.include?(allocation.id) end + + def editable? + return @editable if defined?(@editable) + + @editable = User.current.allowed_in_project?(:allocate_user_resources, project) + 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 index a2f118121fb..b8afad2a68b 100644 --- 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 @@ -53,4 +53,9 @@ See COPYRIGHT and LICENSE files for more details. ) %> <% end %> <% end %> + <% if menu? %> + <%= row.with_column(ml: 2) do %> + <%= context_menu %> + <% end %> + <% 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 index e52858da441..ddd51ade720 100644 --- a/modules/resource_management/app/components/resource_allocations/list_item_component.rb +++ b/modules/resource_management/app/components/resource_allocations/list_item_component.rb @@ -38,17 +38,22 @@ module ResourceAllocations AVATAR_SIZE = 24 - def initialize(allocation:, visible:, overbooked: false) + # `editable` enables the row's edit/delete menu (the caller checks the + # permission). It stays hidden for an anonymised row regardless, since the + # edit form would reveal the hidden user. + def initialize(allocation:, project:, visible:, overbooked: false, editable: false) super @allocation = allocation + @project = project @visible = visible @overbooked = overbooked + @editable = editable end private - attr_reader :allocation + attr_reader :allocation, :project def visible? @visible @@ -58,6 +63,49 @@ module ResourceAllocations @overbooked end + def menu? + @editable && visible? + end + + def context_menu + render(Primer::Alpha::ActionMenu.new) do |menu| + menu.with_show_button(icon: "kebab-horizontal", + "aria-label": t("resource_management.work_package_allocations_dialog.context_menu_label"), + scheme: :invisible) + + edit_item(menu) + delete_item(menu) + end + end + + def edit_item(menu) + menu.with_item( + label: I18n.t(:button_edit), + tag: :a, + href: helpers.edit_project_resource_allocation_path(project, allocation), + content_arguments: { data: { controller: "async-dialog" } } + ) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + + def delete_item(menu) + menu.with_item( + label: I18n.t(:button_delete), + scheme: :danger, + href: helpers.project_resource_allocation_path(project, allocation), + form_arguments: { + method: :delete, + data: { + turbo_confirm: t("resource_management.work_package_allocations_dialog.delete_confirmation"), + turbo_stream: true + } + } + ) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + def name if allocation.principal visible? ? allocation.principal.name : hidden_label diff --git a/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb b/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb index b605de16c18..63da0a4fc56 100644 --- a/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb +++ b/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb @@ -35,6 +35,7 @@ module ::ResourceManagement before_action :find_project_by_project_id before_action :authorize + before_action :find_resource_allocation, only: %i[edit update destroy] def new respond_with_dialog ResourceAllocations::NewDialogComponent.new( @@ -54,30 +55,57 @@ module ::ResourceManagement # date picker. Uses the EmptyContract so in-progress input never surfaces # validation errors while the user types. def refresh_form - allocation = set_attributes(create_params, contract_class: EmptyContract).result + allocation = set_attributes(allocation_params, contract_class: EmptyContract).result replace_via_turbo_stream( component: ResourceAllocations::AllocationStep::ScheduleViolationBannerComponent.new(allocation:) ) respond_with_turbo_streams end - def edit; end + def edit + respond_with_dialog ResourceAllocations::EditDialogComponent.new( + project: @project, + allocation: @resource_allocation + ) + end def create # The confirmation step's "Back" button resubmits the carried form values # so the editable step can be re-rendered pre-filled. return render_back_step if params[:back].present? - validation = set_attributes(create_params) + validation = set_attributes(allocation_params) return render_allocation_step(validation.result, status: :unprocessable_entity) if validation.failure? return render_warning_step(validation.result) if needs_confirmation?(validation.result) persist_allocation end - def update; end + def update + call = ResourceAllocations::UpdateService + .new(user: current_user, model: @resource_allocation) + .call(allocation_params) - def destroy; end + if call.success? + render_update_success(call.result) + else + render_edit_form(call.result, status: :unprocessable_entity) + end + end + + def destroy + entity = @resource_allocation.entity + call = ResourceAllocations::DeleteService + .new(user: current_user, model: @resource_allocation) + .call + + if call.success? + render_destroy_success(entity) + else + render_error_flash_message_via_turbo_stream(message: call.errors.full_messages.to_sentence) + respond_with_turbo_streams + end + end private @@ -117,7 +145,7 @@ module ::ResourceManagement def persist_allocation call = ResourceAllocations::CreateService .new(user: current_user, model: ResourceAllocation.new) - .call(create_params) + .call(allocation_params) if call.success? render_create_success @@ -127,7 +155,7 @@ module ::ResourceManagement end def render_back_step - render_allocation_step(set_attributes(create_params).result) + render_allocation_step(set_attributes(allocation_params).result) end # A final confirmation step is shown only when the allocation would overbook @@ -191,6 +219,53 @@ module ::ResourceManagement respond_with_turbo_streams end + def render_edit_form(allocation, status:) + replace_via_turbo_stream( + component: ResourceAllocations::AllocationStep::FormComponent.new( + allocation:, + project: @project, + allocation_kind:, + dialog_id: ResourceAllocations::EditDialogComponent::DIALOG_ID + ), + status: + ) + respond_with_turbo_streams(status:) + end + + def render_update_success(allocation) + render_success_flash_message_via_turbo_stream( + message: I18n.t("resource_management.edit_allocation_dialog.success_message") + ) + close_dialog_via_turbo_stream("##{ResourceAllocations::EditDialogComponent::DIALOG_ID}") + refresh_allocations_list(allocation.entity) + respond_with_turbo_streams + end + + def render_destroy_success(entity) + render_success_flash_message_via_turbo_stream( + message: I18n.t("resource_management.work_package_allocations_dialog.delete_success") + ) + refresh_allocations_list(entity) + respond_with_turbo_streams + end + + # Re-renders the allocation list of the work package's allocations dialog. + # The stream is a no-op on the client when that dialog is not open. + def refresh_allocations_list(work_package) + return unless work_package.is_a?(WorkPackage) + + allocations = ResourceAllocation.allocated_for_work_packages([work_package])[work_package.id] || [] + replace_via_turbo_stream( + component: ResourceAllocations::ListComponent.new( + project: @project, + work_package:, + allocations:, + visible_principal_ids: ResourceAllocation.visible_principal_ids(allocations, current_user), + overbooked_ids: ResourceAllocation.overbooked_ids(allocations) + ) + ) + end + def allocation_kind params[:allocation_kind].presence || "principal" end @@ -214,7 +289,16 @@ module ::ResourceManagement .to_h end - def create_params + # Only allocations of work packages reachable by the current user within + # the project may be touched; anything else 404s. + def find_resource_allocation + @resource_allocation = ResourceAllocation + .where(entity_type: "WorkPackage", + entity_id: WorkPackage.visible(current_user).where(project: @project)) + .find(params.expect(:id)) + end + + def allocation_params permitted = params .expect(resource_allocation: %i[principal_id filter_name start_date end_date allocated_hours entity_type entity_id]) diff --git a/modules/resource_management/config/locales/en.yml b/modules/resource_management/config/locales/en.yml index 169f511edca..4b6991cea58 100644 --- a/modules/resource_management/config/locales/en.yml +++ b/modules/resource_management/config/locales/en.yml @@ -108,6 +108,10 @@ en: A custom list of items you manually add and remove. Filtering is not possible. label: Manually hand-picked title: Configure view + edit_allocation_dialog: + submit: Save + success_message: Resource allocation updated. + title: Edit allocation favorite_caption: >- Make this view a favorite to add it on the top section of the sidebar menu. label_new_resource_planner: New resource planner @@ -135,6 +139,9 @@ en: label: Users card list work_package_allocations_dialog: allocate_resource: Allocate resource + context_menu_label: Allocation actions + delete_confirmation: Are you sure you want to delete this allocation? This cannot be undone. + delete_success: Resource allocation deleted. hidden_user: Hidden user overbooked: The user is allocated beyond their working hours during this period. title: Allocation 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 index f5558ab7f53..8ed51262b0a 100644 --- 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 @@ -36,9 +36,10 @@ RSpec.describe ResourceAllocations::ListItemComponent, type: :component do let(:visible) { true } let(:overbooked) { false } + let(:editable) { false } subject(:rendered) do - render_inline(described_class.new(allocation:, visible:, overbooked:)) + render_inline(described_class.new(allocation:, project: work_package.project, visible:, overbooked:, editable:)) page end @@ -64,6 +65,35 @@ RSpec.describe ResourceAllocations::ListItemComponent, type: :component do end end + context "when the user may manage allocations" do + let(:allocation) { create(:resource_allocation, entity: work_package, principal: member, allocated_time: 720) } + let(:editable) { true } + + it "offers an edit/delete menu" do + expect(rendered).to have_css("action-menu") + expect(rendered).to have_css("a[href*='/resource_allocations/#{allocation.id}/edit']", visible: :all) + expect(rendered).to have_button(I18n.t(:button_delete), visible: :all) + end + end + + context "when the user may manage allocations but not see the member" do + let(:allocation) { create(:resource_allocation, entity: work_package, principal: member, allocated_time: 720) } + let(:editable) { true } + let(:visible) { false } + + it "offers no menu, since editing would reveal the hidden user" do + expect(rendered).to have_no_css("action-menu") + end + end + + context "when the user may not manage allocations" do + let(:allocation) { create(:resource_allocation, entity: work_package, principal: member, allocated_time: 720) } + + it "offers no menu" do + expect(rendered).to have_no_css("action-menu") + 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 } diff --git a/modules/resource_management/spec/requests/resource_allocations_spec.rb b/modules/resource_management/spec/requests/resource_allocations_spec.rb index 4d659c95b32..a36efdc4514 100644 --- a/modules/resource_management/spec/requests/resource_allocations_spec.rb +++ b/modules/resource_management/spec/requests/resource_allocations_spec.rb @@ -451,8 +451,85 @@ RSpec.describe "ResourceAllocations requests", end end + describe "GET edit" do + shared_let(:allocation) { create(:resource_allocation, entity: work_package, principal: assignee) } + + it "opens the edit dialog with the allocation form" do + get edit_project_resource_allocation_path(project, allocation), as: :turbo_stream + + expect(response).to have_http_status(:ok) + expect(response.body).to include(I18n.t("resource_management.edit_allocation_dialog.title")) + expect(response.body).to include("resource_allocation[allocated_hours]") + end + + context "for an allocation of another project's work package" do + let(:other_allocation) { create(:resource_allocation) } + + it "is not found" do + get edit_project_resource_allocation_path(project, other_allocation), as: :turbo_stream + + expect(response).to have_http_status(:not_found) + end + end + end + + describe "PATCH update" do + let!(:allocation) do + create(:resource_allocation, entity: work_package, principal: assignee, allocated_time: 600) + end + + def perform(allocated_hours: "16h") + patch project_resource_allocation_path(project, allocation), + params: { + allocation_kind: "principal", + resource_allocation: { + principal_id: assignee.id, + entity_type: "WorkPackage", + entity_id: work_package.id, + start_date: "2026-03-02", + end_date: "2026-03-06", + allocated_hours: + } + }, + as: :turbo_stream + end + + it "updates the allocation and confirms it" do + perform + + expect(response).to have_http_status(:ok) + expect(allocation.reload.allocated_time).to eq(16 * 60) + expect(allocation.start_date).to eq(Date.new(2026, 3, 2)) + expect(allocation.end_date).to eq(Date.new(2026, 3, 6)) + expect(response.body).to include(I18n.t("resource_management.edit_allocation_dialog.success_message")) + end + + context "with invalid input" do + it "re-renders the form unprocessable and keeps the allocation unchanged" do + perform(allocated_hours: "") + + expect(response).to have_http_status(:unprocessable_entity) + expect(allocation.reload.allocated_time).to eq(600) + end + end + end + + describe "DELETE destroy" do + let!(:allocation) { create(:resource_allocation, entity: work_package, principal: assignee) } + + it "deletes the allocation and confirms it" do + expect do + delete project_resource_allocation_path(project, allocation), as: :turbo_stream + end.to change(ResourceAllocation, :count).by(-1) + + expect(response).to have_http_status(:ok) + expect(response.body).to include(I18n.t("resource_management.work_package_allocations_dialog.delete_success")) + end + end + context "without the allocate_user_resources permission" do shared_let(:viewer) { create(:user, member_with_permissions: { project => %i[view_resource_planners] }) } + shared_let(:allocation) { create(:resource_allocation, entity: work_package, principal: assignee) } before { login_as viewer } @@ -462,6 +539,28 @@ RSpec.describe "ResourceAllocations requests", expect(response).to have_http_status(:forbidden) end + it "denies access to the edit dialog" do + get edit_project_resource_allocation_path(project, allocation), as: :turbo_stream + + expect(response).to have_http_status(:forbidden) + end + + it "denies updating an allocation" do + patch project_resource_allocation_path(project, allocation), + params: { allocation_kind: "principal", resource_allocation: { allocated_hours: "1h" } }, + as: :turbo_stream + + expect(response).to have_http_status(:forbidden) + end + + it "denies deleting an allocation" do + expect do + delete project_resource_allocation_path(project, allocation), as: :turbo_stream + end.not_to change(ResourceAllocation, :count) + + expect(response).to have_http_status(:forbidden) + end + it "denies creating an allocation" do expect do post project_resource_allocations_path(project), 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 index 5f2fe9ff8bd..c19f5d16685 100644 --- a/modules/resource_management/spec/requests/work_package_resource_allocations_spec.rb +++ b/modules/resource_management/spec/requests/work_package_resource_allocations_spec.rb @@ -76,6 +76,32 @@ RSpec.describe "WorkPackage resource allocations requests", type: :rails_request expect(response.body).to include(I18n.t("resource_management.work_package_allocations_dialog.hidden_user")) end + it "offers no row actions to a user who may not manage allocations" do + get path, as: :turbo_stream + + expect(response.body).not_to include("/edit") + end + + context "when the user may manage allocations" do + shared_let(:manager) do + create(:user, + member_with_permissions: { + project => %i[view_resource_planners allocate_user_resources view_work_packages] + }) + end + + before { login_as(manager) } + + it "offers edit and delete row actions" do + allocation = ResourceAllocation.find_by(principal: assignee) + + get path, as: :turbo_stream + + expect(response.body).to include(edit_project_resource_allocation_path(project, allocation)) + expect(response.body).to include(I18n.t(:button_delete)) + end + end + context "when an assigned user is overbooked" do let!(:overbooked_allocation) do create(:resource_allocation, From 188c0cbfd69f4647318919b72c744260b37d5b32 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 10 Jun 2026 14:44:03 +0200 Subject: [PATCH 12/24] Build the work package list content on the resource planner show page --- .../planner_view_content.rb | 51 +++++++++++++++++++ .../resource_planner_views_controller.rb | 22 ++------ .../resource_planners_controller.rb | 2 + .../requests/resource_planner_views_spec.rb | 24 +++++++++ 4 files changed, 80 insertions(+), 19 deletions(-) create mode 100644 modules/resource_management/app/controllers/concerns/resource_management/planner_view_content.rb diff --git a/modules/resource_management/app/controllers/concerns/resource_management/planner_view_content.rb b/modules/resource_management/app/controllers/concerns/resource_management/planner_view_content.rb new file mode 100644 index 00000000000..a5951ba79d0 --- /dev/null +++ b/modules/resource_management/app/controllers/concerns/resource_management/planner_view_content.rb @@ -0,0 +1,51 @@ +# 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 + # Builds the content component for a resource planner view. Loads the view's + # work packages and their allocations in one place so the allocation columns + # (progress bar and members) share a single query rather than each issuing + # their own. Requires @project and @resource_planner to be set. + module PlannerViewContent + def work_package_list_content(view) + work_packages = view.is_a?(ResourceWorkPackageList) ? view.work_packages.to_a : [] + allocations = ResourceAllocation.allocated_for_work_packages(work_packages) + + ResourcePlannerViews::ContentComponent.new( + view:, + project: @project, + resource_planner: @resource_planner, + work_packages:, + allocations:, + visible_principal_ids: ResourceAllocation.visible_principal_ids(allocations.values.flatten, current_user) + ) + end + 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 6375a3df611..baa9889f218 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 @@ -30,6 +30,7 @@ module ::ResourceManagement class ResourcePlannerViewsController < BaseController include OpTurbo::ComponentStream + include PlannerViewContent menu_item :resource_management @@ -47,7 +48,7 @@ module ::ResourceManagement move_work_package reorder_work_package] def show - @content_component = work_package_list_content + @content_component = work_package_list_content(@view) end def new @@ -190,24 +191,7 @@ module ::ResourceManagement end def replace_work_package_list - replace_via_turbo_stream(component: work_package_list_content) - end - - # Loads the view's work packages and their allocations in one place so the - # allocation columns (progress bar and members) share a single query rather - # than each issuing their own. - def work_package_list_content(view = @view) - work_packages = view.is_a?(ResourceWorkPackageList) ? view.work_packages.to_a : [] - allocations = ResourceAllocation.allocated_for_work_packages(work_packages) - - ResourcePlannerViews::ContentComponent.new( - view:, - project: @project, - resource_planner: @resource_planner, - work_packages:, - allocations:, - visible_principal_ids: ResourceAllocation.visible_principal_ids(allocations.values.flatten, current_user) - ) + replace_via_turbo_stream(component: work_package_list_content(@view)) end def render_configure_step(view, status: :ok) diff --git a/modules/resource_management/app/controllers/resource_management/resource_planners_controller.rb b/modules/resource_management/app/controllers/resource_management/resource_planners_controller.rb index 648109dd9fb..816ec37e4fd 100644 --- a/modules/resource_management/app/controllers/resource_management/resource_planners_controller.rb +++ b/modules/resource_management/app/controllers/resource_management/resource_planners_controller.rb @@ -30,6 +30,7 @@ module ::ResourceManagement class ResourcePlannersController < BaseController include OpTurbo::ComponentStream + include PlannerViewContent menu_item :resource_management @@ -47,6 +48,7 @@ module ::ResourceManagement def show @view = default_view + @content_component = work_package_list_content(@view) render "resource_management/resource_planner_views/show" end diff --git a/modules/resource_management/spec/requests/resource_planner_views_spec.rb b/modules/resource_management/spec/requests/resource_planner_views_spec.rb index d202bf3370b..8fe668a33bf 100644 --- a/modules/resource_management/spec/requests/resource_planner_views_spec.rb +++ b/modules/resource_management/spec/requests/resource_planner_views_spec.rb @@ -45,6 +45,30 @@ RSpec.describe "ResourcePlannerViews requests", before { login_as user } + describe "GET show" do + it "renders the view's work package list" do + get project_resource_planner_view_path(project, resource_planner, view) + + expect(response).to have_http_status(:ok) + end + + # Regression: the planner's own show page renders the same template and + # must build the list content as well. + it "renders the planner's show page with its default view" do + view + + get project_resource_planner_path(project, resource_planner) + + expect(response).to have_http_status(:ok) + end + + it "renders the planner's show page when it has no views yet" do + get project_resource_planner_path(project, resource_planner) + + expect(response).to have_http_status(:ok) + end + end + describe "POST create" do subject(:perform) do post project_resource_planner_views_path(project, resource_planner), From e8795b9152f515bd8c8b85d394b90aac5a70e0b0 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 10 Jun 2026 14:47:28 +0200 Subject: [PATCH 13/24] Compact allocation row menu and left-align the allocate footer action --- .../list_dialog_component.html.erb | 27 ++++++++++--------- .../list_item_component.rb | 2 +- 2 files changed, 16 insertions(+), 13 deletions(-) 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 index 9e193a5d6e7..9bea29bd969 100644 --- 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 @@ -36,24 +36,27 @@ See COPYRIGHT and LICENSE files for more details. 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( + # `mr: :auto` pushes the allocate action to the left of the footer's + # flex row, leaving the close button right-aligned. + concat( + render( + Primer::Beta::Button.new( scheme: :link, tag: :a, href: allocate_resource_path, + mr: :auto, data: { controller: "async-dialog" } - ) do |button| - button.with_leading_visual_icon(icon: :"person-add") - t("resource_management.work_package_allocations_dialog.allocate_resource") - end + ) + ) do |button| + button.with_leading_visual_icon(icon: :"person-add") + t("resource_management.work_package_allocations_dialog.allocate_resource") end - footer.with_column do - render(Primer::Beta::Button.new(data: { "close-dialog-id": DIALOG_ID })) do - t(:button_close) - end + ) + concat( + render(Primer::Beta::Button.new(data: { "close-dialog-id": DIALOG_ID })) do + t(:button_close) end - end + ) 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 index ddd51ade720..21c5110e2d3 100644 --- a/modules/resource_management/app/components/resource_allocations/list_item_component.rb +++ b/modules/resource_management/app/components/resource_allocations/list_item_component.rb @@ -68,7 +68,7 @@ module ResourceAllocations end def context_menu - render(Primer::Alpha::ActionMenu.new) do |menu| + render(Primer::Alpha::ActionMenu.new(size: :small, anchor_align: :end)) do |menu| menu.with_show_button(icon: "kebab-horizontal", "aria-label": t("resource_management.work_package_allocations_dialog.context_menu_label"), scheme: :invisible) From 11315f1933e8c0191dcf703122bbedd18a8b0d90 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 10 Jun 2026 14:52:59 +0200 Subject: [PATCH 14/24] Run the overbooking confirmation step on allocation updates --- .../allocation_step/footer_component.rb | 14 +++- .../edit_dialog_component.html.erb | 19 ++---- .../warning_step/footer_component.rb | 10 ++- .../warning_step/form_component.html.erb | 4 +- .../warning_step/form_component.rb | 14 ++++ .../resource_allocations_controller.rb | 53 +++++++++++---- .../requests/resource_allocations_spec.rb | 65 +++++++++++++++++++ 7 files changed, 150 insertions(+), 29 deletions(-) diff --git a/modules/resource_management/app/components/resource_allocations/allocation_step/footer_component.rb b/modules/resource_management/app/components/resource_allocations/allocation_step/footer_component.rb index e964d93f5dd..8e52f7f3ba1 100644 --- a/modules/resource_management/app/components/resource_allocations/allocation_step/footer_component.rb +++ b/modules/resource_management/app/components/resource_allocations/allocation_step/footer_component.rb @@ -34,6 +34,16 @@ module ResourceAllocations include OpTurbo::Streamable include OpPrimer::ComponentHelpers + # `dialog_id` and `submit_label` default to the create wizard's; the edit + # dialog passes its own. + def initialize(dialog_id: ResourceAllocations::NewDialogComponent::DIALOG_ID, + submit_label: I18n.t("resource_management.allocate_resource_dialog.submit")) + super + + @dialog_id = dialog_id + @submit_label = submit_label + end + def wrapper_key ResourceAllocations::NewDialogComponent::FOOTER_ID end @@ -43,7 +53,7 @@ module ResourceAllocations component_collection do |buttons| buttons.with_component( Primer::Beta::Button.new( - data: { "close-dialog-id": ResourceAllocations::NewDialogComponent::DIALOG_ID }, + data: { "close-dialog-id": @dialog_id }, mr: 1 ) ) { I18n.t(:button_cancel) } @@ -54,7 +64,7 @@ module ResourceAllocations form: ResourceAllocations::NewDialogComponent::FORM_ID, type: :submit ) - ) { I18n.t("resource_management.allocate_resource_dialog.submit") } + ) { @submit_label } end end end diff --git a/modules/resource_management/app/components/resource_allocations/edit_dialog_component.html.erb b/modules/resource_management/app/components/resource_allocations/edit_dialog_component.html.erb index c681882f144..7c964ed1efb 100644 --- a/modules/resource_management/app/components/resource_allocations/edit_dialog_component.html.erb +++ b/modules/resource_management/app/components/resource_allocations/edit_dialog_component.html.erb @@ -51,19 +51,12 @@ See COPYRIGHT and LICENSE files for more details. end dialog.with_footer do - component_collection do |buttons| - buttons.with_component( - Primer::Beta::Button.new(data: { "close-dialog-id": DIALOG_ID }, mr: 1) - ) { I18n.t(:button_cancel) } - - buttons.with_component( - Primer::Beta::Button.new( - scheme: :primary, - form: ResourceAllocations::NewDialogComponent::FORM_ID, - type: :submit - ) - ) { I18n.t("resource_management.edit_allocation_dialog.submit") } - end + render( + ResourceAllocations::AllocationStep::FooterComponent.new( + dialog_id: DIALOG_ID, + submit_label: I18n.t("resource_management.edit_allocation_dialog.submit") + ) + ) end end %> diff --git a/modules/resource_management/app/components/resource_allocations/warning_step/footer_component.rb b/modules/resource_management/app/components/resource_allocations/warning_step/footer_component.rb index f0ad6bb7069..b7ae1df705c 100644 --- a/modules/resource_management/app/components/resource_allocations/warning_step/footer_component.rb +++ b/modules/resource_management/app/components/resource_allocations/warning_step/footer_component.rb @@ -34,6 +34,14 @@ module ResourceAllocations include OpTurbo::Streamable include OpPrimer::ComponentHelpers + # `dialog_id` names the dialog the cancel button closes: the create + # wizard's by default, the edit dialog's when confirming an update. + def initialize(dialog_id: ResourceAllocations::NewDialogComponent::DIALOG_ID) + super + + @dialog_id = dialog_id + end + def wrapper_key ResourceAllocations::NewDialogComponent::FOOTER_ID end @@ -58,7 +66,7 @@ module ResourceAllocations footer.with_column(mr: 1) do render( Primer::Beta::Button.new( - data: { "close-dialog-id": ResourceAllocations::NewDialogComponent::DIALOG_ID } + data: { "close-dialog-id": @dialog_id } ) ) { I18n.t(:button_cancel) } end diff --git a/modules/resource_management/app/components/resource_allocations/warning_step/form_component.html.erb b/modules/resource_management/app/components/resource_allocations/warning_step/form_component.html.erb index 2d9dace93f9..1b738ca3308 100644 --- a/modules/resource_management/app/components/resource_allocations/warning_step/form_component.html.erb +++ b/modules/resource_management/app/components/resource_allocations/warning_step/form_component.html.erb @@ -84,8 +84,8 @@ body.with_row(w: :full) do form_with( - url: project_resource_allocations_path(@project), - method: :post, + url: form_url, + method: form_method, id: ResourceAllocations::NewDialogComponent::FORM_ID, data: { turbo_stream: true } ) do diff --git a/modules/resource_management/app/components/resource_allocations/warning_step/form_component.rb b/modules/resource_management/app/components/resource_allocations/warning_step/form_component.rb index c5761e47cf3..15235ba5e9f 100644 --- a/modules/resource_management/app/components/resource_allocations/warning_step/form_component.rb +++ b/modules/resource_management/app/components/resource_allocations/warning_step/form_component.rb @@ -59,6 +59,20 @@ module ResourceAllocations private + # A confirmed resubmit goes back to where the values came from: the + # update of a persisted allocation or the create flow for a new one. + def form_url + if @allocation.persisted? + project_resource_allocation_path(@project, @allocation) + else + project_resource_allocations_path(@project) + end + end + + def form_method + @allocation.persisted? ? :patch : :post + end + def overbooking_heading t("resource_management.allocate_resource_dialog.overbooking.title") end diff --git a/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb b/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb index 63da0a4fc56..fa077e66265 100644 --- a/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb +++ b/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb @@ -82,15 +82,19 @@ module ::ResourceManagement end def update - call = ResourceAllocations::UpdateService - .new(user: current_user, model: @resource_allocation) - .call(allocation_params) + # The confirmation step's "Back" button resubmits the carried form values + # so the editable step can be re-rendered pre-filled. + return render_edit_form(set_update_attributes.result) if params[:back].present? - if call.success? - render_update_success(call.result) - else - render_edit_form(call.result, status: :unprocessable_entity) + validation = set_update_attributes + return render_edit_form(validation.result, status: :unprocessable_entity) if validation.failure? + + if needs_confirmation?(validation.result) + return render_warning_step(validation.result, + dialog_id: ResourceAllocations::EditDialogComponent::DIALOG_ID) end + + persist_update end def destroy @@ -122,7 +126,7 @@ module ::ResourceManagement respond_with_turbo_streams(status:) end - def render_warning_step(allocation) + def render_warning_step(allocation, dialog_id: ResourceAllocations::NewDialogComponent::DIALOG_ID) ranges = overbooked_ranges(allocation) replace_via_turbo_stream( @@ -137,7 +141,7 @@ module ::ResourceManagement ) ) replace_via_turbo_stream( - component: ResourceAllocations::WarningStep::FooterComponent.new + component: ResourceAllocations::WarningStep::FooterComponent.new(dialog_id:) ) respond_with_turbo_streams end @@ -177,11 +181,14 @@ module ::ResourceManagement def compute_overbooked_ranges(allocation) return [] unless overbooking_checkable?(allocation) + # `exclude_id` drops the persisted version of an allocation being edited, + # so its old booking does not count against the new one. availability(allocation).overbooking_with( start_date: allocation.start_date, end_date: allocation.end_date, minutes: allocation.allocated_time, - work_package_id: allocation.entity_id + work_package_id: allocation.entity_id, + exclude_id: allocation.id ) end @@ -219,7 +226,25 @@ module ::ResourceManagement respond_with_turbo_streams end - def render_edit_form(allocation, status:) + def set_update_attributes + ResourceAllocations::SetAttributesService + .new(user: current_user, model: @resource_allocation, contract_class: ResourceAllocations::UpdateContract) + .call(allocation_params) + end + + def persist_update + call = ResourceAllocations::UpdateService + .new(user: current_user, model: @resource_allocation) + .call(allocation_params) + + if call.success? + render_update_success(call.result) + else + render_edit_form(call.result, status: :unprocessable_entity) + end + end + + def render_edit_form(allocation, status: :ok) replace_via_turbo_stream( component: ResourceAllocations::AllocationStep::FormComponent.new( allocation:, @@ -229,6 +254,12 @@ module ::ResourceManagement ), status: ) + replace_via_turbo_stream( + component: ResourceAllocations::AllocationStep::FooterComponent.new( + dialog_id: ResourceAllocations::EditDialogComponent::DIALOG_ID, + submit_label: I18n.t("resource_management.edit_allocation_dialog.submit") + ) + ) respond_with_turbo_streams(status:) end diff --git a/modules/resource_management/spec/requests/resource_allocations_spec.rb b/modules/resource_management/spec/requests/resource_allocations_spec.rb index a36efdc4514..c9428af18c8 100644 --- a/modules/resource_management/spec/requests/resource_allocations_spec.rb +++ b/modules/resource_management/spec/requests/resource_allocations_spec.rb @@ -512,6 +512,71 @@ RSpec.describe "ResourceAllocations requests", expect(allocation.reload.allocated_time).to eq(600) end end + + context "when the update would overbook the assigned user" do + shared_let(:working_assignee) do + create(:user, member_with_permissions: { project => %i[view_work_packages] }).tap do |member| + # Mon-Fri 8h => 480 minutes/day of capacity. + create(:user_working_hours, user: member, valid_from: Date.new(2025, 1, 1)) + end + end + + # Books 10h across Mon-Tue (16h of capacity). + let!(:allocation) do + create(:resource_allocation, + entity: work_package, principal: working_assignee, + start_date: Date.new(2026, 3, 2), end_date: Date.new(2026, 3, 3), allocated_time: 600) + end + + def perform(extra = {}) + patch project_resource_allocation_path(project, allocation), + params: { + allocation_kind: "principal", + resource_allocation: { + principal_id: working_assignee.id, + entity_type: "WorkPackage", + entity_id: work_package.id, + start_date: "2026-03-02", + end_date: "2026-03-03", + allocated_hours: "40h" + } + }.deep_merge(extra), + as: :turbo_stream + end + + it "does not save yet and renders the overbooking confirmation step" do + perform + + expect(response).to have_http_status(:ok) + expect(allocation.reload.allocated_time).to eq(600) + expect(response.body).to include(I18n.t("resource_management.allocate_resource_dialog.overbooking.title")) + expect(response.body).to include('name="confirmed"') + end + + it "applies the update once confirmed" do + perform(confirmed: "1") + + expect(allocation.reload.allocated_time).to eq(40 * 60) + expect(response.body).to include(I18n.t("resource_management.edit_allocation_dialog.success_message")) + end + + it "returns to the pre-filled edit form when going back from the confirmation" do + perform(back: "1") + + expect(response).to have_http_status(:ok) + expect(allocation.reload.allocated_time).to eq(600) + expect(response.body).to include("resource_allocation[allocated_hours]") + end + + it "does not count the allocation's previous booking against its own update" do + # 16h exactly fills Mon-Tue only if the allocation's persisted 10h are + # excluded from the check; no confirmation step expected. + perform(resource_allocation: { allocated_hours: "16h" }) + + expect(allocation.reload.allocated_time).to eq(16 * 60) + expect(response.body).to include(I18n.t("resource_management.edit_allocation_dialog.success_message")) + end + end end describe "DELETE destroy" do From c730866e12d51a69a1427fc948c5bc4a7d09620b Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 10 Jun 2026 17:00:39 +0200 Subject: [PATCH 15/24] Ensure that everything is nicely reloaded when creating/updating/deleting a resource allocation --- .../concerns/op_turbo/component_stream.rb | 9 ++ .../reload-frame-on-event.controller.spec.ts | 97 +++++++++++++++++++ .../reload-frame-on-event.controller.ts | 59 +++++++++++ frontend/src/stimulus/setup.ts | 2 + .../src/turbo/dispatch-event-stream-action.ts | 11 +++ frontend/src/turbo/setup.ts | 2 + .../list_dialog_component.html.erb | 10 +- .../resource_allocations_controller.rb | 18 +++- .../resource_planner_views/show.html.erb | 19 +++- .../requests/resource_allocations_spec.rb | 31 ++++++ .../work_package_resource_allocations_spec.rb | 9 ++ 11 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 frontend/src/stimulus/controllers/reload-frame-on-event.controller.spec.ts create mode 100644 frontend/src/stimulus/controllers/reload-frame-on-event.controller.ts create mode 100644 frontend/src/turbo/dispatch-event-stream-action.ts diff --git a/app/controllers/concerns/op_turbo/component_stream.rb b/app/controllers/concerns/op_turbo/component_stream.rb index a7ff97a9868..aabecb1ce5e 100644 --- a/app/controllers/concerns/op_turbo/component_stream.rb +++ b/app/controllers/concerns/op_turbo/component_stream.rb @@ -158,6 +158,15 @@ module OpTurbo turbo_streams << OpTurbo::StreamComponent.new(action: :reloadPage, target: nil).render_in(view_context) end + # Dispatches a `CustomEvent` on `document` from a turbo stream, letting the + # server signal a client-side change without knowing which listeners (if + # any) react to it. `detail` is serialized and exposed as the event's detail. + def dispatch_event_via_turbo_stream(name, detail: {}) + turbo_streams << OpTurbo::StreamComponent + .new(action: :dispatchEvent, target: nil, "event-name": name, detail: detail.to_json) + .render_in(view_context) + end + def turbo_streams @turbo_streams ||= [] end diff --git a/frontend/src/stimulus/controllers/reload-frame-on-event.controller.spec.ts b/frontend/src/stimulus/controllers/reload-frame-on-event.controller.spec.ts new file mode 100644 index 00000000000..7d0c0973692 --- /dev/null +++ b/frontend/src/stimulus/controllers/reload-frame-on-event.controller.spec.ts @@ -0,0 +1,97 @@ +/* + * -- 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. + * ++ + */ + +import ReloadFrameOnEventController from './reload-frame-on-event.controller'; +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; + +interface ReloadableFrame extends HTMLElement { + src:string; + reload:() => void; +} + +describe('ReloadFrameOnEventController', () => { + let ctx:StimulusTestContext; + + // Each frame gets its own reload counter. The controller listens on + // `document`, so a counter per frame keeps a test from observing reloads + // triggered on another test's (or another frame's) controller. + const mountFrame = async ():Promise<{ frame:ReloadableFrame; calls:{ count:number } }> => { + await ctx.mount(` + + content + + `); + + const frame = ctx.container.querySelector('#frame') as unknown as ReloadableFrame; + const calls = { count: 0 }; + frame.reload = () => { calls.count += 1; }; + return { frame, calls }; + }; + + beforeEach(async () => { + ctx = await setupStimulusTest({ + controllers: { 'reload-frame-on-event': ReloadFrameOnEventController }, + }); + }); + + afterEach(() => ctx.dispose()); + + it('points the frame at its url on the first event, then reloads on later events', async () => { + const { frame, calls } = await mountFrame(); + + document.dispatchEvent(new CustomEvent('resource-allocations:changed')); + expect(frame.src).toContain('/planner/view'); + expect(calls.count).toBe(0); + + document.dispatchEvent(new CustomEvent('resource-allocations:changed')); + expect(calls.count).toBe(1); + }); + + it('ignores unrelated events', async () => { + const { frame, calls } = await mountFrame(); + + document.dispatchEvent(new CustomEvent('some-other-event')); + + expect(frame.src || '').not.toContain('/planner/view'); + expect(calls.count).toBe(0); + }); + + it('stops reacting once disconnected', async () => { + const { frame, calls } = await mountFrame(); + ctx.getController('reload-frame-on-event', frame).disconnect(); + + document.dispatchEvent(new CustomEvent('resource-allocations:changed')); + + expect(calls.count).toBe(0); + }); +}); diff --git a/frontend/src/stimulus/controllers/reload-frame-on-event.controller.ts b/frontend/src/stimulus/controllers/reload-frame-on-event.controller.ts new file mode 100644 index 00000000000..c2d113a657d --- /dev/null +++ b/frontend/src/stimulus/controllers/reload-frame-on-event.controller.ts @@ -0,0 +1,59 @@ +/* + * -- 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. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; +import { FrameElement } from '@hotwired/turbo'; + +// Reloads the turbo-frame this controller is attached to whenever the +// configured document event fires. The frame starts without a `src` (so it is +// not fetched on load); the first event points it at `urlValue`, later events +// reload it. Pair the frame with `refresh="morph"` to reload without flicker. +export default class ReloadFrameOnEventController extends Controller { + static values = { eventName: String, url: String }; + + declare eventNameValue:string; + declare urlValue:string; + + private readonly listener = ():void => { + if (this.element.src) { + void this.element.reload(); + } else { + this.element.src = this.urlValue; + } + }; + + connect():void { + document.addEventListener(this.eventNameValue, this.listener); + } + + disconnect():void { + document.removeEventListener(this.eventNameValue, this.listener); + } +} diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index 303e1ab973d..c49da8911ed 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -19,6 +19,7 @@ import KeepScrollPositionController from './controllers/keep-scroll-position.con import PatternInputController from './controllers/pattern-input.controller'; import HoverCardTriggerController from './controllers/hover-card-trigger.controller'; import ScrollIntoViewController from './controllers/scroll-into-view.controller'; +import ReloadFrameOnEventController from './controllers/reload-frame-on-event.controller'; import CkeditorFocusController from './controllers/ckeditor-focus.controller'; import IndexController from './controllers/dynamic/work-packages/activities-tab/index.controller'; import AutoScrollingController from './controllers/dynamic/work-packages/activities-tab/auto-scrolling.controller'; @@ -77,6 +78,7 @@ OpenProjectStimulusApplication.preregister('work-packages--date-picker--preview' OpenProjectStimulusApplication.preregister('keep-scroll-position', KeepScrollPositionController); OpenProjectStimulusApplication.preregister('pattern-input', PatternInputController); OpenProjectStimulusApplication.preregister('scroll-into-view', ScrollIntoViewController); +OpenProjectStimulusApplication.preregister('reload-frame-on-event', ReloadFrameOnEventController); OpenProjectStimulusApplication.preregister('ckeditor-focus', CkeditorFocusController); OpenProjectStimulusApplication.preregister('auto-submit', AutoSubmit); OpenProjectStimulusApplication.preregister('reveal', RevealController); diff --git a/frontend/src/turbo/dispatch-event-stream-action.ts b/frontend/src/turbo/dispatch-event-stream-action.ts new file mode 100644 index 00000000000..3d90b110d58 --- /dev/null +++ b/frontend/src/turbo/dispatch-event-stream-action.ts @@ -0,0 +1,11 @@ +import { StreamActions, StreamElement } from '@hotwired/turbo'; + +export function registerDispatchEventStreamAction() { + StreamActions.dispatchEvent = function dispatchEventStreamAction(this:StreamElement) { + const name = this.getAttribute('event-name'); + if (!name) { return; } + + const detail = JSON.parse(this.getAttribute('detail') ?? '{}') as unknown; + document.dispatchEvent(new CustomEvent(name, { detail })); + }; +} diff --git a/frontend/src/turbo/setup.ts b/frontend/src/turbo/setup.ts index c86857fda03..84e0363f876 100644 --- a/frontend/src/turbo/setup.ts +++ b/frontend/src/turbo/setup.ts @@ -5,6 +5,7 @@ import { addTurboEventListeners } from './turbo-event-listeners'; import { registerFlashStreamAction } from './flash-stream-action'; import { registerLiveRegionStreamAction } from './live-region-stream-action'; import { registerInputCaptionStreamAction } from './input-caption-stream-action'; +import { registerDispatchEventStreamAction } from './dispatch-event-stream-action'; import { addTurboGlobalListeners } from './turbo-global-listeners'; import { applyTurboNavigationPatch } from './turbo-navigation-patch'; import { debugLog, whenDebugging } from 'core-app/shared/helpers/debug_output'; @@ -43,6 +44,7 @@ registerDialogStreamAction(); registerFlashStreamAction(); registerLiveRegionStreamAction(); registerInputCaptionStreamAction(); +registerDispatchEventStreamAction(); addTurboAngularWrapper(); StreamActions.reloadPage = function reloadPage() { 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 index 9bea29bd969..1b53f45e8a4 100644 --- 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 @@ -28,7 +28,15 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= - render(Primer::Alpha::Dialog.new(id: DIALOG_ID, title:, size: :large, classes: "Overlay--size-large-portrait")) do |dialog| + render( + Primer::Alpha::Dialog.new( + id: DIALOG_ID, + title:, + size: :large, + classes: "Overlay--size-large-portrait", + data: { "keep-open-on-submit": true } + ) + ) do |dialog| dialog.with_header(variant: :large) dialog.with_body do diff --git a/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb b/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb index fa077e66265..7723ebbc517 100644 --- a/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb +++ b/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb @@ -152,7 +152,7 @@ module ::ResourceManagement .call(allocation_params) if call.success? - render_create_success + render_create_success(call.result) else render_allocation_step(call.result, status: :unprocessable_entity) end @@ -218,11 +218,13 @@ module ::ResourceManagement .call(attributes) end - def render_create_success + def render_create_success(allocation) render_success_flash_message_via_turbo_stream( message: I18n.t("resource_management.allocate_resource_dialog.success_message") ) close_dialog_via_turbo_stream("##{ResourceAllocations::NewDialogComponent::DIALOG_ID}") + refresh_allocations_list(allocation.entity) + notify_allocation_change(allocation.entity) respond_with_turbo_streams end @@ -269,6 +271,7 @@ module ::ResourceManagement ) close_dialog_via_turbo_stream("##{ResourceAllocations::EditDialogComponent::DIALOG_ID}") refresh_allocations_list(allocation.entity) + notify_allocation_change(allocation.entity) respond_with_turbo_streams end @@ -277,6 +280,7 @@ module ::ResourceManagement message: I18n.t("resource_management.work_package_allocations_dialog.delete_success") ) refresh_allocations_list(entity) + notify_allocation_change(entity) respond_with_turbo_streams end @@ -297,6 +301,16 @@ module ::ResourceManagement ) end + # Announces that an allocation of the work package changed. A resource + # planner table open on the page reloads the affected work package in + # response; the controller stays unaware of which view (if any) is on + # screen. The stream is a harmless no-op when nothing listens. + def notify_allocation_change(entity) + return unless entity.is_a?(WorkPackage) + + dispatch_event_via_turbo_stream("resource-allocations:changed", detail: { work_package_id: entity.id }) + end + def allocation_kind params[:allocation_kind].presence || "principal" end diff --git a/modules/resource_management/app/views/resource_management/resource_planner_views/show.html.erb b/modules/resource_management/app/views/resource_management/resource_planner_views/show.html.erb index 65f51c3eb4c..a9a92fead1b 100644 --- a/modules/resource_management/app/views/resource_management/resource_planner_views/show.html.erb +++ b/modules/resource_management/app/views/resource_management/resource_planner_views/show.html.erb @@ -41,4 +41,21 @@ See COPYRIGHT and LICENSE files for more details. end %> <%= render(ResourcePlanners::SubViewsComponent.new(resource_planner: @resource_planner, selected_view: @view)) %> -<%= render(@content_component) %> +<%# + Reloadable frame so allocation changes made in the dialogs refresh the table + in place. The frame has no `src`, so it is not fetched on load — it just holds + the server-rendered content. The reload-frame-on-event controller points its + `src` at this same view and morphs it in when an allocation of any listed work + package changes. +%> +<%= turbo_frame_tag( + "resource-planner-view-content", + refresh: :morph, + data: { + controller: "reload-frame-on-event", + "reload-frame-on-event-event-name-value": "resource-allocations:changed", + "reload-frame-on-event-url-value": project_resource_planner_view_path(@project, @resource_planner, @view) + } + ) do %> + <%= render(@content_component) %> +<% end %> diff --git a/modules/resource_management/spec/requests/resource_allocations_spec.rb b/modules/resource_management/spec/requests/resource_allocations_spec.rb index c9428af18c8..1a8b1ac9717 100644 --- a/modules/resource_management/spec/requests/resource_allocations_spec.rb +++ b/modules/resource_management/spec/requests/resource_allocations_spec.rb @@ -161,6 +161,14 @@ RSpec.describe "ResourceAllocations requests", expect(allocation.user_filter).to eq([]) expect(allocation.requested_by).to eq(user) end + + it "refreshes the open allocations list and announces the change for the planner table" do + perform + + expect(response).to have_http_status(:ok) + expect(response.body).to include('target="resource-allocations-list-component"') + expect_allocation_change_announced_for(work_package) + end end context "for a filter-criteria placeholder" do @@ -504,6 +512,12 @@ RSpec.describe "ResourceAllocations requests", expect(response.body).to include(I18n.t("resource_management.edit_allocation_dialog.success_message")) end + it "announces the change so the planner table can refresh" do + perform + + expect_allocation_change_announced_for(work_package) + end + context "with invalid input" do it "re-renders the form unprocessable and keeps the allocation unchanged" do perform(allocated_hours: "") @@ -590,6 +604,13 @@ RSpec.describe "ResourceAllocations requests", expect(response).to have_http_status(:ok) expect(response.body).to include(I18n.t("resource_management.work_package_allocations_dialog.delete_success")) end + + it "refreshes the open allocations list and announces the change for the planner table" do + delete project_resource_allocation_path(project, allocation), as: :turbo_stream + + expect(response.body).to include('target="resource-allocations-list-component"') + expect_allocation_change_announced_for(work_package) + end end context "without the allocate_user_resources permission" do @@ -646,4 +667,14 @@ RSpec.describe "ResourceAllocations requests", expect(response).to have_http_status(:forbidden) end end + + # The controller emits a `dispatchEvent` turbo stream carrying the changed + # work package so an open resource planner table can reload it. + def expect_allocation_change_announced_for(work_package) + event = Nokogiri::HTML5.fragment(response.body).at_css('turbo-stream[action="dispatchEvent"]') + + expect(event).to be_present + expect(event["event-name"]).to eq("resource-allocations:changed") + expect(JSON.parse(event["detail"])).to eq("work_package_id" => work_package.id) + 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 index c19f5d16685..7292a60307c 100644 --- a/modules/resource_management/spec/requests/work_package_resource_allocations_spec.rb +++ b/modules/resource_management/spec/requests/work_package_resource_allocations_spec.rb @@ -62,6 +62,15 @@ RSpec.describe "WorkPackage resource allocations requests", type: :rails_request expect(response.body).to include(I18n.t("resource_management.work_package_allocations_dialog.title")) end + # An inline delete submits a form inside the dialog; without this flag the + # global submit-end handler would close the dialog on success. + it "keeps the dialog open across in-dialog form submissions" do + get path, as: :turbo_stream + + dialog = Nokogiri::HTML5.fragment(response.body).at_css("dialog") + expect(dialog["data-keep-open-on-submit"]).to eq("true") + end + it "names the visible member and the filter allocation" do get path, as: :turbo_stream From efef9597f43632950d3f067b2376cad2394e0df2 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Thu, 11 Jun 2026 09:38:14 +0200 Subject: [PATCH 16/24] Ensure we have no div/0 errors for the progress bar --- .../components/resource_allocations/progress_component.rb | 2 ++ .../resource_allocations/progress_component_spec.rb | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/modules/resource_management/app/components/resource_allocations/progress_component.rb b/modules/resource_management/app/components/resource_allocations/progress_component.rb index fd1afa43a8d..ed633a98c6c 100644 --- a/modules/resource_management/app/components/resource_allocations/progress_component.rb +++ b/modules/resource_management/app/components/resource_allocations/progress_component.rb @@ -66,6 +66,8 @@ module ResourceAllocations # Share of the scheduled work covered by allocations. Capped at 100 for the # bar width; the raw value still drives the label and over-allocation color. def ratio + return 0 unless work_scheduled? + ((allocated_hours / scheduled_hours) * 100).round end diff --git a/modules/resource_management/spec/components/resource_allocations/progress_component_spec.rb b/modules/resource_management/spec/components/resource_allocations/progress_component_spec.rb index b898ce8e321..0ba305f4fc6 100644 --- a/modules/resource_management/spec/components/resource_allocations/progress_component_spec.rb +++ b/modules/resource_management/spec/components/resource_allocations/progress_component_spec.rb @@ -111,5 +111,12 @@ RSpec.describe ResourceAllocations::ProgressComponent, type: :component do expect(rendered).to have_css(".octicon-alert-fill") expect(rendered).to have_text(I18n.t("resource_management.allocation.no_work")) end + + it "returns a zero ratio instead of dividing by zero" do + component = described_class.new(work_package:, allocations:) + + expect { component.send(:ratio) }.not_to raise_error + expect(component.send(:ratio)).to eq(0) + end end end From 6b276f2f336559256a53cd02f011a7b61df9793e Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Thu, 11 Jun 2026 09:38:50 +0200 Subject: [PATCH 17/24] Only assign turbo frame reload logic, when we actually have a view --- .../resource_planner_views/show.html.erb | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/modules/resource_management/app/views/resource_management/resource_planner_views/show.html.erb b/modules/resource_management/app/views/resource_management/resource_planner_views/show.html.erb index a9a92fead1b..56890cca37b 100644 --- a/modules/resource_management/app/views/resource_management/resource_planner_views/show.html.erb +++ b/modules/resource_management/app/views/resource_management/resource_planner_views/show.html.erb @@ -48,14 +48,18 @@ See COPYRIGHT and LICENSE files for more details. `src` at this same view and morphs it in when an allocation of any listed work package changes. %> -<%= turbo_frame_tag( - "resource-planner-view-content", - refresh: :morph, - data: { - controller: "reload-frame-on-event", - "reload-frame-on-event-event-name-value": "resource-allocations:changed", - "reload-frame-on-event-url-value": project_resource_planner_view_path(@project, @resource_planner, @view) - } - ) do %> +<% + # When the planner does not have a view yet, we cannot add frame data + frame_data = if @view + { + controller: "reload-frame-on-event", + "reload-frame-on-event-event-name-value": "resource-allocations:changed", + "reload-frame-on-event-url-value": project_resource_planner_view_path(@project, @resource_planner, @view) + } + else + {} + end +%> +<%= turbo_frame_tag("resource-planner-view-content", refresh: :morph, data: frame_data) do %> <%= render(@content_component) %> <% end %> From 9065a2d85bebcf7d0d6f8c14ae6e82f48e31288e Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Thu, 11 Jun 2026 10:02:06 +0200 Subject: [PATCH 18/24] put a max cap on allocated_time so we do not run into an integer range error --- .../list_item_component.rb | 5 +++-- .../app/models/resource_allocation.rb | 18 ++++++++++++++++++ .../spec/models/resource_allocation_spec.rb | 11 +++++++++++ .../spec/requests/resource_allocations_spec.rb | 12 ++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) 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 index 21c5110e2d3..d92288cf1fd 100644 --- a/modules/resource_management/app/components/resource_allocations/list_item_component.rb +++ b/modules/resource_management/app/components/resource_allocations/list_item_component.rb @@ -115,12 +115,13 @@ module ResourceAllocations end # An allocation without a user yet (filter placeholder or lost principal) - # shows a person-add icon instead of an avatar. + # shows a person-add icon instead of an avatar. Sized to match the avatar so + # the leading column keeps the same width and the row stays aligned. def leading_visual if allocation.principal Primer::OpenProject::AvatarWithFallback.new(size: AVATAR_SIZE, **avatar_options) else - Primer::Beta::Octicon.new(icon: :"person-add", color: :muted, "aria-hidden": true) + Primer::Beta::Octicon.new(icon: :"person-add", size: :medium, color: :muted, "aria-hidden": true) end end diff --git a/modules/resource_management/app/models/resource_allocation.rb b/modules/resource_management/app/models/resource_allocation.rb index da15a7813cd..d7f07b163f6 100644 --- a/modules/resource_management/app/models/resource_allocation.rb +++ b/modules/resource_management/app/models/resource_allocation.rb @@ -31,6 +31,11 @@ class ResourceAllocation < ApplicationRecord ALLOWED_ENTITY_TYPES = %w[WorkPackage].freeze + # `allocated_time` is stored in minutes in an integer column. Cap it at 5000 + # hours so an absurdly large input is rejected with a validation error rather + # than overflowing the column and raising ActiveModel::RangeError on save. + MAX_ALLOCATED_TIME = (5000.hours / 1.minute).to_i + belongs_to :entity, polymorphic: true, optional: false belongs_to :principal, class_name: "User", optional: true, inverse_of: :resource_allocations belongs_to :requested_by, class_name: "User", optional: true @@ -119,6 +124,8 @@ class ResourceAllocation < ApplicationRecord presence: true, numericality: { only_integer: true, greater_than: 0 } + validate :allocated_time_within_limit + validates :entity_type, inclusion: { in: ALLOWED_ENTITY_TYPES }, allow_blank: true @@ -207,6 +214,17 @@ class ResourceAllocation < ApplicationRecord private + # Capped above to keep `allocated_time` within the integer column. The field is + # entered in hours, so the message reports the limit in the same duration + # format rather than the raw minutes the column stores. + def allocated_time_within_limit + return if allocated_time.blank? + return if allocated_time <= MAX_ALLOCATED_TIME + + errors.add(:allocated_time, :less_than_or_equal_to, + count: DurationConverter.output(MAX_ALLOCATED_TIME / 60.0)) + end + def starts_before_entity? entity_start_date.present? && start_date.present? && start_date < entity_start_date end diff --git a/modules/resource_management/spec/models/resource_allocation_spec.rb b/modules/resource_management/spec/models/resource_allocation_spec.rb index fd54dea341f..e5d47a6e826 100644 --- a/modules/resource_management/spec/models/resource_allocation_spec.rb +++ b/modules/resource_management/spec/models/resource_allocation_spec.rb @@ -401,6 +401,17 @@ RSpec.describe ResourceAllocation do allocation.allocated_time = 1 expect(allocation).to be_valid end + + it "is valid at the upper cap" do + allocation.allocated_time = described_class::MAX_ALLOCATED_TIME + expect(allocation).to be_valid + end + + it "is invalid (rather than overflowing the column) above the cap" do + allocation.allocated_time = described_class::MAX_ALLOCATED_TIME + 1 + expect(allocation).not_to be_valid + expect(allocation.errors.symbols_for(:allocated_time)).to include(:less_than_or_equal_to) + end end describe "date range" do diff --git a/modules/resource_management/spec/requests/resource_allocations_spec.rb b/modules/resource_management/spec/requests/resource_allocations_spec.rb index 1a8b1ac9717..41f4b15cfba 100644 --- a/modules/resource_management/spec/requests/resource_allocations_spec.rb +++ b/modules/resource_management/spec/requests/resource_allocations_spec.rb @@ -525,6 +525,18 @@ RSpec.describe "ResourceAllocations requests", expect(response).to have_http_status(:unprocessable_entity) expect(allocation.reload.allocated_time).to eq(600) end + + # Regression: an absurdly large value used to overflow the integer column + # and raise ActiveModel::RangeError (500) instead of failing validation. + it "rejects a value above the maximum with an hours-formatted message" do + perform(allocated_hours: "999999999999h") + + expect(response).to have_http_status(:unprocessable_entity) + expect(allocation.reload.allocated_time).to eq(600) + expect(response.body).to include( + DurationConverter.output(ResourceAllocation::MAX_ALLOCATED_TIME / 60.0) + ) + end end context "when the update would overbook the assigned user" do From 415c6f797d75767c19342812a33b161ad84570a1 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Thu, 11 Jun 2026 10:50:37 +0200 Subject: [PATCH 19/24] Border Box Table scales the actions column to be auto-sized so it only takes as much space as needed --- .../op_primer/border_box_table_component.html.erb | 1 + .../op_primer/border_box_table_component.rb | 11 +++++++++++ .../op_primer/border_box_table_component.sass | 5 ++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/components/op_primer/border_box_table_component.html.erb b/app/components/op_primer/border_box_table_component.html.erb index 182aff67bd7..8796e367eaa 100644 --- a/app/components/op_primer/border_box_table_component.html.erb +++ b/app/components/op_primer/border_box_table_component.html.erb @@ -32,6 +32,7 @@ See COPYRIGHT and LICENSE files for more details. OpPrimer::BorderBoxTableComponent::InternalBoxComponent.new( id: container_id, classes: container_class, + style: "--op-border-box-grid-template-columns: #{grid_template_columns};", role: :table, aria: { label: mobile_title, colcount: column_count }, list_arguments: { tag: :div, role: :rowgroup }, diff --git a/app/components/op_primer/border_box_table_component.rb b/app/components/op_primer/border_box_table_component.rb index 98802dbbe81..a6b64cc71aa 100644 --- a/app/components/op_primer/border_box_table_component.rb +++ b/app/components/op_primer/border_box_table_component.rb @@ -104,6 +104,17 @@ module OpPrimer "op-border-box-grid" end + # Desktop column template. Data columns share the available width evenly (a + # `main_column` taking a double share), while the optional actions column is + # sized to its content so that a lone icon button does not claim a full + # column's worth of width. Exposed to the grid as a CSS custom property. + def grid_template_columns + data_tracks = columns.sum { |column| main_column?(column) ? 2 : 1 } + template = "repeat(#{data_tracks}, minmax(0, 1fr))" + template += " auto" if has_actions? + template + end + def has_actions? false end diff --git a/app/components/op_primer/border_box_table_component.sass b/app/components/op_primer/border_box_table_component.sass index fec31918606..6224c754897 100644 --- a/app/components/op_primer/border_box_table_component.sass +++ b/app/components/op_primer/border_box_table_component.sass @@ -20,7 +20,10 @@ @media screen and (min-width: $breakpoint-md) .op-border-box-grid - // Distribute columns evenly on desktop + // Distribute data columns evenly while sizing the optional actions column to + // its content (see BorderBoxTableComponent#grid_template_columns). The + // implicit grid below stays as a fallback if the property is ever unset. + grid-template-columns: var(--op-border-box-grid-template-columns) grid-auto-columns: minmax(0, 1fr) grid-auto-flow: column justify-content: flex-start From 85ebce3f76b71ec8509ca9c8be721e4ccc3fa21d Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Thu, 11 Jun 2026 10:58:46 +0200 Subject: [PATCH 20/24] Proper styling of the allocated members table --- .../allocated_members_component.html.erb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.html.erb b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.html.erb index 230047048d5..f3a08bf7c9d 100644 --- a/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.html.erb +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.html.erb @@ -29,22 +29,22 @@ See COPYRIGHT and LICENSE files for more details. <% if named? %> <%= flex_layout(align_items: :center) do |container| %> - <%= container.with_column do %> + <%= container.with_column(flex_shrink: 0) do %> <%= render(Primer::OpenProject::AvatarStack.new(tooltipped: true, body_arguments: { label: tooltip_label })) do |stack| %> <% avatar_options.each do |options| %> <% stack.with_avatar_with_fallback(**options) %> <% end %> <% end %> <% end %> - <%= container.with_column(ml: 2) do %> - <%= render(Primer::Beta::Text.new(font_size: :small)) { lead_name } %> + <%= container.with_column(ml: 2, classes: "min-width-0") do %> + <%= render(Primer::Beta::Truncate.new) { lead_name } %> <% end %> <% if additional? %> - <%= container.with_column(ml: 1) do %> - <%= render(Primer::Beta::Text.new(font_size: :small, color: :muted)) { additional_label } %> + <%= container.with_column(ml: 1, flex_shrink: 0) do %> + <%= render(Primer::Beta::Label.new(scheme: :secondary, inline: true)) { additional_label } %> <% end %> <% end %> <% end %> <% else %> - <%= render(Primer::Beta::Text.new(font_size: :small, color: :muted)) { anonymous_label } %> + <%= render(Primer::Beta::Text.new(color: :muted)) { anonymous_label } %> <% end %> From 6bb3e63b321119dec914aae8586f3ac1cc0c9026 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Thu, 11 Jun 2026 10:59:11 +0200 Subject: [PATCH 21/24] Revert "Border Box Table scales the actions column to be auto-sized so it only takes as much space as needed" This reverts commit 415c6f797d75767c19342812a33b161ad84570a1. --- .../op_primer/border_box_table_component.html.erb | 1 - .../op_primer/border_box_table_component.rb | 11 ----------- .../op_primer/border_box_table_component.sass | 5 +---- 3 files changed, 1 insertion(+), 16 deletions(-) diff --git a/app/components/op_primer/border_box_table_component.html.erb b/app/components/op_primer/border_box_table_component.html.erb index 8796e367eaa..182aff67bd7 100644 --- a/app/components/op_primer/border_box_table_component.html.erb +++ b/app/components/op_primer/border_box_table_component.html.erb @@ -32,7 +32,6 @@ See COPYRIGHT and LICENSE files for more details. OpPrimer::BorderBoxTableComponent::InternalBoxComponent.new( id: container_id, classes: container_class, - style: "--op-border-box-grid-template-columns: #{grid_template_columns};", role: :table, aria: { label: mobile_title, colcount: column_count }, list_arguments: { tag: :div, role: :rowgroup }, diff --git a/app/components/op_primer/border_box_table_component.rb b/app/components/op_primer/border_box_table_component.rb index a6b64cc71aa..98802dbbe81 100644 --- a/app/components/op_primer/border_box_table_component.rb +++ b/app/components/op_primer/border_box_table_component.rb @@ -104,17 +104,6 @@ module OpPrimer "op-border-box-grid" end - # Desktop column template. Data columns share the available width evenly (a - # `main_column` taking a double share), while the optional actions column is - # sized to its content so that a lone icon button does not claim a full - # column's worth of width. Exposed to the grid as a CSS custom property. - def grid_template_columns - data_tracks = columns.sum { |column| main_column?(column) ? 2 : 1 } - template = "repeat(#{data_tracks}, minmax(0, 1fr))" - template += " auto" if has_actions? - template - end - def has_actions? false end diff --git a/app/components/op_primer/border_box_table_component.sass b/app/components/op_primer/border_box_table_component.sass index 6224c754897..fec31918606 100644 --- a/app/components/op_primer/border_box_table_component.sass +++ b/app/components/op_primer/border_box_table_component.sass @@ -20,10 +20,7 @@ @media screen and (min-width: $breakpoint-md) .op-border-box-grid - // Distribute data columns evenly while sizing the optional actions column to - // its content (see BorderBoxTableComponent#grid_template_columns). The - // implicit grid below stays as a fallback if the property is ever unset. - grid-template-columns: var(--op-border-box-grid-template-columns) + // Distribute columns evenly on desktop grid-auto-columns: minmax(0, 1fr) grid-auto-flow: column justify-content: flex-start From dd987c51c143b500af716999a919fb0cf04300f9 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Thu, 11 Jun 2026 11:30:20 +0200 Subject: [PATCH 22/24] Proper layout for the table. Middle align, fixed widths --- frontend/src/global_styles/openproject.sass | 1 + .../app/components/_index.sass | 1 + .../work_package_list/table_component.rb | 4 +++ .../work_package_list/table_component.sass | 27 +++++++++++++++++++ 4 files changed, 33 insertions(+) create mode 100644 modules/resource_management/app/components/_index.sass create mode 100644 modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.sass diff --git a/frontend/src/global_styles/openproject.sass b/frontend/src/global_styles/openproject.sass index 2e2eeea80ab..e187dfd68aa 100644 --- a/frontend/src/global_styles/openproject.sass +++ b/frontend/src/global_styles/openproject.sass @@ -27,6 +27,7 @@ @import "../../../modules/grids/app/components/_index.sass" @import "../../../modules/meeting/app/components/_index.sass" @import "../../../modules/overviews/app/components/_index.sass" +@import "../../../modules/resource_management/app/components/_index.sass" @import "../../../modules/storages/app/components/_index.sass" @import "../../../modules/wikis/app/components/_index.sass" diff --git a/modules/resource_management/app/components/_index.sass b/modules/resource_management/app/components/_index.sass new file mode 100644 index 00000000000..6d1806c4ff1 --- /dev/null +++ b/modules/resource_management/app/components/_index.sass @@ -0,0 +1 @@ +@import "resource_planner_views/work_package_list/table_component" diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.rb b/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.rb index deb89e6b636..3b96ff7db13 100644 --- a/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.rb +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.rb @@ -62,6 +62,10 @@ module ResourcePlannerViews::WorkPackageList def has_actions? = true + # Scopes this table's styling (see table_component.sass) without touching the + # shared border-box grid defaults used by every other table. + def container_class = "op-resource-work-package-list" + def mobile_title I18n.t("resource_management.work_package_list.mobile_title") end diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.sass b/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.sass new file mode 100644 index 00000000000..86f430504b4 --- /dev/null +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.sass @@ -0,0 +1,27 @@ +@import "helpers" + +// The shared border-box grid top-aligns row content and distributes columns +// evenly. The resource planner work package list has a fixed, known set of +// columns, so we center the content and hardcode the column widths from the +// design. Scoped via the table's container_class so no other table is affected. +@media screen and (min-width: $breakpoint-md) + .op-resource-work-package-list + .op-border-box-grid + align-items: center + // Column widths as fractions of the 1200px design (376/80/200/200/200), + // so they scale with the available width. Only the action button is fixed. + // The header and body are independent grids; sharing one template keeps + // their tracks aligned. minmax(0, …) lets a track shrink below its content. + grid-template-columns: minmax(0, 376fr) minmax(0, 80fr) minmax(0, 200fr) minmax(0, 200fr) minmax(0, 200fr) 40px + + // Subject keeps its main-column semantics (row header role, no truncation) + // but must occupy a single track in the explicit template above rather than + // spanning two as the shared grid would have it. + .op-border-box-grid__header--main-column, + .op-border-box-grid__row-item--main-column + grid-column: auto + + // Content is centered here, so the top-alignment nudge the shared component + // applies to the action button is not wanted. + .op-border-box-grid__row-action + margin-top: 0 From b2a2c09717b8dc1bd3f7f298de6fbc9aaab40696 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Thu, 11 Jun 2026 11:34:05 +0200 Subject: [PATCH 23/24] Remove divider on the footer --- .../resource_allocations/list_dialog_component.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 1b53f45e8a4..28d463c6e78 100644 --- 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 @@ -43,7 +43,7 @@ See COPYRIGHT and LICENSE files for more details. render(ResourceAllocations::ListComponent.new(project:, work_package:, allocations:, visible_principal_ids:, overbooked_ids:)) end - dialog.with_footer(show_divider: true) do + dialog.with_footer do # `mr: :auto` pushes the allocate action to the left of the footer's # flex row, leaving the close button right-aligned. concat( From 7649d2c9a5479405c61db251b0288af44e24ca2d Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Thu, 11 Jun 2026 12:01:48 +0200 Subject: [PATCH 24/24] Fixes Rubocop --- .../app/components/resource_allocations/edit_dialog_component.rb | 1 - .../app/components/resource_allocations/list_component.rb | 1 - 2 files changed, 2 deletions(-) diff --git a/modules/resource_management/app/components/resource_allocations/edit_dialog_component.rb b/modules/resource_management/app/components/resource_allocations/edit_dialog_component.rb index 83bade15b00..79cc73a06d9 100644 --- a/modules/resource_management/app/components/resource_allocations/edit_dialog_component.rb +++ b/modules/resource_management/app/components/resource_allocations/edit_dialog_component.rb @@ -28,7 +28,6 @@ # See COPYRIGHT and LICENSE files for more details. #++ - module ResourceAllocations # A single-step dialog to edit a persisted allocation, reusing the create # wizard's allocation form (which submits an update for persisted records). diff --git a/modules/resource_management/app/components/resource_allocations/list_component.rb b/modules/resource_management/app/components/resource_allocations/list_component.rb index 3fb62e7ba05..64e486600a1 100644 --- a/modules/resource_management/app/components/resource_allocations/list_component.rb +++ b/modules/resource_management/app/components/resource_allocations/list_component.rb @@ -28,7 +28,6 @@ # See COPYRIGHT and LICENSE files for more details. #++ - module ResourceAllocations # The body of the work package allocations dialog: the allocation progress # summary and one row per allocation. Streamable so the dialog content can be