From 63cf1dea7eae392d0893a028e527dd7453b385bf Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 2 Jun 2026 11:13:02 +0200 Subject: [PATCH 01/27] Implement stub controller for Resource Allocations --- .../resource_allocations_controller.rb | 53 +++++++++++++++++++ modules/resource_management/config/routes.rb | 4 ++ .../resource_management/engine.rb | 8 +-- 3 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb 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 new file mode 100644 index 00000000000..91be1ffe29c --- /dev/null +++ b/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb @@ -0,0 +1,53 @@ +# 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 + class ResourceAllocationsController < BaseController + include OpTurbo::ComponentStream + + menu_item :resource_management + + before_action :find_project_by_project_id + before_action :authorize + + # The modals and the ResourceAllocations::* contracts/services are wired up + # in follow-up work. For now these are stubs so the routes and the + # `allocate_user_resources` permission have something to bind to. + + def new; end + + def edit; end + + def create; end + + def update; end + + def destroy; end + end +end diff --git a/modules/resource_management/config/routes.rb b/modules/resource_management/config/routes.rb index 808c848deb5..a96e319be63 100644 --- a/modules/resource_management/config/routes.rb +++ b/modules/resource_management/config/routes.rb @@ -62,5 +62,9 @@ Rails.application.routes.draw do get "menu" => "resource_management/menus#show" end end + + resources :resource_allocations, + controller: "resource_management/resource_allocations", + only: %i[new create edit update destroy] 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 6e8f5320735..d3021104fa6 100644 --- a/modules/resource_management/lib/open_project/resource_management/engine.rb +++ b/modules/resource_management/lib/open_project/resource_management/engine.rb @@ -72,12 +72,12 @@ module OpenProject::ResourceManagement dependencies: %i[view_resource_planners] # `allocate_user_resources` gates create/update/delete on - # ResourceAllocation records. No controller actions yet — the - # ResourceAllocations::*Contract classes consume this directly via - # `allowed_in_project?`. The `contract_actions` map keeps the + # ResourceAllocation records, both via the controller actions below and + # directly in the ResourceAllocations::*Contract classes (which consume + # it through `allowed_in_project?`). The `contract_actions` map keeps the # permission discoverable for API contracts. permission :allocate_user_resources, - {}, + { "resource_management/resource_allocations": %i[new create edit update destroy] }, permissible_on: :project, dependencies: %i[view_resource_planners], contract_actions: { resource_allocation: %i[create update destroy] } From 45afae52d63f7f0427f09123dc820d49af761762 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 2 Jun 2026 11:22:27 +0200 Subject: [PATCH 02/27] Ensure ResourceAllocation is scoped to a work package for now, allow further classes later --- .../app/models/resource_allocation.rb | 6 +++++ .../create_contract_spec.rb | 4 +-- .../delete_contract_spec.rb | 4 +-- .../shared_contract_examples.rb | 4 +-- .../update_contract_spec.rb | 4 +-- .../factories/resource_allocation_factory.rb | 2 +- .../spec/models/resource_allocation_spec.rb | 27 +++++++++++++++---- .../create_service_spec.rb | 6 ++--- .../delete_service_spec.rb | 4 +-- .../update_service_spec.rb | 10 +++---- 10 files changed, 47 insertions(+), 24 deletions(-) diff --git a/modules/resource_management/app/models/resource_allocation.rb b/modules/resource_management/app/models/resource_allocation.rb index f5174116280..12ff081a309 100644 --- a/modules/resource_management/app/models/resource_allocation.rb +++ b/modules/resource_management/app/models/resource_allocation.rb @@ -29,6 +29,8 @@ #++ class ResourceAllocation < ApplicationRecord + ALLOWED_ENTITY_TYPES = %w[WorkPackage].freeze + belongs_to :entity, polymorphic: true, optional: false belongs_to :principal, class_name: "User", optional: true, inverse_of: :resource_allocations @@ -46,6 +48,10 @@ class ResourceAllocation < ApplicationRecord presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :entity_type, + inclusion: { in: ALLOWED_ENTITY_TYPES }, + allow_blank: true + validate :end_date_after_start_date # Resource allocations are scoped to whatever project their (polymorphic) diff --git a/modules/resource_management/spec/contracts/resource_allocations/create_contract_spec.rb b/modules/resource_management/spec/contracts/resource_allocations/create_contract_spec.rb index 72427bd9319..435a8cdb288 100644 --- a/modules/resource_management/spec/contracts/resource_allocations/create_contract_spec.rb +++ b/modules/resource_management/spec/contracts/resource_allocations/create_contract_spec.rb @@ -43,8 +43,8 @@ RSpec.describe ResourceAllocations::CreateContract do let(:current_user) do create(:user, member_with_permissions: { project => %i[view_resource_planners allocate_user_resources] }) end - let(:planner) { create(:resource_planner, project:, principal: current_user) } - let(:resource_allocation) { build_stubbed(:resource_allocation, entity: planner, principal: current_user) } + let(:work_package) { create(:work_package, project:) } + let(:resource_allocation) { build_stubbed(:resource_allocation, entity: work_package, principal: current_user) } let(:contract) { described_class.new(resource_allocation, current_user) } it "allows entity to be set" do diff --git a/modules/resource_management/spec/contracts/resource_allocations/delete_contract_spec.rb b/modules/resource_management/spec/contracts/resource_allocations/delete_contract_spec.rb index a73a6ea38cd..319cc59bd94 100644 --- a/modules/resource_management/spec/contracts/resource_allocations/delete_contract_spec.rb +++ b/modules/resource_management/spec/contracts/resource_allocations/delete_contract_spec.rb @@ -36,9 +36,9 @@ RSpec.describe ResourceAllocations::DeleteContract do shared_let(:project) { create(:project, enabled_module_names: %w[resource_management]) } shared_let(:owner) { create(:user) } - shared_let(:planner) { create(:resource_planner, project:, principal: owner) } + shared_let(:work_package) { create(:work_package, project:) } - let(:resource_allocation) { build_stubbed(:resource_allocation, entity: planner, principal: owner) } + let(:resource_allocation) { build_stubbed(:resource_allocation, entity: work_package, principal: owner) } let(:contract) { described_class.new(resource_allocation, current_user) } context "when user has allocate_user_resources" do diff --git a/modules/resource_management/spec/contracts/resource_allocations/shared_contract_examples.rb b/modules/resource_management/spec/contracts/resource_allocations/shared_contract_examples.rb index 6dc00ed586e..107c7c1440c 100644 --- a/modules/resource_management/spec/contracts/resource_allocations/shared_contract_examples.rb +++ b/modules/resource_management/spec/contracts/resource_allocations/shared_contract_examples.rb @@ -34,10 +34,10 @@ require "contracts/shared/model_contract_shared_context" RSpec.shared_examples_for "resource allocation contract" do shared_let(:project) { create(:project, enabled_module_names: %w[resource_management]) } shared_let(:owner) { create(:user) } - shared_let(:planner) { create(:resource_planner, project:, principal: owner) } + shared_let(:work_package) { create(:work_package, project:) } let(:resource_allocation) do - build_stubbed(:resource_allocation, entity: planner, principal: owner) + build_stubbed(:resource_allocation, entity: work_package, principal: owner) end context "when user has the allocate_user_resources permission" do diff --git a/modules/resource_management/spec/contracts/resource_allocations/update_contract_spec.rb b/modules/resource_management/spec/contracts/resource_allocations/update_contract_spec.rb index e21158a8e33..ebbcae25b16 100644 --- a/modules/resource_management/spec/contracts/resource_allocations/update_contract_spec.rb +++ b/modules/resource_management/spec/contracts/resource_allocations/update_contract_spec.rb @@ -43,8 +43,8 @@ RSpec.describe ResourceAllocations::UpdateContract do let(:current_user) do create(:user, member_with_permissions: { project => %i[view_resource_planners allocate_user_resources] }) end - let(:planner) { create(:resource_planner, project:, principal: current_user) } - let(:resource_allocation) { build_stubbed(:resource_allocation, entity: planner, principal: current_user) } + let(:work_package) { create(:work_package, project:) } + let(:resource_allocation) { build_stubbed(:resource_allocation, entity: work_package, principal: current_user) } let(:contract) { described_class.new(resource_allocation, current_user) } it "does not allow entity to be set" do diff --git a/modules/resource_management/spec/factories/resource_allocation_factory.rb b/modules/resource_management/spec/factories/resource_allocation_factory.rb index f277279473e..cbec2ab1045 100644 --- a/modules/resource_management/spec/factories/resource_allocation_factory.rb +++ b/modules/resource_management/spec/factories/resource_allocation_factory.rb @@ -30,7 +30,7 @@ FactoryBot.define do factory :resource_allocation, class: "ResourceAllocation" do - entity factory: :resource_planner + entity factory: :work_package principal factory: :user state { "requested" } start_date { Date.new(2026, 1, 5) } diff --git a/modules/resource_management/spec/models/resource_allocation_spec.rb b/modules/resource_management/spec/models/resource_allocation_spec.rb index 233770a069e..1129790d56b 100644 --- a/modules/resource_management/spec/models/resource_allocation_spec.rb +++ b/modules/resource_management/spec/models/resource_allocation_spec.rb @@ -70,9 +70,9 @@ RSpec.describe ResourceAllocation do describe "validations" do shared_let(:project) { create(:project, enabled_module_names: %w[resource_management]) } shared_let(:owner) { create(:user, member_with_permissions: { project => %i[view_resource_planners] }) } - shared_let(:planner) { create(:resource_planner, project:, principal: owner) } + shared_let(:work_package) { create(:work_package, project:) } - let(:allocation) { build(:resource_allocation, entity: planner, principal: owner) } + let(:allocation) { build(:resource_allocation, entity: work_package, principal: owner) } it "is valid with the factory defaults" do expect(allocation).to be_valid @@ -115,6 +115,23 @@ RSpec.describe ResourceAllocation do end end + describe "entity type" do + it "lists the supported entity types" do + expect(described_class::ALLOWED_ENTITY_TYPES).to eq(%w[WorkPackage]) + end + + it "is valid when the entity type is in the allowed list" do + allocation.entity = work_package + expect(allocation).to be_valid + end + + it "is invalid when the entity type is outside the allowed list" do + allocation.entity = create(:resource_planner, project:, principal: owner) + expect(allocation).not_to be_valid + expect(allocation.errors.symbols_for(:entity_type)).to include(:inclusion) + end + end + describe "allocated_time numericality" do it "is invalid when zero" do allocation.allocated_time = 0 @@ -175,7 +192,7 @@ RSpec.describe ResourceAllocation do describe "user_filter serialization" do shared_let(:project) { create(:project, enabled_module_names: %w[resource_management]) } shared_let(:owner) { create(:user, member_with_permissions: { project => %i[view_resource_planners] }) } - shared_let(:planner) { create(:resource_planner, project:, principal: owner) } + shared_let(:work_package) { create(:work_package, project:) } it "serializes filters using the same coder as UserQuery" do coder = described_class.type_for_attribute(:user_filter).coder @@ -191,7 +208,7 @@ RSpec.describe ResourceAllocation do filter.operator = "~" filter.values = ["alice"] - allocation = create(:resource_allocation, entity: planner, principal: owner, user_filter: [filter]) + allocation = create(:resource_allocation, entity: work_package, principal: owner, user_filter: [filter]) reloaded = described_class.find(allocation.id) expect(reloaded.user_filter.size).to eq(1) @@ -201,7 +218,7 @@ RSpec.describe ResourceAllocation do end it "defaults to an empty array" do - allocation = create(:resource_allocation, entity: planner, principal: owner) + allocation = create(:resource_allocation, entity: work_package, principal: owner) expect(allocation.reload.user_filter).to eq([]) end end diff --git a/modules/resource_management/spec/services/resource_allocations/create_service_spec.rb b/modules/resource_management/spec/services/resource_allocations/create_service_spec.rb index 213d86df16d..9e1ca208f6a 100644 --- a/modules/resource_management/spec/services/resource_allocations/create_service_spec.rb +++ b/modules/resource_management/spec/services/resource_allocations/create_service_spec.rb @@ -35,12 +35,12 @@ RSpec.describe ResourceAllocations::CreateService, type: :model do shared_let(:owner) do create(:user, member_with_permissions: { project => %i[view_resource_planners allocate_user_resources] }) end - shared_let(:planner) { create(:resource_planner, project:, principal: owner) } + shared_let(:work_package) { create(:work_package, project:) } let(:assignee) { create(:user, member_with_permissions: { project => %i[view_resource_planners] }) } let(:params) do { - entity: planner, + entity: work_package, principal: assignee, start_date: Date.new(2026, 1, 1), end_date: Date.new(2026, 1, 31), @@ -53,7 +53,7 @@ RSpec.describe ResourceAllocations::CreateService, type: :model do it "creates a resource allocation" do result = service_call expect(result).to be_success, "expected success but got: #{result.errors.full_messages}" - expect(result.result.entity).to eq(planner) + expect(result.result.entity).to eq(work_package) expect(result.result.principal).to eq(assignee) expect(result.result.allocated_time).to eq(8) end diff --git a/modules/resource_management/spec/services/resource_allocations/delete_service_spec.rb b/modules/resource_management/spec/services/resource_allocations/delete_service_spec.rb index 2cd83d64c93..a25f95da2da 100644 --- a/modules/resource_management/spec/services/resource_allocations/delete_service_spec.rb +++ b/modules/resource_management/spec/services/resource_allocations/delete_service_spec.rb @@ -35,9 +35,9 @@ RSpec.describe ResourceAllocations::DeleteService, type: :model do shared_let(:owner) do create(:user, member_with_permissions: { project => %i[view_resource_planners allocate_user_resources] }) end - shared_let(:planner) { create(:resource_planner, project:, principal: owner) } + shared_let(:work_package) { create(:work_package, project:) } - let!(:resource_allocation) { create(:resource_allocation, entity: planner, principal: owner) } + let!(:resource_allocation) { create(:resource_allocation, entity: work_package, principal: owner) } subject(:service_call) do described_class.new(user:, model: resource_allocation).call diff --git a/modules/resource_management/spec/services/resource_allocations/update_service_spec.rb b/modules/resource_management/spec/services/resource_allocations/update_service_spec.rb index 81ae0bd5e57..8eff1cfe6dc 100644 --- a/modules/resource_management/spec/services/resource_allocations/update_service_spec.rb +++ b/modules/resource_management/spec/services/resource_allocations/update_service_spec.rb @@ -35,10 +35,10 @@ RSpec.describe ResourceAllocations::UpdateService, type: :model do shared_let(:owner) do create(:user, member_with_permissions: { project => %i[view_resource_planners allocate_user_resources] }) end - shared_let(:planner) { create(:resource_planner, project:, principal: owner) } + shared_let(:work_package) { create(:work_package, project:) } let!(:resource_allocation) do - create(:resource_allocation, entity: planner, principal: owner, state: "requested", allocated_time: 8) + create(:resource_allocation, entity: work_package, principal: owner, state: "requested", allocated_time: 8) end subject(:service_call) do @@ -52,13 +52,13 @@ RSpec.describe ResourceAllocations::UpdateService, type: :model do end context "when attempting to change the entity" do - let(:other_planner) { create(:resource_planner, project:, principal: owner) } + let(:other_work_package) { create(:work_package, project:) } it "fails because entity is not writable" do - result = described_class.new(user: owner, model: resource_allocation).call(entity: other_planner) + result = described_class.new(user: owner, model: resource_allocation).call(entity: other_work_package) expect(result).not_to be_success expect(result.errors.symbols_for(:entity_id)).to include(:error_readonly) - expect(resource_allocation.reload.entity).to eq(planner) + expect(resource_allocation.reload.entity).to eq(work_package) end end From cff618a1534359853f1ac92ce90a95f9d491e545 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 2 Jun 2026 11:58:54 +0200 Subject: [PATCH 03/27] Add reviewed_by and requested_by to the resource allocation --- .../app/models/resource_allocation.rb | 2 + .../set_attributes_service.rb | 8 +++- ...60507141533_create_resource_allocations.rb | 27 ++++++++++++++ ...20000_add_users_to_resource_allocations.rb | 37 +++++++++++++++++++ .../resource_management/engine.rb | 2 +- .../factories/resource_allocation_factory.rb | 4 +- .../spec/models/resource_allocation_spec.rb | 16 ++------ .../principals/delete_job_integration_spec.rb | 17 +++++++++ .../create_service_spec.rb | 24 ++++++++++-- 9 files changed, 118 insertions(+), 19 deletions(-) create mode 100644 modules/resource_management/db/migrate/20260602120000_add_users_to_resource_allocations.rb diff --git a/modules/resource_management/app/models/resource_allocation.rb b/modules/resource_management/app/models/resource_allocation.rb index 12ff081a309..1242bdf9130 100644 --- a/modules/resource_management/app/models/resource_allocation.rb +++ b/modules/resource_management/app/models/resource_allocation.rb @@ -33,6 +33,8 @@ class ResourceAllocation < ApplicationRecord 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 + belongs_to :reviewed_by, class_name: "User", optional: true serialize :user_filter, coder: Queries::Serialization::Filters.new(UserQuery) diff --git a/modules/resource_management/app/services/resource_allocations/set_attributes_service.rb b/modules/resource_management/app/services/resource_allocations/set_attributes_service.rb index 327d1064f50..36c84592333 100644 --- a/modules/resource_management/app/services/resource_allocations/set_attributes_service.rb +++ b/modules/resource_management/app/services/resource_allocations/set_attributes_service.rb @@ -34,7 +34,13 @@ module ResourceAllocations def set_default_attributes(_params) model.change_by_system do - model.state ||= "requested" + # When a resource allocation is created via this service it bypasses + # the request/approval flow and is directly allocated. This is currently + # the only way to create resource allocations, so this is the default. + # The request/approve flow will get custom services later + model.state ||= "allocated" + model.requested_by = user + model.reviewed_by = user end end end diff --git a/modules/resource_management/db/migrate/20260507141533_create_resource_allocations.rb b/modules/resource_management/db/migrate/20260507141533_create_resource_allocations.rb index be17eefbe7e..a1bbcdcd5ea 100644 --- a/modules/resource_management/db/migrate/20260507141533_create_resource_allocations.rb +++ b/modules/resource_management/db/migrate/20260507141533_create_resource_allocations.rb @@ -1,5 +1,32 @@ # frozen_string_literal: true +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ class CreateResourceAllocations < ActiveRecord::Migration[8.1] def change create_table :resource_allocations do |t| diff --git a/modules/resource_management/db/migrate/20260602120000_add_users_to_resource_allocations.rb b/modules/resource_management/db/migrate/20260602120000_add_users_to_resource_allocations.rb new file mode 100644 index 00000000000..00a4ae8e76a --- /dev/null +++ b/modules/resource_management/db/migrate/20260602120000_add_users_to_resource_allocations.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +class AddUsersToResourceAllocations < ActiveRecord::Migration[8.1] + def change + change_table :resource_allocations, bulk: true do |t| + t.references :requested_by, foreign_key: { to_table: :users }, null: true + t.references :reviewed_by, foreign_key: { to_table: :users }, null: true + 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 d3021104fa6..41a50a9cf11 100644 --- a/modules/resource_management/lib/open_project/resource_management/engine.rb +++ b/modules/resource_management/lib/open_project/resource_management/engine.rb @@ -40,7 +40,7 @@ module OpenProject::ResourceManagement OpenProject::FeatureDecisions.add :resource_management, allow_enabling: Rails.env.local? end - replace_principal_references "ResourceAllocation" => :principal_id + replace_principal_references "ResourceAllocation" => %i[principal_id requested_by_id reviewed_by_id] register "openproject-resource_management", author_url: "https://www.openproject.org", diff --git a/modules/resource_management/spec/factories/resource_allocation_factory.rb b/modules/resource_management/spec/factories/resource_allocation_factory.rb index cbec2ab1045..4a5098dc348 100644 --- a/modules/resource_management/spec/factories/resource_allocation_factory.rb +++ b/modules/resource_management/spec/factories/resource_allocation_factory.rb @@ -32,7 +32,9 @@ FactoryBot.define do factory :resource_allocation, class: "ResourceAllocation" do entity factory: :work_package principal factory: :user - state { "requested" } + requested_by factory: :user + reviewed_by { requested_by } + state { "allocated" } start_date { Date.new(2026, 1, 5) } end_date { Date.new(2026, 1, 9) } allocated_time { 5 * 8 * 60 } # 5 days of 8 hours in minutes diff --git a/modules/resource_management/spec/models/resource_allocation_spec.rb b/modules/resource_management/spec/models/resource_allocation_spec.rb index 1129790d56b..6d0508a4af1 100644 --- a/modules/resource_management/spec/models/resource_allocation_spec.rb +++ b/modules/resource_management/spec/models/resource_allocation_spec.rb @@ -32,18 +32,10 @@ require "spec_helper" RSpec.describe ResourceAllocation do describe "associations" do - it "belongs to a polymorphic entity" do - association = described_class.reflect_on_association(:entity) - expect(association.macro).to eq(:belongs_to) - expect(association.options[:polymorphic]).to be(true) - end - - it "belongs to a principal (user), optional" do - association = described_class.reflect_on_association(:principal) - expect(association.macro).to eq(:belongs_to) - expect(association.options[:class_name]).to eq("User") - expect(association.options[:optional]).to be(true) - end + it { is_expected.to belong_to(:entity).required } + it { is_expected.to belong_to(:principal).class_name("User").inverse_of(:resource_allocations).optional } + it { is_expected.to belong_to(:requested_by).class_name("User").optional } + it { is_expected.to belong_to(:reviewed_by).class_name("User").optional } end describe "state enum" do diff --git a/modules/resource_management/spec/services/principals/delete_job_integration_spec.rb b/modules/resource_management/spec/services/principals/delete_job_integration_spec.rb index ee088244782..67e5921fbfa 100644 --- a/modules/resource_management/spec/services/principals/delete_job_integration_spec.rb +++ b/modules/resource_management/spec/services/principals/delete_job_integration_spec.rb @@ -55,4 +55,21 @@ RSpec.describe Principals::DeleteJob, "ResourceAllocation", type: :model do expect { job }.not_to change { unassigned_allocation.reload.principal_id } end end + + context "with a resource allocation requested or reviewed by the principal" do + let!(:requested_allocation) { create(:resource_allocation, requested_by: principal) } + let!(:reviewed_allocation) { create(:resource_allocation, reviewed_by: principal) } + + it "rewrites requested_by to the deleted user placeholder" do + job + + expect(requested_allocation.reload.requested_by).to eq deleted_user + end + + it "rewrites reviewed_by to the deleted user placeholder" do + job + + expect(reviewed_allocation.reload.reviewed_by).to eq deleted_user + end + end end diff --git a/modules/resource_management/spec/services/resource_allocations/create_service_spec.rb b/modules/resource_management/spec/services/resource_allocations/create_service_spec.rb index 9e1ca208f6a..899718948fc 100644 --- a/modules/resource_management/spec/services/resource_allocations/create_service_spec.rb +++ b/modules/resource_management/spec/services/resource_allocations/create_service_spec.rb @@ -58,13 +58,29 @@ RSpec.describe ResourceAllocations::CreateService, type: :model do expect(result.result.allocated_time).to eq(8) end - it "defaults the state to requested" do - expect(service_call.result.state).to eq("requested") + it "defaults the state to allocated" do + expect(service_call.result.state).to eq("allocated") + end + + it "stamps requested_by and reviewed_by with the calling user" do + result = service_call + expect(result.result.requested_by).to eq(owner) + expect(result.result.reviewed_by).to eq(owner) + end + + it "ignores requested_by and reviewed_by passed in params" do + other_user = create(:user) + result = described_class.new(user: owner).call( + params.merge(requested_by: other_user, reviewed_by: other_user) + ) + expect(result).to be_success, "expected success but got: #{result.errors.full_messages}" + expect(result.result.requested_by).to eq(owner) + expect(result.result.reviewed_by).to eq(owner) end it "honors an explicitly-passed state" do - result = described_class.new(user: owner).call(params.merge(state: "allocated")) - expect(result.result.state).to eq("allocated") + result = described_class.new(user: owner).call(params.merge(state: "requested")) + expect(result.result.state).to eq("requested") end context "when allocated_time is zero" do From 204d0516f87ecb457b92d347aa6ea160a3d07aa8 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 2 Jun 2026 12:32:36 +0200 Subject: [PATCH 04/27] Accessors to set allocated time in hours --- .../app/models/resource_allocation.rb | 13 ++++++ .../spec/models/resource_allocation_spec.rb | 43 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/modules/resource_management/app/models/resource_allocation.rb b/modules/resource_management/app/models/resource_allocation.rb index 1242bdf9130..90bfae4c71c 100644 --- a/modules/resource_management/app/models/resource_allocation.rb +++ b/modules/resource_management/app/models/resource_allocation.rb @@ -62,6 +62,19 @@ class ResourceAllocation < ApplicationRecord entity&.project end + def allocated_hours + return if allocated_time.nil? + + allocated_time / 60.0 + end + + def allocated_hours=(value) + hours = value.is_a?(String) ? DurationConverter.parse(value) : value + self.allocated_time = hours.nil? ? nil : (Float(hours) * 60).round + rescue ChronicDuration::DurationParseError, ArgumentError, TypeError + self.allocated_time = nil + end + private def end_date_after_start_date diff --git a/modules/resource_management/spec/models/resource_allocation_spec.rb b/modules/resource_management/spec/models/resource_allocation_spec.rb index 6d0508a4af1..9343f43a9c3 100644 --- a/modules/resource_management/spec/models/resource_allocation_spec.rb +++ b/modules/resource_management/spec/models/resource_allocation_spec.rb @@ -59,6 +59,49 @@ RSpec.describe ResourceAllocation do end end + describe "#allocated_hours" do + subject(:allocation) { described_class.new } + + describe "reader" do + it "returns the persisted minutes as hours" do + allocation.allocated_time = 150 + expect(allocation.allocated_hours).to eq(2.5) + end + + it "is nil when allocated_time is unset" do + expect(allocation.allocated_hours).to be_nil + end + end + + describe "writer" do + it "stores a numeric value of hours as minutes" do + allocation.allocated_hours = 8 + expect(allocation.allocated_time).to eq(480) + end + + it "parses a duration string via chronic duration" do + allocation.allocated_hours = "2h30m" + expect(allocation.allocated_time).to eq(150) + end + + it "parses a decimal-hours string" do + allocation.allocated_hours = "2.5" + expect(allocation.allocated_time).to eq(150) + end + + it "clears the value when given nil" do + allocation.allocated_time = 480 + allocation.allocated_hours = nil + expect(allocation.allocated_time).to be_nil + end + + it "falls back to nil for an unparseable string (so validation can reject it)" do + allocation.allocated_hours = "not a duration" + expect(allocation.allocated_time).to be_nil + end + end + end + describe "validations" do shared_let(:project) { create(:project, enabled_module_names: %w[resource_management]) } shared_let(:owner) { create(:user, member_with_permissions: { project => %i[view_resource_planners] }) } From ccf61ead91af66c1774ee85db3533bd9d84460ce Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 2 Jun 2026 12:37:27 +0200 Subject: [PATCH 05/27] Add GlobalID handling for ResourceAllocation#entity --- .../app/models/resource_allocation.rb | 12 ++++++ .../spec/models/resource_allocation_spec.rb | 41 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/modules/resource_management/app/models/resource_allocation.rb b/modules/resource_management/app/models/resource_allocation.rb index 90bfae4c71c..115206a6455 100644 --- a/modules/resource_management/app/models/resource_allocation.rb +++ b/modules/resource_management/app/models/resource_allocation.rb @@ -62,6 +62,18 @@ class ResourceAllocation < ApplicationRecord entity&.project end + def entity_gid + entity&.to_gid.to_s + end + + def entity=(value) + if value.is_a?(String) && value.starts_with?("gid://") + super(GlobalID::Locator.locate(value, only: ALLOWED_ENTITY_TYPES.map(&:safe_constantize))) + else + super + end + end + def allocated_hours return if allocated_time.nil? diff --git a/modules/resource_management/spec/models/resource_allocation_spec.rb b/modules/resource_management/spec/models/resource_allocation_spec.rb index 9343f43a9c3..e95dc8d10d2 100644 --- a/modules/resource_management/spec/models/resource_allocation_spec.rb +++ b/modules/resource_management/spec/models/resource_allocation_spec.rb @@ -102,6 +102,47 @@ RSpec.describe ResourceAllocation do end end + describe "entity GlobalID handling" do + shared_let(:project) { create(:project) } + shared_let(:work_package) { create(:work_package, project:) } + + subject(:allocation) { described_class.new } + + describe "#entity_gid" do + it "returns the GlobalID string of the entity" do + allocation.entity = work_package + expect(allocation.entity_gid).to eq(work_package.to_gid.to_s) + end + + it "is an empty string when no entity is set" do + expect(allocation.entity_gid).to eq("") + end + end + + describe "#entity=" do + it "assigns a plain record directly" do + allocation.entity = work_package + expect(allocation.entity).to eq(work_package) + end + + it "resolves a GlobalID string to the record" do + allocation.entity = work_package.to_gid.to_s + expect(allocation.entity).to eq(work_package) + end + + it "round-trips an entity through entity_gid" do + allocation.entity = work_package.to_gid.to_s + expect(allocation.entity_gid).to eq(work_package.to_gid.to_s) + end + + it "ignores a GlobalID of a type outside ALLOWED_ENTITY_TYPES" do + disallowed = create(:user) + allocation.entity = disallowed.to_gid.to_s + expect(allocation.entity).to be_nil + end + end + end + describe "validations" do shared_let(:project) { create(:project, enabled_module_names: %w[resource_management]) } shared_let(:owner) { create(:user, member_with_permissions: { project => %i[view_resource_planners] }) } From b7640dfccd04d5d7df65fd64bbca4a6bf89a8bf1 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 2 Jun 2026 12:37:49 +0200 Subject: [PATCH 06/27] Implement journaling for resource allocations --- app/models/journal.rb | 1 + .../journal/resource_allocation_journal.rb | 33 +++++ .../app/models/resource_allocation.rb | 9 ++ ...000_create_resource_allocation_journals.rb | 44 +++++++ .../journal_formatter/allocated_time.rb | 44 +++++++ ...rce_allocation_acts_as_journalized_spec.rb | 113 ++++++++++++++++++ 6 files changed, 244 insertions(+) create mode 100644 modules/resource_management/app/models/journal/resource_allocation_journal.rb create mode 100644 modules/resource_management/db/migrate/20260602130000_create_resource_allocation_journals.rb create mode 100644 modules/resource_management/lib/open_project/journal_formatter/allocated_time.rb create mode 100644 modules/resource_management/spec/models/resource_allocation_acts_as_journalized_spec.rb diff --git a/app/models/journal.rb b/app/models/journal.rb index 28e3195b639..ec4ace6e18b 100644 --- a/app/models/journal.rb +++ b/app/models/journal.rb @@ -49,6 +49,7 @@ class Journal < ApplicationRecord register_journal_formatter OpenProject::JournalFormatter::AgendaItemDuration register_journal_formatter OpenProject::JournalFormatter::AgendaItemPosition register_journal_formatter OpenProject::JournalFormatter::AgendaItemTitle + register_journal_formatter OpenProject::JournalFormatter::AllocatedTime register_journal_formatter OpenProject::JournalFormatter::Attachment register_journal_formatter OpenProject::JournalFormatter::Cause register_journal_formatter OpenProject::JournalFormatter::CustomComment diff --git a/modules/resource_management/app/models/journal/resource_allocation_journal.rb b/modules/resource_management/app/models/journal/resource_allocation_journal.rb new file mode 100644 index 00000000000..1d125c97e3b --- /dev/null +++ b/modules/resource_management/app/models/journal/resource_allocation_journal.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Journal::ResourceAllocationJournal < Journal::BaseJournal + self.table_name = "resource_allocation_journals" +end diff --git a/modules/resource_management/app/models/resource_allocation.rb b/modules/resource_management/app/models/resource_allocation.rb index 115206a6455..1b951730f39 100644 --- a/modules/resource_management/app/models/resource_allocation.rb +++ b/modules/resource_management/app/models/resource_allocation.rb @@ -38,6 +38,15 @@ class ResourceAllocation < ApplicationRecord serialize :user_filter, coder: Queries::Serialization::Filters.new(UserQuery) + acts_as_journalized + + register_journal_formatted_fields "state", formatter_key: :plaintext + register_journal_formatted_fields "start_date", "end_date", formatter_key: :datetime + register_journal_formatted_fields "allocated_time", formatter_key: :allocated_time + register_journal_formatted_fields "principal_id", "requested_by_id", "reviewed_by_id", + formatter_key: :named_association + register_journal_formatted_fields "entity_gid", formatter_key: :polymorphic_association + enum :state, { requested: "requested", allocated: "allocated", diff --git a/modules/resource_management/db/migrate/20260602130000_create_resource_allocation_journals.rb b/modules/resource_management/db/migrate/20260602130000_create_resource_allocation_journals.rb new file mode 100644 index 00000000000..deb65912eb8 --- /dev/null +++ b/modules/resource_management/db/migrate/20260602130000_create_resource_allocation_journals.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +class CreateResourceAllocationJournals < ActiveRecord::Migration[8.1] + def change + create_table :resource_allocation_journals do |t| + t.references :entity, polymorphic: true + t.references :principal, foreign_key: { to_table: :users } + t.jsonb :user_filter, default: [] + t.string :state + t.date :start_date + t.date :end_date + t.integer :allocated_time + t.references :requested_by, foreign_key: { to_table: :users } + t.references :reviewed_by, foreign_key: { to_table: :users } + end + end +end diff --git a/modules/resource_management/lib/open_project/journal_formatter/allocated_time.rb b/modules/resource_management/lib/open_project/journal_formatter/allocated_time.rb new file mode 100644 index 00000000000..7f46c4e052c --- /dev/null +++ b/modules/resource_management/lib/open_project/journal_formatter/allocated_time.rb @@ -0,0 +1,44 @@ +# 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. +#++ + +# Renders the journaled allocated_time, which is stored in minutes, as a +# human-readable duration in hours (e.g. "8h"), respecting the instance's +# duration format setting. +class OpenProject::JournalFormatter::AllocatedTime < JournalFormatter::Attribute + private + + def format_values(values) + values.map do |minutes| + next if minutes.nil? + + DurationConverter.output(minutes.to_f / 60) + end + end +end diff --git a/modules/resource_management/spec/models/resource_allocation_acts_as_journalized_spec.rb b/modules/resource_management/spec/models/resource_allocation_acts_as_journalized_spec.rb new file mode 100644 index 00000000000..4e35cf1c185 --- /dev/null +++ b/modules/resource_management/spec/models/resource_allocation_acts_as_journalized_spec.rb @@ -0,0 +1,113 @@ +# 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 ResourceAllocation do + describe "journaling" do + shared_let(:project) { create(:project, enabled_module_names: %w[resource_management]) } + shared_let(:work_package) { create(:work_package, project:) } + shared_let(:user) { create(:user) } + + current_user { user } + + subject(:allocation) do + build(:resource_allocation, entity: work_package, principal: user, allocated_time: 2400) + end + + it "uses the dedicated journal data class backed by its own table" do + expect(described_class.journal_class).to eq(Journal::ResourceAllocationJournal) + expect(Journal::ResourceAllocationJournal.table_name).to eq("resource_allocation_journals") + end + + context "on creation" do + it "creates an initial journal capturing the data" do + allocation.save! + + expect(allocation.journals.count).to eq(1) + + data = allocation.last_journal.data + expect(data).to be_a(Journal::ResourceAllocationJournal) + expect(data.state).to eq(allocation.state) + expect(data.entity_type).to eq("WorkPackage") + expect(data.entity_id).to eq(work_package.id) + expect(data.principal_id).to eq(user.id) + expect(data.allocated_time).to eq(2400) + end + + it "attributes the journal to the current user" do + allocation.save! + expect(allocation.last_journal.user).to eq(user) + end + end + + context "when a journaled attribute changes outside the aggregation window", + with_settings: { journal_aggregation_time_minutes: 0 } do + before { allocation.save! } + + it "records a new version with the diff" do + expect { allocation.update!(allocated_time: 999) } + .to change { allocation.journals.count }.from(1).to(2) + + expect(allocation.last_journal.details).to include("allocated_time" => [2400, 999]) + end + + it "renders the allocated_time change in hours, not minutes" do + allocation.update!(allocated_time: 999) + + rendered = allocation.last_journal.render_detail( + ["allocated_time", allocation.last_journal.details["allocated_time"]], html: false + ) + + expect(rendered).to include("40h") # 2400 minutes + expect(rendered).not_to include("2400") + end + end + + context "when nothing changes" do + before { allocation.save! } + + it "does not create a new journal version" do + expect { allocation.save! }.not_to change { allocation.journals.count } + end + end + + context "when the journaled user is deleted" do + before { allocation.save! } + + it "rewrites the principal on the journal data to the deleted-user placeholder" do + deleted_user = create(:deleted_user) + Principals::DeleteJob.perform_now(user) + + expect(allocation.last_journal.data.reload.principal_id).to eq(deleted_user.id) + end + end + end +end From 9be91ca804ff5a13323decc6642c77426b1f5038 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 2 Jun 2026 17:20:55 +0200 Subject: [PATCH 07/27] Store the name for resource allocation user filters --- .../app/models/resource_allocation.rb | 11 +++ ...add_filter_name_to_resource_allocations.rb | 35 ++++++++ ...rce_allocation_acts_as_journalized_spec.rb | 7 ++ .../spec/models/resource_allocation_spec.rb | 84 ++++++++++++++++++- 4 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 modules/resource_management/db/migrate/20260602140000_add_filter_name_to_resource_allocations.rb diff --git a/modules/resource_management/app/models/resource_allocation.rb b/modules/resource_management/app/models/resource_allocation.rb index 1b951730f39..2031875be11 100644 --- a/modules/resource_management/app/models/resource_allocation.rb +++ b/modules/resource_management/app/models/resource_allocation.rb @@ -46,6 +46,7 @@ class ResourceAllocation < ApplicationRecord register_journal_formatted_fields "principal_id", "requested_by_id", "reviewed_by_id", formatter_key: :named_association register_journal_formatted_fields "entity_gid", formatter_key: :polymorphic_association + register_journal_formatted_fields "filter_name", formatter_key: :plaintext enum :state, { requested: "requested", @@ -63,6 +64,8 @@ class ResourceAllocation < ApplicationRecord inclusion: { in: ALLOWED_ENTITY_TYPES }, allow_blank: true + validates :filter_name, presence: true, if: :filter_based? + validate :end_date_after_start_date # Resource allocations are scoped to whatever project their (polymorphic) @@ -83,6 +86,14 @@ class ResourceAllocation < ApplicationRecord end end + def filter_based? + user_filter.present? + end + + def user_assigned? + principal_id.present? + end + def allocated_hours return if allocated_time.nil? diff --git a/modules/resource_management/db/migrate/20260602140000_add_filter_name_to_resource_allocations.rb b/modules/resource_management/db/migrate/20260602140000_add_filter_name_to_resource_allocations.rb new file mode 100644 index 00000000000..8e878f70456 --- /dev/null +++ b/modules/resource_management/db/migrate/20260602140000_add_filter_name_to_resource_allocations.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +class AddFilterNameToResourceAllocations < ActiveRecord::Migration[8.1] + def change + add_column :resource_allocations, :filter_name, :string, null: true + add_column :resource_allocation_journals, :filter_name, :string, null: true + end +end diff --git a/modules/resource_management/spec/models/resource_allocation_acts_as_journalized_spec.rb b/modules/resource_management/spec/models/resource_allocation_acts_as_journalized_spec.rb index 4e35cf1c185..4ed0b4f6cf2 100644 --- a/modules/resource_management/spec/models/resource_allocation_acts_as_journalized_spec.rb +++ b/modules/resource_management/spec/models/resource_allocation_acts_as_journalized_spec.rb @@ -79,6 +79,13 @@ RSpec.describe ResourceAllocation do expect(allocation.last_journal.details).to include("allocated_time" => [2400, 999]) end + it "tracks filter_name changes" do + allocation.update!(filter_name: "Full stack Developer (DE-EN)") + + expect(allocation.last_journal.data.filter_name).to eq("Full stack Developer (DE-EN)") + expect(allocation.last_journal.details).to include("filter_name" => [nil, "Full stack Developer (DE-EN)"]) + end + it "renders the allocated_time change in hours, not minutes" do allocation.update!(allocated_time: 999) diff --git a/modules/resource_management/spec/models/resource_allocation_spec.rb b/modules/resource_management/spec/models/resource_allocation_spec.rb index e95dc8d10d2..a6d9154dabf 100644 --- a/modules/resource_management/spec/models/resource_allocation_spec.rb +++ b/modules/resource_management/spec/models/resource_allocation_spec.rb @@ -102,6 +102,44 @@ RSpec.describe ResourceAllocation do end end + describe "#user_assigned? / #filter_based?" do + let(:assignee) { build_stubbed(:user) } + let(:filter) do + UserQuery.new.filter_for(:name).tap do |f| + f.operator = "~" + f.values = ["alice"] + end + end + + context "with an explicit user (no filter)" do + subject(:allocation) { described_class.new(principal: assignee, user_filter: []) } + + it { is_expected.to be_user_assigned } + it { is_expected.not_to be_filter_based } + end + + context "with a filter placeholder (no principal)" do + subject(:allocation) { described_class.new(principal: nil, user_filter: [filter]) } + + it { is_expected.not_to be_user_assigned } + it { is_expected.to be_filter_based } + end + + context "with a real user assigned to a filter allocation" do + subject(:allocation) { described_class.new(principal: assignee, user_filter: [filter]) } + + it { is_expected.to be_user_assigned } + it { is_expected.to be_filter_based } + end + + context "with neither a principal nor a filter" do + subject(:allocation) { described_class.new(principal: nil, user_filter: []) } + + it { is_expected.not_to be_user_assigned } + it { is_expected.not_to be_filter_based } + end + end + describe "entity GlobalID handling" do shared_let(:project) { create(:project) } shared_let(:work_package) { create(:work_package, project:) } @@ -263,6 +301,46 @@ RSpec.describe ResourceAllocation do end end end + + describe "filter_name (filter-based allocations)" do + let(:filter) do + UserQuery.new.filter_for(:name).tap do |f| + f.operator = "~" + f.values = ["alice"] + end + end + + it "is not filter-based and needs no filter_name with only a principal" do + allocation.user_filter = [] + expect(allocation).to be_valid + expect(allocation).not_to be_filter_based + end + + it "requires a filter_name once a user_filter is present" do + allocation.user_filter = [filter] + allocation.filter_name = nil + + expect(allocation).to be_filter_based + expect(allocation).not_to be_valid + expect(allocation.errors.symbols_for(:filter_name)).to include(:blank) + end + + it "is valid as a placeholder (filter, no principal) with a name" do + allocation.principal = nil + allocation.user_filter = [filter] + allocation.filter_name = "Full stack Developer (DE-EN)" + + expect(allocation).to be_valid + end + + it "allows a real principal alongside a named filter (assigned placeholder)" do + allocation.principal = owner + allocation.user_filter = [filter] + allocation.filter_name = "Full stack Developer (DE-EN)" + + expect(allocation).to be_valid + end + end end describe "user_filter serialization" do @@ -284,7 +362,11 @@ RSpec.describe ResourceAllocation do filter.operator = "~" filter.values = ["alice"] - allocation = create(:resource_allocation, entity: work_package, principal: owner, user_filter: [filter]) + allocation = create(:resource_allocation, + entity: work_package, + principal: owner, + filter_name: "Alices", + user_filter: [filter]) reloaded = described_class.find(allocation.id) expect(reloaded.user_filter.size).to eq(1) From 3466daba5e8eaf1e2d1b7b563a2313d3520df6fc Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 2 Jun 2026 17:26:21 +0200 Subject: [PATCH 08/27] Add methods to resolve the user filter to users --- .../resource_allocations/base_contract.rb | 1 + .../app/models/resource_allocation.rb | 8 +++ .../factories/resource_allocation_factory.rb | 30 +++++--- .../spec/models/resource_allocation_spec.rb | 72 +++++++++++++++++++ 4 files changed, 102 insertions(+), 9 deletions(-) diff --git a/modules/resource_management/app/contracts/resource_allocations/base_contract.rb b/modules/resource_management/app/contracts/resource_allocations/base_contract.rb index 0b0faf8efab..e92cf20cfa8 100644 --- a/modules/resource_management/app/contracts/resource_allocations/base_contract.rb +++ b/modules/resource_management/app/contracts/resource_allocations/base_contract.rb @@ -40,6 +40,7 @@ module ResourceAllocations attribute :end_date attribute :allocated_time attribute :user_filter + attribute :filter_name validate :user_allowed_to_allocate diff --git a/modules/resource_management/app/models/resource_allocation.rb b/modules/resource_management/app/models/resource_allocation.rb index 2031875be11..c71ef279194 100644 --- a/modules/resource_management/app/models/resource_allocation.rb +++ b/modules/resource_management/app/models/resource_allocation.rb @@ -94,6 +94,14 @@ class ResourceAllocation < ApplicationRecord principal_id.present? end + def candidate_query + UserQuery.new.tap do |query| + user_filter.each do |filter| + query.where(filter.field, filter.operator, filter.values) + end + end + end + def allocated_hours return if allocated_time.nil? diff --git a/modules/resource_management/spec/factories/resource_allocation_factory.rb b/modules/resource_management/spec/factories/resource_allocation_factory.rb index 4a5098dc348..e099f05f117 100644 --- a/modules/resource_management/spec/factories/resource_allocation_factory.rb +++ b/modules/resource_management/spec/factories/resource_allocation_factory.rb @@ -44,6 +44,7 @@ FactoryBot.define do trait :with_user_filter do principal { nil } + filter_name { "Full stack Developer (DE-EN)" } transient do job_title_custom_field do UserCustomField.find_by(name: "Job title") || @@ -51,17 +52,28 @@ FactoryBot.define do name: "Job title", possible_values: ["Developer", "Designer", "Project Manager", "Product Manager"]) end + spoken_language_custom_field do + UserCustomField.find_by(name: "Spoken language") || + create(:user_custom_field, :list, + name: "Spoken language", + multi_value: true, + possible_values: %w[German English French Spanish Italian Dutch Portuguese Polish]) + end end + # Build real UserQuery filter objects (not hashes): the serialization + # coder dumps via `filter.field`, so it only accepts filter instances. + # The filter matches developers who speak German or English ("DE-EN"), + # leaving the other languages as non-matching values to test against. user_filter do - cf = job_title_custom_field - developer_option = cf.custom_options.find_by(value: "Developer") - [ - { - "attribute" => cf.column_name, - "operator" => "=", - "values" => [developer_option.id.to_s] - } - ] + job_title = job_title_custom_field + language = spoken_language_custom_field + developer_option = job_title.custom_options.find_by(value: "Developer") + language_options = language.custom_options.where(value: %w[German English]) + + query = UserQuery.new + query.where(job_title.column_name, "=", [developer_option.id.to_s]) + query.where(language.column_name, "=", language_options.map { |option| option.id.to_s }) + query.filters 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 a6d9154dabf..cdd7ff2b46f 100644 --- a/modules/resource_management/spec/models/resource_allocation_spec.rb +++ b/modules/resource_management/spec/models/resource_allocation_spec.rb @@ -379,5 +379,77 @@ RSpec.describe ResourceAllocation do allocation = create(:resource_allocation, entity: work_package, principal: owner) expect(allocation.reload.user_filter).to eq([]) end + + it "round-trips the custom-field filters from the :with_user_filter trait" do + allocation = create(:resource_allocation, :with_user_filter, entity: work_package) + + filters = allocation.reload.user_filter + expect(filters.size).to eq(2) + + job_title = UserCustomField.find_by(name: "Job title") + language = UserCustomField.find_by(name: "Spoken language") + + job_title_filter = filters.find { |f| f.name.to_s == job_title.column_name } + language_filter = filters.find { |f| f.name.to_s == language.column_name } + + expect(job_title_filter.operator).to eq("=") + expect(job_title_filter.values).to eq(job_title.custom_options.where(value: "Developer").pluck(:id).map(&:to_s)) + + # "is (OR)" — matches users speaking German or English. + expect(language_filter.operator).to eq("=") + expect(language_filter.values) + .to match_array(language.custom_options.where(value: %w[German English]).pluck(:id).map(&:to_s)) + end + end + + describe "matching users with the :with_user_filter criteria" do + shared_let(:project) { create(:project, enabled_module_names: %w[resource_management]) } + shared_let(:work_package) { create(:work_package, project:) } + + # Materializes the "Job title" and "Spoken language" custom fields and the + # Developer + (German OR English) filter. + shared_let(:allocation) { create(:resource_allocation, :with_user_filter, entity: work_package) } + shared_let(:job_title) { UserCustomField.find_by(name: "Job title") } + shared_let(:language) { UserCustomField.find_by(name: "Spoken language") } + + def option_id(custom_field, value) + custom_field.custom_options.find_by(value:).id + end + + def user_with(job_title_value, *languages) + create(:user).tap do |user| + user.custom_field_values = { + job_title.id => option_id(job_title, job_title_value), + language.id => languages.map { |spoken| option_id(language, spoken) } + } + user.save!(validate: false) + end + end + + shared_let(:german_developer) { user_with("Developer", "German") } + shared_let(:english_developer) { user_with("Developer", "English") } + shared_let(:bilingual_developer) { user_with("Developer", "French", "English") } + shared_let(:french_developer) { user_with("Developer", "French") } + shared_let(:german_designer) { user_with("Designer", "German") } + + describe "#candidate_query" do + # `UserQuery#results` is scoped to what the current user may see. + current_user { create(:admin) } + + it "is a UserQuery carrying the stored filter criteria" do + query = allocation.candidate_query + + expect(query).to be_a(UserQuery) + expect(query.filters.map { |f| f.name.to_s }) + .to contain_exactly(job_title.column_name, language.column_name) + end + + it "resolves to developers speaking German or English (is (OR)), and excludes the rest" do + results = allocation.candidate_query.results + + expect(results).to include(german_developer, english_developer, bilingual_developer) + expect(results).not_to include(french_developer, german_designer) + end + end end end From 323c095e3692cd780549c2f47e1507db3c322f67 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 3 Jun 2026 11:43:29 +0200 Subject: [PATCH 09/27] Use real timestamp IDs for resource allocation migrations The migrations used a date plus a fake incrementing hour counter (e.g. 20260602120000, ...130000, ...140000) rather than real timestamps. Those synthetic IDs risk colliding with migrations added elsewhere in the app. Rename them to their actual file creation time: 20260602120000 -> 20260602152805 add_users 20260602130000 -> 20260602152807 create_..._journals 20260602140000 -> 20260602170908 add_filter_name create_resource_allocations already had a real timestamp and is left unchanged. Order and resulting schema are preserved. --- ...ons.rb => 20260602152805_add_users_to_resource_allocations.rb} | 0 ...s.rb => 20260602152807_create_resource_allocation_journals.rb} | 0 ... => 20260602170908_add_filter_name_to_resource_allocations.rb} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename modules/resource_management/db/migrate/{20260602120000_add_users_to_resource_allocations.rb => 20260602152805_add_users_to_resource_allocations.rb} (100%) rename modules/resource_management/db/migrate/{20260602130000_create_resource_allocation_journals.rb => 20260602152807_create_resource_allocation_journals.rb} (100%) rename modules/resource_management/db/migrate/{20260602140000_add_filter_name_to_resource_allocations.rb => 20260602170908_add_filter_name_to_resource_allocations.rb} (100%) diff --git a/modules/resource_management/db/migrate/20260602120000_add_users_to_resource_allocations.rb b/modules/resource_management/db/migrate/20260602152805_add_users_to_resource_allocations.rb similarity index 100% rename from modules/resource_management/db/migrate/20260602120000_add_users_to_resource_allocations.rb rename to modules/resource_management/db/migrate/20260602152805_add_users_to_resource_allocations.rb diff --git a/modules/resource_management/db/migrate/20260602130000_create_resource_allocation_journals.rb b/modules/resource_management/db/migrate/20260602152807_create_resource_allocation_journals.rb similarity index 100% rename from modules/resource_management/db/migrate/20260602130000_create_resource_allocation_journals.rb rename to modules/resource_management/db/migrate/20260602152807_create_resource_allocation_journals.rb diff --git a/modules/resource_management/db/migrate/20260602140000_add_filter_name_to_resource_allocations.rb b/modules/resource_management/db/migrate/20260602170908_add_filter_name_to_resource_allocations.rb similarity index 100% rename from modules/resource_management/db/migrate/20260602140000_add_filter_name_to_resource_allocations.rb rename to modules/resource_management/db/migrate/20260602170908_add_filter_name_to_resource_allocations.rb From 978b89121e4a5eccb1234abb4ef1466e1de85d04 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 3 Jun 2026 11:43:57 +0200 Subject: [PATCH 10/27] Add multi-step "Allocate resource" dialog Implement the dialog for creating a resource allocation as a two-step Primer dialog driven by Turbo streams: 1. Kind step: choose between an explicit principal and a filter-criteria placeholder. 2. Allocation step: the per-kind form (principal/work package, date range, hours, filter name) swapped in without navigation. Backend changes: - Controller `new`/`step`/`create` actions render and update the dialog via Turbo streams; entity and principal are resolved against the project and the user's visibility. - Add `principal_explicit` to distinguish an assigned principal from a named filter placeholder, with validations and a `needs_principal_assignment` scope on ResourceAllocation. - Add the `step` collection route. - ViewComponents and form objects under app/components and app/forms. - Locales for the dialog copy. Add request, feature, and model specs covering the flow. --- .../allocation_step/footer_component.rb | 63 +++++ .../allocation_step/form_component.html.erb | 16 ++ .../allocation_step/form_component.rb | 97 ++++++++ .../kind_step/footer_component.rb | 63 +++++ .../kind_step/form_component.html.erb | 14 ++ .../kind_step/form_component.rb | 51 ++++ .../new_dialog_component.html.erb | 57 +++++ .../new_dialog_component.rb | 56 +++++ .../sub_header_component.html.erb | 12 +- .../resource_allocations/base_contract.rb | 1 + .../resource_allocations_controller.rb | 119 ++++++++- .../forms/allocation_kind_form.rb | 48 ++++ .../forms/date_range_form.rb | 61 +++++ .../forms/filter_name_form.rb | 45 ++++ .../resource_allocations/forms/hours_form.rb | 72 ++++++ .../forms/kind_select_form.rb | 66 +++++ .../forms/principal_form.rb | 85 +++++++ .../forms/work_package_form.rb | 77 ++++++ .../app/models/resource_allocation.rb | 16 +- .../resource_management/config/locales/en.yml | 14 ++ modules/resource_management/config/routes.rb | 8 +- ...ncipal_explicit_to_resource_allocations.rb | 42 ++++ .../resource_management/engine.rb | 2 +- .../create_contract_spec.rb | 2 +- .../update_contract_spec.rb | 2 +- .../factories/resource_allocation_factory.rb | 2 + .../features/allocate_resource_dialog_spec.rb | 82 +++++++ .../spec/models/resource_allocation_spec.rb | 122 ++++++---- .../requests/resource_allocations_spec.rb | 228 ++++++++++++++++++ 29 files changed, 1466 insertions(+), 57 deletions(-) create mode 100644 modules/resource_management/app/components/resource_allocations/allocation_step/footer_component.rb create mode 100644 modules/resource_management/app/components/resource_allocations/allocation_step/form_component.html.erb create mode 100644 modules/resource_management/app/components/resource_allocations/allocation_step/form_component.rb create mode 100644 modules/resource_management/app/components/resource_allocations/kind_step/footer_component.rb create mode 100644 modules/resource_management/app/components/resource_allocations/kind_step/form_component.html.erb create mode 100644 modules/resource_management/app/components/resource_allocations/kind_step/form_component.rb create mode 100644 modules/resource_management/app/components/resource_allocations/new_dialog_component.html.erb create mode 100644 modules/resource_management/app/components/resource_allocations/new_dialog_component.rb create mode 100644 modules/resource_management/app/forms/resource_allocations/forms/allocation_kind_form.rb create mode 100644 modules/resource_management/app/forms/resource_allocations/forms/date_range_form.rb create mode 100644 modules/resource_management/app/forms/resource_allocations/forms/filter_name_form.rb create mode 100644 modules/resource_management/app/forms/resource_allocations/forms/hours_form.rb create mode 100644 modules/resource_management/app/forms/resource_allocations/forms/kind_select_form.rb create mode 100644 modules/resource_management/app/forms/resource_allocations/forms/principal_form.rb create mode 100644 modules/resource_management/app/forms/resource_allocations/forms/work_package_form.rb create mode 100644 modules/resource_management/db/migrate/20260603112259_add_principal_explicit_to_resource_allocations.rb create mode 100644 modules/resource_management/spec/features/allocate_resource_dialog_spec.rb create mode 100644 modules/resource_management/spec/requests/resource_allocations_spec.rb 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 new file mode 100644 index 00000000000..e964d93f5dd --- /dev/null +++ b/modules/resource_management/app/components/resource_allocations/allocation_step/footer_component.rb @@ -0,0 +1,63 @@ +# 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 + module AllocationStep + class FooterComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def wrapper_key + ResourceAllocations::NewDialogComponent::FOOTER_ID + end + + def call + component_wrapper do + component_collection do |buttons| + buttons.with_component( + Primer::Beta::Button.new( + data: { "close-dialog-id": ResourceAllocations::NewDialogComponent::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.allocate_resource_dialog.submit") } + end + end + end + end + end +end 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 new file mode 100644 index 00000000000..bcb428e6c9d --- /dev/null +++ b/modules/resource_management/app/components/resource_allocations/allocation_step/form_component.html.erb @@ -0,0 +1,16 @@ +<%= + component_wrapper do + primer_form_with( + model: @allocation, + scope: :resource_allocation, + url: project_resource_allocations_path(@project), + method: :post, + html: { + data: { turbo_stream: true }, + id: ResourceAllocations::NewDialogComponent::FORM_ID + } + ) do |f| + render(form_list_component(f)) + end + end +%> 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 new file mode 100644 index 00000000000..f26aa454df9 --- /dev/null +++ b/modules/resource_management/app/components/resource_allocations/allocation_step/form_component.rb @@ -0,0 +1,97 @@ +# 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 + module AllocationStep + # Step 2 of the dialog: the allocation details. Shares the body wrapper key + # with the step 1 form so the controller can swap one for the other via a + # Turbo stream. For the filter kind it also renders the criteria builder + # (`Filters::FilterForm`) over a UserQuery. + class FormComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def initialize(allocation:, project:, allocation_kind:) + super + @allocation = allocation + @project = project + @allocation_kind = allocation_kind + end + + def wrapper_key + ResourceAllocations::NewDialogComponent::BODY_ID + end + + private + + def filter_based? + @allocation_kind.to_s == "filter" + end + + def dialog_id + ResourceAllocations::NewDialogComponent::DIALOG_ID + end + + def form_list_component(form) + result = [ + ResourceAllocations::Forms::WorkPackageForm.new(form, project: @project, dialog_id: dialog_id), + ResourceAllocations::Forms::DateRangeForm.new(form, dialog_id: dialog_id), + ResourceAllocations::Forms::HoursForm.new(form), + ResourceAllocations::Forms::AllocationKindForm.new(form, allocation_kind: @allocation_kind) + + ] + result = if filter_based? + [ + ResourceAllocations::Forms::FilterNameForm.new(form), + ::Filters::FilterForm.new( + form, + query: @allocation.candidate_query, + wrap_with_controller: true, + hidden_input_name: "filters", + output_format: :json, + autocomplete_append_to: "##{dialog_id}" + ) + ] + result + else + [ + ResourceAllocations::Forms::PrincipalForm.new( + form, + project: @project, + dialog_id: dialog_id + ) + ] + result + end + + Primer::Forms::FormList.new(*result) + end + end + end +end diff --git a/modules/resource_management/app/components/resource_allocations/kind_step/footer_component.rb b/modules/resource_management/app/components/resource_allocations/kind_step/footer_component.rb new file mode 100644 index 00000000000..19f2e10dac6 --- /dev/null +++ b/modules/resource_management/app/components/resource_allocations/kind_step/footer_component.rb @@ -0,0 +1,63 @@ +# 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 + module KindStep + class FooterComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def wrapper_key + ResourceAllocations::NewDialogComponent::FOOTER_ID + end + + def call + component_wrapper do + component_collection do |buttons| + buttons.with_component( + Primer::Beta::Button.new( + data: { "close-dialog-id": ResourceAllocations::NewDialogComponent::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("button_next") } + end + end + end + end + end +end diff --git a/modules/resource_management/app/components/resource_allocations/kind_step/form_component.html.erb b/modules/resource_management/app/components/resource_allocations/kind_step/form_component.html.erb new file mode 100644 index 00000000000..d00fc391d0d --- /dev/null +++ b/modules/resource_management/app/components/resource_allocations/kind_step/form_component.html.erb @@ -0,0 +1,14 @@ +<%= + component_wrapper do + primer_form_with( + url: step_project_resource_allocations_path(@project), + method: :get, + html: { + data: { turbo_stream: true }, + id: ResourceAllocations::NewDialogComponent::FORM_ID + } + ) do |f| + render(ResourceAllocations::Forms::KindSelectForm.new(f, work_package: @work_package)) + end + end +%> diff --git a/modules/resource_management/app/components/resource_allocations/kind_step/form_component.rb b/modules/resource_management/app/components/resource_allocations/kind_step/form_component.rb new file mode 100644 index 00000000000..00a774d2051 --- /dev/null +++ b/modules/resource_management/app/components/resource_allocations/kind_step/form_component.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 ResourceAllocations + module KindStep + # Step 1 of the dialog: the kind selection. Submits via GET to #new, which + # swaps in the step 2 form keyed on the chosen `allocation_kind`. + class FormComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def initialize(project:, work_package: nil) + super + @project = project + @work_package = work_package + end + + def wrapper_key + ResourceAllocations::NewDialogComponent::BODY_ID + end + end + end +end diff --git a/modules/resource_management/app/components/resource_allocations/new_dialog_component.html.erb b/modules/resource_management/app/components/resource_allocations/new_dialog_component.html.erb new file mode 100644 index 00000000000..2e7ea4cc524 --- /dev/null +++ b/modules/resource_management/app/components/resource_allocations/new_dialog_component.html.erb @@ -0,0 +1,57 @@ +<%#-- 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, + position: :right, + data: { "keep-open-on-submit": true } + ) + ) do |dialog| + dialog.with_header(variant: :large) + + # Gives the user/work-package autocompleter dropdowns room in this + # content-sized, centered modal. + dialog.with_body(classes: "Overlay-body_autocomplete_height") do + render( + ResourceAllocations::KindStep::FormComponent.new( + project: @project, + work_package: @work_package + ) + ) + end + + dialog.with_footer do + render(ResourceAllocations::KindStep::FooterComponent.new) + end + end +%> diff --git a/modules/resource_management/app/components/resource_allocations/new_dialog_component.rb b/modules/resource_management/app/components/resource_allocations/new_dialog_component.rb new file mode 100644 index 00000000000..fabd0ba9d8b --- /dev/null +++ b/modules/resource_management/app/components/resource_allocations/new_dialog_component.rb @@ -0,0 +1,56 @@ +# 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 + class NewDialogComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + DIALOG_ID = "allocate-resource-dialog" + FORM_ID = "allocate-resource-form" + FOOTER_ID = "allocate-resource-footer" + # Shared by both step forms so swapping step 1 for step 2 targets the same + # Turbo stream wrapper. + BODY_ID = "allocate-resource-dialog-body" + + def initialize(project:, work_package: nil) + super + + @project = project + @work_package = work_package + end + + private + + def title + I18n.t("resource_management.allocate_resource_dialog.title") + end + end +end diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/sub_header_component.html.erb b/modules/resource_management/app/components/resource_planner_views/work_package_list/sub_header_component.html.erb index c15f3fca861..5b63af7ed0c 100644 --- a/modules/resource_management/app/components/resource_planner_views/work_package_list/sub_header_component.html.erb +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/sub_header_component.html.erb @@ -51,7 +51,12 @@ See COPYRIGHT and LICENSE files for more details. "aria-label": t("resource_management.work_package_list.subheader.add") } ) do |menu| - menu.with_item(label: t("resource_management.work_package_list.subheader.allocate")) do |item| + menu.with_item( + label: t("resource_management.work_package_list.subheader.allocate"), + tag: :a, + href: new_project_resource_allocation_path(@project), + content_arguments: { data: { controller: "async-dialog" } } + ) do |item| item.with_leading_visual_icon(icon: :people) end @@ -68,7 +73,10 @@ See COPYRIGHT and LICENSE files for more details. subheader.with_action_button( leading_icon: :plus, scheme: :primary, - label: t("resource_management.work_package_list.subheader.allocate") + label: t("resource_management.work_package_list.subheader.allocate"), + tag: :a, + href: new_project_resource_allocation_path(@project), + data: { controller: "async-dialog" } ) do t("resource_management.work_package_list.subheader.allocate") end diff --git a/modules/resource_management/app/contracts/resource_allocations/base_contract.rb b/modules/resource_management/app/contracts/resource_allocations/base_contract.rb index e92cf20cfa8..a65622555a2 100644 --- a/modules/resource_management/app/contracts/resource_allocations/base_contract.rb +++ b/modules/resource_management/app/contracts/resource_allocations/base_contract.rb @@ -35,6 +35,7 @@ module ResourceAllocations end attribute :principal + attribute :principal_explicit attribute :state attribute :start_date attribute :end_date 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 91be1ffe29c..52afa89306b 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 @@ -36,18 +36,127 @@ module ::ResourceManagement before_action :find_project_by_project_id before_action :authorize - # The modals and the ResourceAllocations::* contracts/services are wired up - # in follow-up work. For now these are stubs so the routes and the - # `allocate_user_resources` permission have something to bind to. + # Step 1 of the "Allocate resource" dialog: open it on the kind selection + # (explicit user vs filter-criteria placeholder). + def new + respond_with_dialog ResourceAllocations::NewDialogComponent.new( + project: @project, + work_package: context_work_package + ) + end - def new; end + # Step 2: the kind selection submits here, swapping the dialog body and + # footer for the allocation form of the chosen `allocation_kind` via Turbo + # streams (no navigation). + def step + # Seed the entity from the originating context (if any) so the work + # package autocompleter renders pre-selected. + render_allocation_step(ResourceAllocation.new(entity: context_work_package)) + end def edit; end - def create; end + def create + call = ResourceAllocations::CreateService + .new(user: current_user, model: ResourceAllocation.new) + .call(create_params) + + if call.success? + render_create_success + else + render_allocation_step(call.result, status: :unprocessable_entity) + end + end def update; end def destroy; end + + private + + def render_allocation_step(allocation, status: :ok) + replace_via_turbo_stream( + component: ResourceAllocations::AllocationStep::FormComponent.new( + allocation:, + project: @project, + allocation_kind: + ), + status: + ) + replace_via_turbo_stream(component: ResourceAllocations::AllocationStep::FooterComponent.new) + respond_with_turbo_streams(status:) + end + + def render_create_success + 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}") + respond_with_turbo_streams + end + + def allocation_kind + params[:allocation_kind].presence || "principal" + end + + def filter_based_kind? + allocation_kind == "filter" + end + + # The work package the dialog was opened from (e.g. a timeline row), used + # to pre-select the autocompleter. It arrives as `work_package_id`. Always + # scoped to the current project and the user's visibility. + def context_work_package + return @context_work_package if defined?(@context_work_package) + + @context_work_package = resolve_entity("WorkPackage", params[:work_package_id]) + end + + def create_params + permitted = params + .expect(resource_allocation: %i[principal_id filter_name start_date end_date allocated_hours + entity_type entity_id]) + .to_h + .symbolize_keys + + principal_id = permitted.delete(:principal_id) + entity = resolve_entity(permitted.delete(:entity_type), permitted.delete(:entity_id)) + permitted.merge(entity:, **resource_params(principal_id)) + end + + # Resolves the polymorphic entity from the submitted type/id pair, scoped to + # the current project and the user's visibility. The type is checked against + # the model's allow-list before it is constantized. Returns nil for an + # unknown type or unreachable id so the `entity` presence/type validations + # surface the error. + def resolve_entity(entity_type, entity_id) + return if entity_id.blank? + return unless ResourceAllocation::ALLOWED_ENTITY_TYPES.include?(entity_type) + + entity_type.constantize.visible(current_user).where(project: @project).find_by(id: entity_id) + end + + # The kind drives which side of the allocation is populated and is recorded + # on the model via `principal_explicit`: an explicit principal, or a named + # filter placeholder. + def resource_params(principal_id) + if filter_based_kind? + { principal_explicit: false, principal: nil, user_filter: parsed_user_filter } + else + { principal_explicit: true, principal: User.find_by(id: principal_id), filter_name: nil, user_filter: [] } + end + end + + # Turns the FilterForm's JSON payload into UserQuery filter objects, which + # is the shape `ResourceAllocation#user_filter` serializes. + def parsed_user_filter + return [] if params[:filters].blank? + + query = UserQuery.new + ::Queries::ParamsParser.parse(filters: params[:filters]) + .fetch(:filters, []) + .each { |f| query.where(f[:attribute], f[:operator], f[:values]) } + query.filters + end end end diff --git a/modules/resource_management/app/forms/resource_allocations/forms/allocation_kind_form.rb b/modules/resource_management/app/forms/resource_allocations/forms/allocation_kind_form.rb new file mode 100644 index 00000000000..428f569f73d --- /dev/null +++ b/modules/resource_management/app/forms/resource_allocations/forms/allocation_kind_form.rb @@ -0,0 +1,48 @@ +# 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 + module Forms + class AllocationKindForm < ApplicationForm + def initialize(allocation_kind:) + @allocation_kind = allocation_kind + super() + end + + form do |f| + f.hidden( + name: :allocation_kind, + value: @allocation_kind, + scope_name_to_model: false + ) + end + end + end +end diff --git a/modules/resource_management/app/forms/resource_allocations/forms/date_range_form.rb b/modules/resource_management/app/forms/resource_allocations/forms/date_range_form.rb new file mode 100644 index 00000000000..8845f7e155e --- /dev/null +++ b/modules/resource_management/app/forms/resource_allocations/forms/date_range_form.rb @@ -0,0 +1,61 @@ +# 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 + module Forms + # The allocation's start and finish dates, shown side by side. The date + # pickers are told they live in the dialog so their popovers are not clipped. + class DateRangeForm < ApplicationForm + form do |f| + f.group(layout: :horizontal) do |dates| + dates.single_date_picker( + name: :start_date, + label: ResourceAllocation.human_attribute_name(:start_date), + required: true, + value: model.start_date&.iso8601, + datepicker_options: { inDialog: @dialog_id } + ) + dates.single_date_picker( + name: :end_date, + label: ResourceAllocation.human_attribute_name(:end_date), + required: true, + value: model.end_date&.iso8601, + datepicker_options: { inDialog: @dialog_id } + ) + end + end + + def initialize(dialog_id:) + super() + @dialog_id = dialog_id + end + end + end +end diff --git a/modules/resource_management/app/forms/resource_allocations/forms/filter_name_form.rb b/modules/resource_management/app/forms/resource_allocations/forms/filter_name_form.rb new file mode 100644 index 00000000000..dafcd6cd9e2 --- /dev/null +++ b/modules/resource_management/app/forms/resource_allocations/forms/filter_name_form.rb @@ -0,0 +1,45 @@ +# 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 + module Forms + # The placeholder's display name shown for a `filter` allocation, rendered + # above the filter-criteria builder (`Filters::FilterForm`). + class FilterNameForm < ApplicationForm + form do |f| + f.text_field( + name: :filter_name, + label: ResourceAllocation.human_attribute_name(:filter_name), + required: true + ) + end + end + end +end diff --git a/modules/resource_management/app/forms/resource_allocations/forms/hours_form.rb b/modules/resource_management/app/forms/resource_allocations/forms/hours_form.rb new file mode 100644 index 00000000000..d435e9528b8 --- /dev/null +++ b/modules/resource_management/app/forms/resource_allocations/forms/hours_form.rb @@ -0,0 +1,72 @@ +# 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 + module Forms + # The allocated duration, entered in chronic-duration syntax (e.g. "40h", + # "1d 4h"). The `chronic-duration` Stimulus controller normalises the input + # to a canonical hours string on blur. + class HoursForm < ApplicationForm + form do |f| + f.text_field( + name: :allocated_hours, + label: ResourceAllocation.human_attribute_name(:allocated_hours), + required: true, + value: formatted_hours, + invalid: allocated_time_error.present?, + validation_message: allocated_time_error, + data: { controller: "chronic-duration" } + ) + end + + private + + # The duration is entered as `allocated_hours` but stored and validated as + # `allocated_time`. Surface that attribute's errors on this field, each + # formatted like Primer's own field errors ("Hours can't be blank."). + def allocated_time_error + label = ResourceAllocation.human_attribute_name(:allocated_hours) + model.errors.messages_for(:allocated_time) + .map { |message| "#{label} #{message}" } + .join(" ") + .presence + end + + # Renders the stored duration as e.g. "40h", matching what the model's + # `allocated_hours=` setter accepts back. Only relevant when re-rendering + # the step after a validation error. + def formatted_hours + return if model.allocated_hours.nil? + + DurationConverter.output(model.allocated_hours) + end + end + end +end diff --git a/modules/resource_management/app/forms/resource_allocations/forms/kind_select_form.rb b/modules/resource_management/app/forms/resource_allocations/forms/kind_select_form.rb new file mode 100644 index 00000000000..0ab69455620 --- /dev/null +++ b/modules/resource_management/app/forms/resource_allocations/forms/kind_select_form.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 + module Forms + class KindSelectForm < ApplicationForm + def initialize(work_package:) + super() + + @work_package = work_package + end + + form do |f| + f.hidden name: :work_package_id, + value: @work_package&.id, + scope_name_to_model: false + + f.advanced_radio_button_group( + name: :allocation_kind, + label: I18n.t("resource_management.allocate_resource_dialog.kind.label"), + visually_hide_label: true, + scope_name_to_model: false + ) do |group| + group.radio_button( + value: "principal", + checked: true, + label: I18n.t("resource_management.allocate_resource_dialog.kind.principal.label"), + caption: I18n.t("resource_management.allocate_resource_dialog.kind.principal.caption") + ) + group.radio_button( + value: "filter", + label: I18n.t("resource_management.allocate_resource_dialog.kind.filter.label"), + caption: I18n.t("resource_management.allocate_resource_dialog.kind.filter.caption") + ) + end + end + end + end +end diff --git a/modules/resource_management/app/forms/resource_allocations/forms/principal_form.rb b/modules/resource_management/app/forms/resource_allocations/forms/principal_form.rb new file mode 100644 index 00000000000..9da4a2afb49 --- /dev/null +++ b/modules/resource_management/app/forms/resource_allocations/forms/principal_form.rb @@ -0,0 +1,85 @@ +# 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 + module Forms + # The explicit-user picker shown for a `principal` allocation. The typeahead + # is scoped to active users who are members of the current project. + class PrincipalForm < ApplicationForm + form do |f| + f.autocompleter( + name: :principal_id, + label: ResourceAllocation.human_attribute_name(:principal), + required: true, + invalid: principal_error.present?, + validation_message: principal_error, + autocomplete_options: { + component: "opce-user-autocompleter", + url: ::API::V3::Utilities::PathHelper::ApiV3Path.principals, + resource: "principals", + searchKey: "any_name_attribute", + filters: principal_filters, + defaultData: true, + focusDirectly: false, + multiple: false, + appendTo: "##{@dialog_id}" + } + ) + end + + def initialize(project:, dialog_id:) + super() + @project = project + @dialog_id = dialog_id + end + + private + + # The picker submits `principal_id`; any model error is keyed on the + # `principal` association. Surface those errors on this field, each + # formatted like Primer's own field errors ("Assignee can't be blank."). + def principal_error + label = ResourceAllocation.human_attribute_name(:principal) + model.errors.messages_for(:principal) + .map { |message| "#{label} #{message}" } + .join(" ") + .presence + end + + def principal_filters + [ + { name: "type", operator: "=", values: %w[User] }, + { name: "status", operator: "=", values: [Principal.statuses[:active]] }, + { name: "member", operator: "=", values: [@project.id.to_s] } + ] + end + end + end +end diff --git a/modules/resource_management/app/forms/resource_allocations/forms/work_package_form.rb b/modules/resource_management/app/forms/resource_allocations/forms/work_package_form.rb new file mode 100644 index 00000000000..1ea26be11c5 --- /dev/null +++ b/modules/resource_management/app/forms/resource_allocations/forms/work_package_form.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. +#++ + +module ResourceAllocations + module Forms + # The work package the allocation is for. It is the polymorphic `entity`, + # submitted as `entity_type` + `entity_id` and resolved back to a WorkPackage + # in the controller. A remote autocompleter over all work packages in the + # project; pre-selection flows through the bound model's `entity_id`. + class WorkPackageForm < ApplicationForm + form do |f| + f.hidden name: :entity_type, value: "WorkPackage" + f.work_package_autocompleter( + name: :entity_id, + label: WorkPackage.model_name.human, + required: true, + invalid: entity_error.present?, + validation_message: entity_error, + autocomplete_options: { + openDirectly: false, + focusDirectly: false, + dropdownPosition: "bottom", + appendTo: "##{@dialog_id}", + filters: [{ name: "project_id", operator: "=", values: [@project.id.to_s] }] + } + ) + end + + def initialize(project:, dialog_id:) + super() + @project = project + @dialog_id = dialog_id + end + + private + + # The work package is submitted as `entity_id`, but the model keys the + # polymorphic association's presence/type errors on `entity`/`entity_type`. + # Surface them on this field, each formatted like Primer's own field errors + # ("Work package must exist."). + def entity_error + messages = model.errors.messages_for(:entity) + model.errors.messages_for(:entity_type) + messages + .map { |message| "#{WorkPackage.model_name.human} #{message}" } + .join(" ") + .presence + end + end + end +end diff --git a/modules/resource_management/app/models/resource_allocation.rb b/modules/resource_management/app/models/resource_allocation.rb index c71ef279194..64e2a01ef00 100644 --- a/modules/resource_management/app/models/resource_allocation.rb +++ b/modules/resource_management/app/models/resource_allocation.rb @@ -55,6 +55,8 @@ class ResourceAllocation < ApplicationRecord canceled: "canceled" } + scope :needs_principal_assignment, -> { where(principal_explicit: false, principal_id: nil) } + validates :state, :start_date, :end_date, presence: true validates :allocated_time, presence: true, @@ -64,7 +66,13 @@ class ResourceAllocation < ApplicationRecord inclusion: { in: ALLOWED_ENTITY_TYPES }, allow_blank: true - validates :filter_name, presence: true, if: :filter_based? + with_options if: :principal_explicit? do + validates :principal, presence: true + validates :filter_name, absence: true + validates :user_filter, absence: true + end + + validates :filter_name, presence: true, unless: :principal_explicit? validate :end_date_after_start_date @@ -87,13 +95,17 @@ class ResourceAllocation < ApplicationRecord end def filter_based? - user_filter.present? + !principal_explicit? end def user_assigned? principal_id.present? end + def needs_principal_assignment? + !principal_explicit? && principal_id.blank? + end + def candidate_query UserQuery.new.tap do |query| user_filter.each do |filter| diff --git a/modules/resource_management/config/locales/en.yml b/modules/resource_management/config/locales/en.yml index 88fbdaf432c..eaeefd34687 100644 --- a/modules/resource_management/config/locales/en.yml +++ b/modules/resource_management/config/locales/en.yml @@ -3,9 +3,11 @@ en: activerecord: attributes: resource_allocation: + allocated_hours: Hours allocated_time: Allocated time end_date: Finish date entity: Entity + filter_name: Resource filter name principal: Assignee start_date: Start date state: State @@ -66,6 +68,18 @@ en: make_private: Make private make_public: Make public unfavorite: Remove from favorites + allocate_resource_dialog: + title: Allocate resource + submit: Allocate + success_message: Resource allocated. + kind: + label: Allocation type + principal: + label: User + caption: Allocate hours for a specific user. + filter: + label: Filter criteria + caption: Set filter criteria based on user attributes to create a placeholder resource. blankslate: desc: Create a resource planner to start planning capacity for this project. title: No resource planners yet diff --git a/modules/resource_management/config/routes.rb b/modules/resource_management/config/routes.rb index a96e319be63..b5fcc4484e2 100644 --- a/modules/resource_management/config/routes.rb +++ b/modules/resource_management/config/routes.rb @@ -65,6 +65,12 @@ Rails.application.routes.draw do resources :resource_allocations, controller: "resource_management/resource_allocations", - only: %i[new create edit update destroy] + only: %i[new create edit update destroy] do + collection do + # Step 2 of the "Allocate resource" dialog: swaps the kind selection for + # the allocation form of the chosen `allocation_kind`. + get :step + end + end end end diff --git a/modules/resource_management/db/migrate/20260603112259_add_principal_explicit_to_resource_allocations.rb b/modules/resource_management/db/migrate/20260603112259_add_principal_explicit_to_resource_allocations.rb new file mode 100644 index 00000000000..29112dff654 --- /dev/null +++ b/modules/resource_management/db/migrate/20260603112259_add_principal_explicit_to_resource_allocations.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ +class AddPrincipalExplicitToResourceAllocations < ActiveRecord::Migration[8.1] + def change + add_column :resource_allocations, :principal_explicit, :boolean, null: false, default: true + add_column :resource_allocation_journals, :principal_explicit, :boolean + + # Existing placeholders are identified by a stored user filter. + up_only do + execute(<<~SQL.squish) + UPDATE resource_allocations SET principal_explicit = false WHERE user_filter <> '[]'::jsonb + SQL + 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 41a50a9cf11..7cf7656cb8c 100644 --- a/modules/resource_management/lib/open_project/resource_management/engine.rb +++ b/modules/resource_management/lib/open_project/resource_management/engine.rb @@ -77,7 +77,7 @@ module OpenProject::ResourceManagement # it through `allowed_in_project?`). The `contract_actions` map keeps the # permission discoverable for API contracts. permission :allocate_user_resources, - { "resource_management/resource_allocations": %i[new create edit update destroy] }, + { "resource_management/resource_allocations": %i[new step create edit update destroy] }, permissible_on: :project, dependencies: %i[view_resource_planners], contract_actions: { resource_allocation: %i[create update destroy] } diff --git a/modules/resource_management/spec/contracts/resource_allocations/create_contract_spec.rb b/modules/resource_management/spec/contracts/resource_allocations/create_contract_spec.rb index 435a8cdb288..2edbdeb9d99 100644 --- a/modules/resource_management/spec/contracts/resource_allocations/create_contract_spec.rb +++ b/modules/resource_management/spec/contracts/resource_allocations/create_contract_spec.rb @@ -52,7 +52,7 @@ RSpec.describe ResourceAllocations::CreateContract do end it "allows principal, state, dates, allocated_time, and user_filter" do - %i[principal state start_date end_date allocated_time user_filter].each do |attr| + %i[principal principal_explicit state start_date end_date allocated_time user_filter].each do |attr| expect(contract.writable?(attr)).to be(true), "expected #{attr} to be writable" end end diff --git a/modules/resource_management/spec/contracts/resource_allocations/update_contract_spec.rb b/modules/resource_management/spec/contracts/resource_allocations/update_contract_spec.rb index ebbcae25b16..077b681a1fa 100644 --- a/modules/resource_management/spec/contracts/resource_allocations/update_contract_spec.rb +++ b/modules/resource_management/spec/contracts/resource_allocations/update_contract_spec.rb @@ -52,7 +52,7 @@ RSpec.describe ResourceAllocations::UpdateContract do end it "allows principal, state, dates, allocated_time, and user_filter" do - %i[principal state start_date end_date allocated_time user_filter].each do |attr| + %i[principal principal_explicit state start_date end_date allocated_time user_filter].each do |attr| expect(contract.writable?(attr)).to be(true), "expected #{attr} to be writable" end end diff --git a/modules/resource_management/spec/factories/resource_allocation_factory.rb b/modules/resource_management/spec/factories/resource_allocation_factory.rb index e099f05f117..181961e1ec5 100644 --- a/modules/resource_management/spec/factories/resource_allocation_factory.rb +++ b/modules/resource_management/spec/factories/resource_allocation_factory.rb @@ -39,10 +39,12 @@ FactoryBot.define do end_date { Date.new(2026, 1, 9) } allocated_time { 5 * 8 * 60 } # 5 days of 8 hours in minutes user_filter { [] } + principal_explicit { true } traits_for_enum :state trait :with_user_filter do + principal_explicit { false } principal { nil } filter_name { "Full stack Developer (DE-EN)" } transient do diff --git a/modules/resource_management/spec/features/allocate_resource_dialog_spec.rb b/modules/resource_management/spec/features/allocate_resource_dialog_spec.rb new file mode 100644 index 00000000000..f1f4f5f6478 --- /dev/null +++ b/modules/resource_management/spec/features/allocate_resource_dialog_spec.rb @@ -0,0 +1,82 @@ +# 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 "Allocate resource dialog", :js 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 allocate_user_resources view_work_packages] }) + end + shared_let(:resource_planner) { create(:resource_planner, project:, principal: user) } + shared_let(:view) do + ResourceWorkPackageList.create!(name: "WP list", parent: resource_planner, project:, principal: user) + end + + before do + login_as user + visit project_resource_planner_view_path(project, resource_planner, view) + end + + it "opens the dialog and advances from the kind step to the allocation step" do + click_on I18n.t("resource_management.work_package_list.subheader.allocate") + + within_dialog do + expect(page).to have_text(I18n.t("resource_management.allocate_resource_dialog.title")) + expect(page).to have_text(I18n.t("resource_management.allocate_resource_dialog.kind.principal.label")) + expect(page).to have_text(I18n.t("resource_management.allocate_resource_dialog.kind.filter.label")) + + # "User" is selected by default — advance to step 2. + click_on I18n.t("button_next") + + expect(page).to have_field(WorkPackage.model_name.human) + expect(page).to have_field(ResourceAllocation.human_attribute_name(:allocated_hours)) + expect(page).to have_button(I18n.t("resource_management.allocate_resource_dialog.submit")) + end + end + + it "shows the filter criteria builder on the filter step" do + click_on I18n.t("resource_management.work_package_list.subheader.allocate") + + within_dialog do + choose I18n.t("resource_management.allocate_resource_dialog.kind.filter.label") + click_on I18n.t("button_next") + + expect(page).to have_field(ResourceAllocation.human_attribute_name(:filter_name)) + # The blank UserQuery filter form renders its "add filter" selector. + expect(page).to have_css(".op-filters-form") + end + end + + def within_dialog(&) + within("##{ResourceAllocations::NewDialogComponent::DIALOG_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 cdd7ff2b46f..251bf276bb8 100644 --- a/modules/resource_management/spec/models/resource_allocation_spec.rb +++ b/modules/resource_management/spec/models/resource_allocation_spec.rb @@ -102,41 +102,51 @@ RSpec.describe ResourceAllocation do end end - describe "#user_assigned? / #filter_based?" do + describe "#user_assigned? / #filter_based? / #needs_principal_assignment?" do let(:assignee) { build_stubbed(:user) } - let(:filter) do - UserQuery.new.filter_for(:name).tap do |f| - f.operator = "~" - f.values = ["alice"] - end - end - context "with an explicit user (no filter)" do - subject(:allocation) { described_class.new(principal: assignee, user_filter: []) } + context "with an explicit user allocation" do + subject(:allocation) { described_class.new(principal_explicit: true, principal: assignee) } it { is_expected.to be_user_assigned } it { is_expected.not_to be_filter_based } + it { is_expected.not_to be_needs_principal_assignment } end - context "with a filter placeholder (no principal)" do - subject(:allocation) { described_class.new(principal: nil, user_filter: [filter]) } + context "with an unassigned filter placeholder" do + subject(:allocation) { described_class.new(principal_explicit: false, principal: nil) } it { is_expected.not_to be_user_assigned } it { is_expected.to be_filter_based } + it { is_expected.to be_needs_principal_assignment } end - context "with a real user assigned to a filter allocation" do - subject(:allocation) { described_class.new(principal: assignee, user_filter: [filter]) } + context "with a filter placeholder that has a principal assigned" do + subject(:allocation) { described_class.new(principal_explicit: false, principal: assignee) } it { is_expected.to be_user_assigned } it { is_expected.to be_filter_based } + it { is_expected.not_to be_needs_principal_assignment } + end + end + + describe ".needs_principal_assignment" do + shared_let(:project) { create(:project) } + shared_let(:work_package) { create(:work_package, project:) } + + let!(:unassigned_placeholder) do + create(:resource_allocation, entity: work_package, principal_explicit: false, principal: nil, filter_name: "Devs") end - context "with neither a principal nor a filter" do - subject(:allocation) { described_class.new(principal: nil, user_filter: []) } + before do + # An explicit allocation and an already-assigned placeholder must be excluded. + create(:resource_allocation, entity: work_package) + create(:resource_allocation, entity: work_package, + principal_explicit: false, principal: create(:user), filter_name: "Devs") + end - it { is_expected.not_to be_user_assigned } - it { is_expected.not_to be_filter_based } + it "returns only filter placeholders without a principal" do + expect(described_class.needs_principal_assignment).to contain_exactly(unassigned_placeholder) end end @@ -223,8 +233,17 @@ RSpec.describe ResourceAllocation do expect(allocation.errors[:allocated_time]).to be_present end - it "does not require principal (column is nullable)" do + it "requires a principal for an explicit allocation" do + allocation.principal_explicit = true allocation.principal = nil + expect(allocation).not_to be_valid + expect(allocation.errors.symbols_for(:principal)).to include(:blank) + end + + it "does not require a principal for a filter placeholder" do + allocation.principal_explicit = false + allocation.principal = nil + allocation.filter_name = "Devs" expect(allocation).to be_valid end end @@ -302,7 +321,7 @@ RSpec.describe ResourceAllocation do end end - describe "filter_name (filter-based allocations)" do + describe "allocation kind (principal_explicit)" do let(:filter) do UserQuery.new.filter_for(:name).tap do |f| f.operator = "~" @@ -310,35 +329,49 @@ RSpec.describe ResourceAllocation do end end - it "is not filter-based and needs no filter_name with only a principal" do - allocation.user_filter = [] - expect(allocation).to be_valid - expect(allocation).not_to be_filter_based + context "when explicit (principal_explicit: true)" do + before { allocation.principal_explicit = true } + + it "is valid with a principal and no filter" do + expect(allocation).to be_valid + end + + it "rejects a filter_name" do + allocation.filter_name = "Devs" + expect(allocation).not_to be_valid + expect(allocation.errors.symbols_for(:filter_name)).to include(:present) + end + + it "rejects a user_filter" do + allocation.user_filter = [filter] + expect(allocation).not_to be_valid + expect(allocation.errors.symbols_for(:user_filter)).to include(:present) + end end - it "requires a filter_name once a user_filter is present" do - allocation.user_filter = [filter] - allocation.filter_name = nil + context "when filter-based (principal_explicit: false)" do + before do + allocation.principal_explicit = false + allocation.principal = nil + end - expect(allocation).to be_filter_based - expect(allocation).not_to be_valid - expect(allocation.errors.symbols_for(:filter_name)).to include(:blank) - end + it "requires a filter_name" do + allocation.filter_name = nil + expect(allocation).not_to be_valid + expect(allocation.errors.symbols_for(:filter_name)).to include(:blank) + end - it "is valid as a placeholder (filter, no principal) with a name" do - allocation.principal = nil - allocation.user_filter = [filter] - allocation.filter_name = "Full stack Developer (DE-EN)" + it "is valid as an unassigned placeholder with a name" do + allocation.filter_name = "Full stack Developer (DE-EN)" + expect(allocation).to be_valid + end - expect(allocation).to be_valid - end - - it "allows a real principal alongside a named filter (assigned placeholder)" do - allocation.principal = owner - allocation.user_filter = [filter] - allocation.filter_name = "Full stack Developer (DE-EN)" - - expect(allocation).to be_valid + it "allows a real principal alongside a named filter (assigned placeholder)" do + allocation.principal = owner + allocation.filter_name = "Full stack Developer (DE-EN)" + allocation.user_filter = [filter] + expect(allocation).to be_valid + end end end end @@ -364,7 +397,8 @@ RSpec.describe ResourceAllocation do allocation = create(:resource_allocation, entity: work_package, - principal: owner, + principal_explicit: false, + principal: nil, filter_name: "Alices", user_filter: [filter]) diff --git a/modules/resource_management/spec/requests/resource_allocations_spec.rb b/modules/resource_management/spec/requests/resource_allocations_spec.rb new file mode 100644 index 00000000000..7d2acb81b01 --- /dev/null +++ b/modules/resource_management/spec/requests/resource_allocations_spec.rb @@ -0,0 +1,228 @@ +# 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 "ResourceAllocations requests", + :skip_csrf, + 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 allocate_user_resources view_work_packages] }) + end + shared_let(:assignee) { create(:user, member_with_permissions: { project => %i[view_work_packages] }) } + shared_let(:work_package) { create(:work_package, project:) } + + before { login_as user } + + describe "GET new" do + it "opens the dialog on the kind-selection step" do + get new_project_resource_allocation_path(project), as: :turbo_stream + + expect(response).to have_http_status(:ok) + expect(response.body).to include('value="principal"') + expect(response.body).to include('value="filter"') + end + end + + describe "GET step" do + context "with allocation_kind=principal" do + it "renders the allocation step with a user picker" do + get step_project_resource_allocations_path(project, allocation_kind: "principal"), as: :turbo_stream + + expect(response).to have_http_status(:ok) + # Autocompleters render as Angular custom elements carrying the field + # name in `data-input-name` rather than a plain `name` attribute. + expect(response.body).to include("opce-user-autocompleter") + expect(response.body).to include("resource_allocation[principal_id]") + expect(response.body).to include("resource_allocation[entity_id]") + expect(response.body).to include("resource_allocation[allocated_hours]") + end + end + + context "with allocation_kind=filter" do + it "renders the allocation step with a filter name and the filter form" do + get step_project_resource_allocations_path(project, allocation_kind: "filter"), as: :turbo_stream + + expect(response).to have_http_status(:ok) + expect(response.body).to include("resource_allocation[filter_name]") + expect(response.body).to include('name="filters"') + end + end + end + + describe "POST create" do + context "for an explicit user" do + subject(:perform) do + post project_resource_allocations_path(project), + 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-03", + allocated_hours: "40h" + } + }, + as: :turbo_stream + end + + it "creates a resource allocation for the principal" do + expect { perform }.to change(ResourceAllocation, :count).by(1) + + allocation = ResourceAllocation.last + expect(allocation.entity).to eq(work_package) + expect(allocation.principal).to eq(assignee) + expect(allocation).to be_principal_explicit + expect(allocation.allocated_time).to eq(40 * 60) + expect(allocation.filter_name).to be_nil + expect(allocation.user_filter).to eq([]) + expect(allocation.requested_by).to eq(user) + end + end + + context "for a filter-criteria placeholder" do + subject(:perform) do + post project_resource_allocations_path(project), + params: { + allocation_kind: "filter", + filters: [{ login: { operator: "~", values: ["dev"] } }].to_json, + resource_allocation: { + filter_name: "Full stack Developer (DE-EN)", + entity_type: "WorkPackage", + entity_id: work_package.id, + start_date: "2026-03-02", + end_date: "2026-03-03", + allocated_hours: "40h" + } + }, + as: :turbo_stream + end + + it "creates a placeholder allocation carrying the user filter" do + expect { perform }.to change(ResourceAllocation, :count).by(1) + + allocation = ResourceAllocation.last + expect(allocation.principal).to be_nil + expect(allocation).not_to be_principal_explicit + expect(allocation).to be_needs_principal_assignment + expect(allocation.filter_name).to eq("Full stack Developer (DE-EN)") + expect(allocation.user_filter.map(&:name)).to contain_exactly(:login) + expect(allocation.user_filter.first.values).to eq(["dev"]) + end + end + + context "with invalid input" do + subject(:perform) do + post project_resource_allocations_path(project), + params: { + allocation_kind: "principal", + resource_allocation: { + principal_id: assignee.id, + entity_type: "WorkPackage", + entity_id: work_package.id, + start_date: "2026-03-03", + end_date: "2026-03-02", # before start_date + allocated_hours: "40h" + } + }, + as: :turbo_stream + end + + it "does not create an allocation and re-renders the step" do + expect { perform }.not_to change(ResourceAllocation, :count) + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context "with a work package the user cannot reach in this project" do + shared_let(:other_work_package) { create(:work_package) } + + subject(:perform) do + post project_resource_allocations_path(project), + params: { + allocation_kind: "principal", + resource_allocation: { + principal_id: assignee.id, + entity_type: "WorkPackage", + entity_id: other_work_package.id, + start_date: "2026-03-02", + end_date: "2026-03-03", + allocated_hours: "40h" + } + }, + as: :turbo_stream + end + + it "does not create an allocation and re-renders the step" do + expect { perform }.not_to change(ResourceAllocation, :count) + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context "with an entity type outside the allow-list" do + subject(:perform) do + post project_resource_allocations_path(project), + params: { + allocation_kind: "principal", + resource_allocation: { + principal_id: assignee.id, + entity_type: "Project", + entity_id: project.id, + start_date: "2026-03-02", + end_date: "2026-03-03", + allocated_hours: "40h" + } + }, + as: :turbo_stream + end + + it "does not create an allocation and re-renders the step" do + expect { perform }.not_to change(ResourceAllocation, :count) + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + + context "without the allocate_user_resources permission" do + shared_let(:viewer) { create(:user, member_with_permissions: { project => %i[view_resource_planners] }) } + + before { login_as viewer } + + it "denies access to the new dialog" do + get new_project_resource_allocation_path(project), as: :turbo_stream + + expect(response).to have_http_status(:forbidden) + end + end +end From cc688f59730f6ea71a0e0f0a5e231fa7fd98a5dc Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 3 Jun 2026 11:50:04 +0200 Subject: [PATCH 11/27] Refactor form component for allocation step --- .../allocation_step/form_component.rb | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) 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 f26aa454df9..2f4b95b420d 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 @@ -61,36 +61,35 @@ module ResourceAllocations end def form_list_component(form) - result = [ + prepends = if filter_based? + [ + ResourceAllocations::Forms::FilterNameForm.new(form), + ::Filters::FilterForm.new( + form, + query: @allocation.candidate_query, + wrap_with_controller: true, + hidden_input_name: "filters", + output_format: :json, + autocomplete_append_to: "##{dialog_id}" + ) + ] + else + [ + ResourceAllocations::Forms::PrincipalForm.new( + form, + project: @project, + dialog_id: dialog_id + ) + ] + end + + Primer::Forms::FormList.new( + *prepends, ResourceAllocations::Forms::WorkPackageForm.new(form, project: @project, dialog_id: dialog_id), ResourceAllocations::Forms::DateRangeForm.new(form, dialog_id: dialog_id), ResourceAllocations::Forms::HoursForm.new(form), ResourceAllocations::Forms::AllocationKindForm.new(form, allocation_kind: @allocation_kind) - - ] - result = if filter_based? - [ - ResourceAllocations::Forms::FilterNameForm.new(form), - ::Filters::FilterForm.new( - form, - query: @allocation.candidate_query, - wrap_with_controller: true, - hidden_input_name: "filters", - output_format: :json, - autocomplete_append_to: "##{dialog_id}" - ) - ] + result - else - [ - ResourceAllocations::Forms::PrincipalForm.new( - form, - project: @project, - dialog_id: dialog_id - ) - ] + result - end - - Primer::Forms::FormList.new(*result) + ) end end end From e3fe605ce92e5691fd787764139707e9c056148b Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 3 Jun 2026 12:01:03 +0200 Subject: [PATCH 12/27] Condense comments --- .../allocation_step/form_component.rb | 4 --- .../kind_step/form_component.rb | 2 -- .../new_dialog_component.html.erb | 2 -- .../configure_step/footer_component.rb | 6 ++-- .../configure_step/form_component.rb | 15 ++++------ .../edit_dialog_component.html.erb | 4 +-- ...add_work_package_dialog_component.html.erb | 2 -- .../content_component.html.erb | 6 ++-- .../work_package_list/row_component.rb | 24 +++++---------- .../sub_header_component.html.erb | 2 -- .../manage_contents_contract.rb | 7 ++--- .../resource_allocations_controller.rb | 25 ++++------------ .../resource_planner_views_controller.rb | 30 ++++++------------- .../forms/date_range_form.rb | 2 -- .../forms/filter_name_form.rb | 2 -- .../resource_allocations/forms/hours_form.rb | 11 ++----- .../forms/principal_form.rb | 7 ++--- .../forms/work_package_form.rb | 10 ++----- .../forms/configure_form.rb | 7 ++--- .../add_work_package_form.rb | 8 ++--- .../resource_management/categorized.rb | 5 ++-- .../app/models/resource_work_package_list.rb | 13 ++++---- .../app/models/user_card.rb | 3 -- .../set_attributes_service.rb | 6 ++-- .../resource_planner_views/create_service.rb | 20 +++++-------- .../resource_planner_views/delete_service.rb | 2 -- .../set_attributes_service.rb | 12 +++----- .../resource_planners/create_service.rb | 12 ++++---- modules/resource_management/config/routes.rb | 4 --- .../journal_formatter/allocated_time.rb | 4 +-- .../resource_management/engine.rb | 11 +++---- .../requests/resource_planner_views_spec.rb | 3 -- 32 files changed, 78 insertions(+), 193 deletions(-) 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 2f4b95b420d..2f8df518ecc 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 @@ -30,10 +30,6 @@ module ResourceAllocations module AllocationStep - # Step 2 of the dialog: the allocation details. Shares the body wrapper key - # with the step 1 form so the controller can swap one for the other via a - # Turbo stream. For the filter kind it also renders the criteria builder - # (`Filters::FilterForm`) over a UserQuery. class FormComponent < ApplicationComponent include ApplicationHelper include OpTurbo::Streamable diff --git a/modules/resource_management/app/components/resource_allocations/kind_step/form_component.rb b/modules/resource_management/app/components/resource_allocations/kind_step/form_component.rb index 00a774d2051..de71e0d771f 100644 --- a/modules/resource_management/app/components/resource_allocations/kind_step/form_component.rb +++ b/modules/resource_management/app/components/resource_allocations/kind_step/form_component.rb @@ -30,8 +30,6 @@ module ResourceAllocations module KindStep - # Step 1 of the dialog: the kind selection. Submits via GET to #new, which - # swaps in the step 2 form keyed on the chosen `allocation_kind`. class FormComponent < ApplicationComponent include ApplicationHelper include OpTurbo::Streamable diff --git a/modules/resource_management/app/components/resource_allocations/new_dialog_component.html.erb b/modules/resource_management/app/components/resource_allocations/new_dialog_component.html.erb index 2e7ea4cc524..d7d5a4cf25e 100644 --- a/modules/resource_management/app/components/resource_allocations/new_dialog_component.html.erb +++ b/modules/resource_management/app/components/resource_allocations/new_dialog_component.html.erb @@ -39,8 +39,6 @@ See COPYRIGHT and LICENSE files for more details. ) do |dialog| dialog.with_header(variant: :large) - # Gives the user/work-package autocompleter dropdowns room in this - # content-sized, centered modal. dialog.with_body(classes: "Overlay-body_autocomplete_height") do render( ResourceAllocations::KindStep::FormComponent.new( diff --git a/modules/resource_management/app/components/resource_planner_views/configure_step/footer_component.rb b/modules/resource_management/app/components/resource_planner_views/configure_step/footer_component.rb index f0ff80515ce..e933e1bdb4e 100644 --- a/modules/resource_management/app/components/resource_planner_views/configure_step/footer_component.rb +++ b/modules/resource_management/app/components/resource_planner_views/configure_step/footer_component.rb @@ -68,10 +68,8 @@ module ResourcePlannerViews private - # When a `cancel_href` is passed, Cancel becomes a link that navigates - # away — used by the new-planner flow so dismissing step 2 lands the - # user on a page that reflects the just-created planner. Without one, - # Cancel just dismisses the dialog (standalone "+ Add view" flow). + # The new-planner flow passes a `cancel_href` so dismissing step 2 still + # lands on the just-created planner rather than just closing the dialog. def cancel_button if @cancel_href Primer::Beta::Button.new(tag: :a, href: @cancel_href, mr: 1) diff --git a/modules/resource_management/app/components/resource_planner_views/configure_step/form_component.rb b/modules/resource_management/app/components/resource_planner_views/configure_step/form_component.rb index 2925b5148c9..1121b4c2872 100644 --- a/modules/resource_management/app/components/resource_planner_views/configure_step/form_component.rb +++ b/modules/resource_management/app/components/resource_planner_views/configure_step/form_component.rb @@ -30,13 +30,10 @@ module ResourcePlannerViews module ConfigureStep - # Renders the "Configure view" form (name + filter mode). Used both for - # the new-view dialog (step 2) and for the edit dialog. Callers - # pass the form `url`, HTTP `method`, and the model to bind to. The - # `form_id` lets the surrounding dialog wire its submit button to this - # form via `