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