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.
This commit is contained in:
Klaus Zanders
2026-06-03 11:43:57 +02:00
parent 323c095e36
commit 978b89121e
29 changed files with 1466 additions and 57 deletions
@@ -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
@@ -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
%>
@@ -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
@@ -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
@@ -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
%>
@@ -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
@@ -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
%>
@@ -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
@@ -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
@@ -35,6 +35,7 @@ module ResourceAllocations
end
attribute :principal
attribute :principal_explicit
attribute :state
attribute :start_date
attribute :end_date
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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|
@@ -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
+7 -1
View File
@@ -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
@@ -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
@@ -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] }
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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])
@@ -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