Merge pull request #23512 from opf/resource-allocation-modals

Resource allocation modals
This commit is contained in:
Klaus Zanders
2026-06-10 11:50:11 +02:00
committed by GitHub
69 changed files with 2827 additions and 228 deletions
+1
View File
@@ -49,6 +49,7 @@ class Journal < ApplicationRecord
register_journal_formatter OpenProject::JournalFormatter::AgendaItemDuration
register_journal_formatter OpenProject::JournalFormatter::AgendaItemPosition
register_journal_formatter OpenProject::JournalFormatter::AgendaItemTitle
register_journal_formatter OpenProject::JournalFormatter::AllocatedTime
register_journal_formatter OpenProject::JournalFormatter::Attachment
register_journal_formatter OpenProject::JournalFormatter::Cause
register_journal_formatter OpenProject::JournalFormatter::CustomComment
@@ -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,92 @@
# 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 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)
prepends = if filter_based?
[
ResourceAllocations::Forms::FilterNameForm.new(form),
::Filters::FilterFormComponent.new(
builder: form,
query: @allocation.candidate_query,
wrap_with_controller: true,
hidden_input_name: "filters",
output_format: :json,
autocomplete_append_to: "##{dialog_id}"
)
]
else
[
ResourceAllocations::Forms::PrincipalForm.new(
form,
project: @project,
dialog_id: dialog_id
)
]
end
Primer::Forms::FormList.new(
*prepends,
ResourceAllocations::Forms::WorkPackageForm.new(form, project: @project, dialog_id: dialog_id),
ResourceAllocations::Forms::DateRangeForm.new(form, dialog_id: dialog_id),
ResourceAllocations::Forms::HoursForm.new(form),
ResourceAllocations::Forms::AllocationKindForm.new(form, allocation_kind: @allocation_kind)
)
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,49 @@
# 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 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,55 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
++#%>
<%=
render(
Primer::Alpha::Dialog.new(
id: DIALOG_ID,
title:,
size: :large,
classes: "Overlay--size-large-portrait",
data: { "keep-open-on-submit": true }
)
) do |dialog|
dialog.with_header(variant: :large)
dialog.with_body(classes: "Overlay-body_autocomplete_height") do
render(
ResourceAllocations::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
@@ -0,0 +1,90 @@
# 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 OutsideDatesStep
class FooterComponent < ApplicationComponent
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
def wrapper_key
ResourceAllocations::NewDialogComponent::FOOTER_ID
end
def call
component_wrapper(:footer_layout, align_items: :center, flex: 1) do |footer|
footer.with_column(mr: :auto) do
render(
Primer::Beta::Button.new(
scheme: :invisible,
form: ResourceAllocations::NewDialogComponent::FORM_ID,
type: :submit,
name: "back",
value: "1"
)
) do |button|
button.with_leading_visual_icon(icon: :"arrow-left")
I18n.t(:button_back)
end
end
footer.with_column(mr: 1) do
render(
Primer::Beta::Button.new(
data: { "close-dialog-id": ResourceAllocations::NewDialogComponent::DIALOG_ID }
)
) { I18n.t(:button_cancel) }
end
footer.with_column do
render(
Primer::Beta::Button.new(
scheme: :danger,
form: ResourceAllocations::NewDialogComponent::FORM_ID,
type: :submit,
name: "confirmed",
value: "1"
)
) { I18n.t("resource_management.allocate_resource_dialog.submit") }
end
end
end
private
# Renders the turbo-stream wrapper as a Primer flex container so the footer
# layout uses system arguments instead of inline styles. Receives the
# wrapper arguments (including the `id`) from `component_wrapper`.
def footer_layout(system_arguments, &)
flex_layout(**system_arguments, &)
end
end
end
end
@@ -0,0 +1,38 @@
<%=
component_wrapper do
flex_layout(align_items: :center, text_align: :center, p: 3) do |body|
body.with_row(mb: 2) do
render(Primer::Beta::Octicon.new(:calendar, size: :medium, color: :danger))
end
body.with_row(mb: 2) do
render(Primer::Beta::Heading.new(tag: :h2, font_size: 4)) { heading }
end
body.with_row(mb: 1) do
render(Primer::Beta::Text.new(tag: :p, color: :muted, m: 0)) { description }
end
body.with_row do
render(Primer::Beta::Text.new(tag: :p, color: :muted, m: 0)) { confirmation }
end
body.with_row do
form_with(
url: project_resource_allocations_path(@project),
method: :post,
id: ResourceAllocations::NewDialogComponent::FORM_ID,
data: { turbo_stream: true }
) do
safe_join(
[
hidden_field_tag("allocation_kind", @allocation_kind),
(hidden_field_tag("filters", @filters) if @filters.present?),
*@form_values.map { |name, value| hidden_field_tag("resource_allocation[#{name}]", value) }
].compact
)
end
end
end
end
%>
@@ -0,0 +1,78 @@
# 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 OutsideDatesStep
class FormComponent < ApplicationComponent
include ApplicationHelper
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
def initialize(allocation:, project:, allocation_kind:, form_values:, filters: nil)
super
@allocation = allocation
@project = project
@allocation_kind = allocation_kind
@form_values = form_values
@filters = filters
end
def wrapper_key
ResourceAllocations::NewDialogComponent::BODY_ID
end
private
def heading
I18n.t("resource_management.allocate_resource_dialog.outside_dates.title")
end
def description
I18n.t(
"resource_management.allocate_resource_dialog.outside_dates.description",
resource_dates: date_range(@allocation.start_date, @allocation.end_date),
work_package_dates: date_range(@allocation.entity_start_date, @allocation.entity_due_date)
)
end
def confirmation
I18n.t("resource_management.allocate_resource_dialog.outside_dates.confirm_#{@allocation.schedule_violation}")
end
def date_range(from_date, to_date)
"#{format_or_dash(from_date)} - #{format_or_dash(to_date)}"
end
def format_or_dash(date)
date.present? ? helpers.format_date(date) : ""
end
end
end
end
@@ -68,10 +68,8 @@ module ResourcePlannerViews
private
# When a `cancel_href` is passed, Cancel becomes a link that navigates
# away — used by the new-planner flow so dismissing step 2 lands the
# user on a page that reflects the just-created planner. Without one,
# Cancel just dismisses the dialog (standalone "+ Add view" flow).
# The new-planner flow passes a `cancel_href` so dismissing step 2 still
# lands on the just-created planner rather than just closing the dialog.
def cancel_button
if @cancel_href
Primer::Beta::Button.new(tag: :a, href: @cancel_href, mr: 1)
@@ -30,13 +30,10 @@
module ResourcePlannerViews
module ConfigureStep
# Renders the "Configure view" form (name + filter mode). Used both for
# the new-view dialog (step 2) and for the edit dialog. Callers
# pass the form `url`, HTTP `method`, and the model to bind to. The
# `form_id` lets the surrounding dialog wire its submit button to this
# form via `<button form="...">`. `wrapper_key` is overridable so this
# component can be slotted into different dialog bodies — each dialog
# has its own replaceable body wrapper.
# Shared by the new-view and edit dialogs. `form_id` lets the surrounding
# dialog wire its submit button to this form via `<button form="...">`, and
# `wrapper_key` is overridable so it can be slotted into either dialog's
# (separately replaceable) body.
class FormComponent < ApplicationComponent
include ApplicationHelper
include OpTurbo::Streamable
@@ -65,8 +62,8 @@ module ResourcePlannerViews
private
# Hidden on first render for manual views to match the initially-checked
# radio; the show-when-value-selected controller takes over after that.
# Matches the initially-checked radio on first render; the
# show-when-value-selected controller takes over after that.
def initial_filter_mode_automatic?
!(@view.respond_to?(:manually_picked?) && @view.manually_picked?)
end
@@ -33,7 +33,7 @@ See COPYRIGHT and LICENSE files for more details.
id: DIALOG_ID,
title:,
size: :large,
position: :right,
classes: "Overlay--size-large-portrait",
data: { "keep-open-on-submit": true }
)
) do |dialog|
@@ -53,8 +53,8 @@ See COPYRIGHT and LICENSE files for more details.
end
dialog.with_footer do
# `mr: :auto` pushes the destructive action to the left; the dialog
# footer's flex layout right-aligns the remaining buttons.
# `mr: :auto` pushes the destructive action to the left of the footer's
# flex row, leaving the remaining buttons right-aligned.
concat(
render(
Primer::Beta::Button.new(
@@ -33,7 +33,7 @@ See COPYRIGHT and LICENSE files for more details.
id: DIALOG_ID,
title:,
size: :large,
position: :right,
classes: "Overlay--size-large-portrait",
data: { "keep-open-on-submit": true }
)
) do |dialog|
@@ -32,13 +32,12 @@ See COPYRIGHT and LICENSE files for more details.
Primer::Alpha::Dialog.new(
id: DIALOG_ID,
title:,
size: :medium_portrait
size: :large,
classes: "Overlay--size-large-portrait"
)
) do |dialog|
dialog.with_header(variant: :large)
# Gives the autocompleter dropdown room in this content-sized modal — the
# shared fix for autocompleters in centered dialogs.
dialog.with_body(classes: "Overlay-body_autocomplete_height") do
primer_form_with(
url: form_url,
@@ -43,9 +43,9 @@ See COPYRIGHT and LICENSE files for more details.
)
%>
<% if @view.manually_picked? %>
<%# `target-container-accessor` points the drag controller at the body
rowgroup (the header one carries .Box-header), as the BorderBox markup
offers no hook on the list element itself. %>
<%# `target-container-accessor` selects the body rowgroup (the header one
carries .Box-header); the BorderBox markup offers no hook on the list
element itself. %>
<%= content_tag(
:div,
data: { controller: "generic-drag-and-drop", "generic-drag-and-drop-position-mode-value": "position" }
@@ -32,14 +32,13 @@ module ResourcePlannerViews::WorkPackageList
class RowComponent < ::OpPrimer::BorderBoxRowComponent
alias_method :work_package, :model
# Drag type shared with the container in ContentComponent so the
# generic-drag-and-drop controller only accepts rows from this list.
# Must match the container's accepted type in ContentComponent so the
# drag-and-drop controller only accepts rows from this list.
DRAGGABLE_TYPE = "resource-work-package"
# Drag-and-drop attributes for the generic-drag-and-drop controller (manual
# lists only). `row_data` runs while the parent table builds the row, before
# this component enters the render pipeline, so `helpers` is unavailable —
# hence the direct route-helpers call.
# `row_data` runs while the parent table builds the row, before this
# component enters the render pipeline, so `helpers` is unavailable — hence
# the direct route-helpers call.
def row_data
return {} unless manual?
@@ -56,9 +55,8 @@ module ResourcePlannerViews::WorkPackageList
"resource-work-package-row-#{work_package.id}" if manual?
end
# The type / id / status info line stacked above the linked subject. For
# manual lists a drag handle is prepended — the generic-drag-and-drop
# controller only starts a drag from a `.DragHandle` (handle: true).
# Manual lists prepend a drag handle — the drag-and-drop controller only
# starts a drag from a `.DragHandle`.
def subject
return subject_content unless manual?
@@ -100,12 +98,10 @@ module ResourcePlannerViews::WorkPackageList
render(WorkPackages::HighlightedDateComponent.new(work_package:))
end
# Placeholder until allocation data is available.
def allocation
render(Primer::Beta::Text.new(color: :muted)) { allocation_placeholder }
end
# Placeholder until allocated members are available.
def allocated_members
render(Primer::Beta::Text.new(color: :muted)) { allocation_placeholder }
end
@@ -120,8 +116,6 @@ module ResourcePlannerViews::WorkPackageList
I18n.t("resource_management.work_package_list.allocation_placeholder")
end
# Most items are still stubs. Reorder + remove apply only to manual views;
# automatic views offer the filter-criteria shortcut instead.
def context_menu
render(Primer::Alpha::ActionMenu.new) do |menu|
menu.with_show_button(icon: "kebab-horizontal",
@@ -169,8 +163,6 @@ module ResourcePlannerViews::WorkPackageList
end
end
# Reorder sub-menu. Edge moves are omitted when the row already sits at the
# top/bottom, mirroring the agenda-item and phase-definition menus.
def move_item(menu)
menu.with_sub_menu_item(label: t("resource_management.work_package_list.context_menu.move")) do |submenu|
submenu.with_leading_visual_icon(icon: :"arrow-right")
@@ -219,8 +211,6 @@ module ResourcePlannerViews::WorkPackageList
table.manual?
end
# Position of this row within the manually ordered list, used to drop the
# edge move actions.
def position_index
@position_index ||= table.rows.index { |wp| wp.id == work_package.id }
end
@@ -39,8 +39,6 @@ See COPYRIGHT and LICENSE files for more details.
)
if @view.manually_picked?
# Hand-picked views can both allocate and pull in existing work packages,
# so the primary action becomes a dropdown.
subheader.with_action_menu(
leading_icon: :plus,
trailing_icon: :"triangle-down",
@@ -51,8 +49,15 @@ 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|
item.with_leading_visual_icon(icon: :people)
if allowed_to_allocate?
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
end
menu.with_item(
@@ -64,11 +69,14 @@ See COPYRIGHT and LICENSE files for more details.
item.with_leading_visual_icon(icon: :"op-relations")
end
end
else
elsif allowed_to_allocate?
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
@@ -38,5 +38,11 @@ module ResourcePlannerViews::WorkPackageList
@resource_planner = resource_planner
@view = view
end
private
def allowed_to_allocate?
User.current.allowed_in_project?(:allocate_user_resources, @project)
end
end
end
@@ -32,7 +32,7 @@ See COPYRIGHT and LICENSE files for more details.
Primer::Alpha::Dialog.new(
title:,
size: :large,
position: :right,
classes: "Overlay--size-large-portrait",
id: DIALOG_ID,
data: { "keep-open-on-submit": true }
)
@@ -35,11 +35,13 @@ module ResourceAllocations
end
attribute :principal
attribute :principal_explicit
attribute :state
attribute :start_date
attribute :end_date
attribute :allocated_time
attribute :user_filter
attribute :filter_name
validate :user_allowed_to_allocate
@@ -29,10 +29,9 @@
#++
module ResourcePlannerViews
# Authorizes changing a view's contents (picking, removing, and reordering
# work packages). This only mutates the view's ordered work packages, not the
# view model itself, so we reuse BaseContract's owner/public rule but skip
# model validation.
# Reuses BaseContract's owner/public authorization but skips model validation:
# mutating a view's contents only touches its ordered work packages, not the
# view model itself.
class ManageContentsContract < BaseContract
protected
@@ -0,0 +1,205 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module ::ResourceManagement
class ResourceAllocationsController < BaseController
include OpTurbo::ComponentStream
menu_item :resource_management
before_action :find_project_by_project_id
before_action :authorize
def new
respond_with_dialog ResourceAllocations::NewDialogComponent.new(
project: @project,
work_package: context_work_package
)
end
def step
# Pre-select the autocompleter when the dialog was opened from a work package.
render_allocation_step(ResourceAllocation.new(entity: context_work_package))
end
def edit; end
def create
# The confirmation step's "Back" button resubmits the carried form values
# so the editable step can be re-rendered pre-filled.
return render_back_step if params[:back].present?
validation = set_attributes(create_params)
return render_allocation_step(validation.result, status: :unprocessable_entity) if validation.failure?
return render_outside_dates_step(validation.result) if needs_date_confirmation?(validation.result)
persist_allocation
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_outside_dates_step(allocation)
replace_via_turbo_stream(
component: ResourceAllocations::OutsideDatesStep::FormComponent.new(
allocation:,
project: @project,
allocation_kind:,
form_values: submitted_allocation_params,
filters: params[:filters]
)
)
replace_via_turbo_stream(component: ResourceAllocations::OutsideDatesStep::FooterComponent.new)
respond_with_turbo_streams
end
def persist_allocation
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 render_back_step
render_allocation_step(set_attributes(create_params).result)
end
def needs_date_confirmation?(allocation)
allocation.schedule_violation && params[:confirmed].blank?
end
def set_attributes(attributes)
ResourceAllocations::SetAttributesService
.new(user: current_user, model: ResourceAllocation.new, contract_class: ResourceAllocations::CreateContract)
.call(attributes)
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
def context_work_package
return @context_work_package if defined?(@context_work_package)
@context_work_package = resolve_entity("WorkPackage", params[:work_package_id])
end
# Raw, untransformed values to carry through the confirmation step as hidden
# inputs so a confirmed resubmit recreates exactly what the user entered.
def submitted_allocation_params
params
.fetch(:resource_allocation, {})
.permit(:principal_id, :filter_name, :start_date, :end_date, :allocated_hours, :entity_type, :entity_id)
.to_h
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
# Allow-list the type before constantizing it. Returns nil for an unknown
# type or unreachable id, letting the entity 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
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.visible.in_project(@project).find_by(id: principal_id),
filter_name: nil,
user_filter: []
}
end
end
# `user_filter` serializes UserQuery filter objects, so convert the
# FilterForm's JSON payload into them.
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
@@ -40,9 +40,8 @@ module ::ResourceManagement
only: %i[show edit update destroy
new_work_package add_work_package remove_work_package
move_work_package reorder_work_package]
# `view_resource_planners` (the controller-level :authorize) only grants
# read access. Changing a view's contents requires owning the planner, or
# the manage-public permission on a public planner.
# The controller-level :authorize only grants read access; mutating a view's
# contents additionally requires ownership or manage-public.
before_action :authorize_manage_contents,
only: %i[new_work_package add_work_package remove_work_package
move_work_package reorder_work_package]
@@ -96,8 +95,6 @@ module ::ResourceManagement
flash[:error] = call.message
end
# The deleted view was a tab; navigate back to the planner, which falls
# back to a remaining view (or the blank slate).
redirect_to project_resource_planner_path(@project, @resource_planner), status: :see_other
end
@@ -136,7 +133,6 @@ module ::ResourceManagement
respond_with_turbo_streams
end
# `direction` is one of top/up/down/bottom (from the row's Move sub-menu).
def move_work_package
move_to_index(params[:work_package_id]) do |index, count|
case params[:direction].to_s
@@ -151,8 +147,7 @@ module ::ResourceManagement
respond_with_turbo_streams
end
# Drag-and-drop drop target. The generic-drag-and-drop controller posts the
# 1-based index the row was dropped at; convert it to a 0-based target.
# The drag-and-drop controller posts a 1-based drop index; convert to 0-based.
def reorder_work_package
move_to_index(params[:work_package_id]) { params[:position].to_i - 1 }
@@ -170,10 +165,8 @@ module ::ResourceManagement
query.ordered_work_packages.create!(work_package:, position: next_position)
end
# The block receives the current index and total count and returns the
# desired index. Positions are re-packed 1..n afterwards, which keeps menu
# moves and drag-drop drops consistent and tolerates sparse positions left
# by the work-package table.
# Positions are re-packed 1..n afterwards so menu moves and drag-drop stay
# consistent and sparse positions left by the work-package table are tolerated.
def move_to_index(work_package_id)
ordered = @view.effective_query.ordered_work_packages.order(:position).to_a
from = ordered.index { |owp| owp.work_package_id == work_package_id.to_i }
@@ -223,8 +216,7 @@ module ::ResourceManagement
respond_with_turbo_streams
end
# Re-renders the edit dialog's form in place when the update fails
# validation. The footer is static, so only the form is replaced.
# The edit dialog's footer is static, so only the form is replaced.
def render_edit_step(view, status: :ok)
replace_via_turbo_stream(
component: ResourcePlannerViews::ConfigureStep::FormComponent.new(
@@ -240,8 +232,6 @@ module ::ResourceManagement
respond_with_turbo_streams
end
# Closes the dialog and replaces the tab nav (the name may have changed)
# and content in place rather than navigating away.
def render_update_success(view)
# The cached children association still holds the pre-update name.
@resource_planner.children.reload
@@ -268,10 +258,9 @@ module ::ResourceManagement
name: default_view_name(view_class))
end
# Pre-fills the view name so the configure step is not a second blank
# "Name" field after naming the planner. Falls back to the view type's
# label (e.g. "Work packages list"); the user can still rename it. On
# create the submitted name overrides this.
# Pre-fill the name with the view type's label so the configure step is not
# a second blank "Name" field after naming the planner. A submitted name
# overrides it.
def default_view_name(view_class)
I18n.t("resource_management.view_types.#{view_class.model_name.i18n_key}.label",
default: view_class.model_name.human)
@@ -281,7 +270,6 @@ module ::ResourceManagement
params.expect(view: %i[name]).to_h.merge(query_configuration_params)
end
# `filters` and `filter_mode` configure the backing query, not the view.
# The radio is scoped to the `:view` form (`view[filter_mode]`) while the
# filters JSON is top-level, so read the toggle from either place.
def query_configuration_params
@@ -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,59 @@
# 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 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,43 @@
# 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 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,65 @@
# 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 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 field is `allocated_hours` but the model validates `allocated_time`;
# relabel that attribute's errors onto this field.
def allocated_time_error
label = ResourceAllocation.human_attribute_name(:allocated_hours)
model.errors.messages_for(:allocated_time)
.map { |message| "#{label} #{message}" }
.join(" ")
.presence
end
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,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.
#++
module ResourceAllocations
module Forms
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 field is `principal_id` but the model keys errors on the `principal`
# association; relabel them onto this field.
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,71 @@
# 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 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 field is `entity_id` but the model keys errors on the polymorphic
# `entity`/`entity_type`; relabel them onto this field.
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
@@ -38,10 +38,9 @@ module ResourcePlannerViews
required: true
)
# `filter_mode` is a UI-only toggle (not persisted on the view); the
# `show-when-value-selected` controller toggles the filter form. The
# checked radio reflects the persisted query so editing a hand-picked
# view does not silently revert it to automatic.
# `filter_mode` is a UI-only toggle, not a view attribute. Seed the
# checked radio from the persisted query so editing a hand-picked view
# does not silently revert it to automatic.
manual = model.respond_to?(:manually_picked?) && model.manually_picked?
f.advanced_radio_button_group(
@@ -30,10 +30,6 @@
module ResourcePlannerViews
module WorkPackageList
# Single-field form used inside the "add existing work package" dialog of a
# manually hand-picked view. The autocompleter is scoped to the current
# project, excludes work packages already on the list, and its dropdown is
# appended to the dialog so it is not clipped.
class AddWorkPackageForm < ApplicationForm
form do |f|
f.work_package_autocompleter(
@@ -59,8 +55,8 @@ module ResourcePlannerViews
private
# Scope the typeahead to the project and hide work packages already on
# the list (`id` is a list filter, so `!` excludes the given ids).
# `id` is a list filter, so the `!` operator excludes the given ids
# (work packages already on the list).
def autocomplete_filters
filters = [{ name: "project_id", operator: "=", values: [@project.id] }]
@@ -29,9 +29,8 @@
#++
module ResourceManagement
# Tags persisted views that belong to the resource management area with the
# matching `category` so they can be told apart from work-package or project
# views. Applies to the planner and every view type nested below it.
# Tags the planner and its nested views with a `category` so they can be told
# apart from work-package or project views.
module Categorized
extend ActiveSupport::Concern
@@ -0,0 +1,33 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
class Journal::ResourceAllocationJournal < Journal::BaseJournal
self.table_name = "resource_allocation_journals"
end
@@ -29,11 +29,25 @@
#++
class ResourceAllocation < ApplicationRecord
ALLOWED_ENTITY_TYPES = %w[WorkPackage].freeze
belongs_to :entity, polymorphic: true, optional: false
belongs_to :principal, class_name: "User", optional: true, inverse_of: :resource_allocations
belongs_to :requested_by, class_name: "User", optional: true
belongs_to :reviewed_by, class_name: "User", optional: true
serialize :user_filter, coder: Queries::Serialization::Filters.new(UserQuery)
acts_as_journalized
register_journal_formatted_fields "state", formatter_key: :plaintext
register_journal_formatted_fields "start_date", "end_date", formatter_key: :datetime
register_journal_formatted_fields "allocated_time", formatter_key: :allocated_time
register_journal_formatted_fields "principal_id", "requested_by_id", "reviewed_by_id",
formatter_key: :named_association
register_journal_formatted_fields "entity_gid", formatter_key: :polymorphic_association
register_journal_formatted_fields "filter_name", formatter_key: :plaintext
enum :state, {
requested: "requested",
allocated: "allocated",
@@ -41,11 +55,25 @@ 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,
numericality: { only_integer: true, greater_than: 0 }
validates :entity_type,
inclusion: { in: ALLOWED_ENTITY_TYPES },
allow_blank: true
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
# Resource allocations are scoped to whatever project their (polymorphic)
@@ -54,11 +82,85 @@ class ResourceAllocation < ApplicationRecord
entity&.project
end
def entity_gid
entity&.to_gid.to_s
end
def entity=(value)
if value.is_a?(String) && value.starts_with?("gid://")
super(GlobalID::Locator.locate(value, only: ALLOWED_ENTITY_TYPES.map(&:safe_constantize)))
else
super
end
end
def filter_based?
!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|
query.where(filter.field, filter.operator, filter.values)
end
end
end
def allocated_hours
return if allocated_time.nil?
allocated_time / 60.0
end
def allocated_hours=(value)
hours = value.is_a?(String) ? DurationConverter.parse(value) : value
self.allocated_time = hours.nil? ? nil : (Float(hours) * 60).round
rescue ChronicDuration::DurationParseError, ArgumentError, TypeError
self.allocated_time = nil
end
def entity_start_date
entity.try(:start_date)
end
def entity_due_date
entity.try(:due_date)
end
# Describes how the allocation falls outside the schedule of its entity,
# comparing only the bounds the entity actually defines. Returns nil when the
# allocation fits within those bounds or there is nothing to compare against.
def schedule_violation
if starts_before_entity? && ends_after_entity?
:before_and_after
elsif starts_before_entity?
:before_start
elsif ends_after_entity?
:after_finish
end
end
private
def starts_before_entity?
entity_start_date.present? && start_date.present? && start_date < entity_start_date
end
def ends_after_entity?
entity_due_date.present? && end_date.present? && end_date > entity_due_date
end
def end_date_after_start_date
return if start_date.blank? || end_date.blank?
return if end_date > start_date
return if end_date >= start_date
errors.add :end_date, :greater_than_start_date
end
@@ -31,22 +31,20 @@
class ResourceWorkPackageList < PersistedView
include ResourceManagement::Categorized
# Restricts the result set to the query's `ordered_work_packages` (operator
# `ow`) — i.e. a manually hand-picked selection.
# The `ow` ("ordered work packages") filter restricts the result set to a
# manually hand-picked selection.
MANUAL_FILTER_NAME = "manual_sort"
validate :query_must_be_work_package_query
# `new_default` applies the standard defaults (status filter, sort, …) so the
# query validates. The `::` prefix disambiguates from
# The `::` prefix disambiguates the top-level `Query` from
# `ActiveRecord::AttributeMethods::Query`.
def build_default_query
::Query.new_default(project:, user: principal)
end
# Translates the configure form's filter selection and automatic/manual
# toggle into the backing query. The change is persisted alongside the view
# via the `autosave` association.
# The mutated query is persisted alongside the view via the `autosave`
# association.
def apply_query_configuration(filters_json:, filter_mode:)
query = effective_query
return if query.nil?
@@ -61,7 +59,6 @@ class ResourceWorkPackageList < PersistedView
end
end
# Whether this view's items are hand-picked rather than filtered.
def manually_picked?
effective_query&.manually_sorted? || false
end
@@ -55,9 +55,6 @@ class UserCard < PersistedView
effective_query&.results
end
# Returns a fresh, unsaved UserQuery suitable for this view. Used by the
# create flow (saved and linked to the view) and by the configure-view
# dialog (rendered against the FilterComponent without being saved).
def build_default_query
UserQuery.new(project:, principal:)
end
@@ -34,7 +34,11 @@ module ResourceAllocations
def set_default_attributes(_params)
model.change_by_system do
model.state ||= "requested"
# This service bypasses the request/approval flow and allocates directly;
# the request/approve flow will get its own services later.
model.state ||= "allocated"
model.requested_by = user
model.reviewed_by = user
end
end
end
@@ -32,10 +32,9 @@ module ResourcePlannerViews
class CreateService < ::BaseServices::Create
protected
# Same trick as ResourcePlanners::CreateService: STI sets `type` during
# `.new`, before the model is extended with ChangedBySystem. Treat that
# initial change as a system change so the contract does not flag `type`
# as a user-written readonly attribute.
# STI sets `type` during `.new`, before the model is extended with
# ChangedBySystem; mark that initial change as system-made so the contract
# does not flag `type` as a user-written readonly attribute.
def instance(_params)
view = model || PersistedView.new
view.extend(OpenProject::ChangedBySystem) unless view.is_a?(OpenProject::ChangedBySystem)
@@ -43,10 +42,8 @@ module ResourcePlannerViews
view
end
# Saves the view together with a freshly built query of the type
# appropriate for the view subclass (UserQuery for UserCard, work-package
# Query for ResourceWorkPackageList). Both records are saved inside a
# transaction so a failed view validation rolls back the query as well.
# View and query are saved in one transaction so a failed view validation
# rolls back the query as well.
def persist(service_result)
view = service_result.result
ApplicationRecord.transaction do
@@ -55,11 +52,8 @@ module ResourcePlannerViews
end
end
# When the parent planner has no default view yet, treat the first
# created child as the default. Covers both the "+" dialog (sets the
# default if none was picked) and the new-planner flow (the planner's
# `default_view_id` is intentionally unset at creation time and is
# filled in here once the chosen view exists).
# The new-planner flow intentionally leaves `default_view_id` unset at
# creation; fill it from the first created child here.
def after_perform(call)
return call unless call.success?
@@ -34,8 +34,6 @@ module ResourcePlannerViews
class DeleteService < ::BaseServices::Delete
private
# Keep the parent planner consistent: if the deleted view was its default,
# repoint the default at a remaining view (or clear it).
def after_perform(call)
return call unless call.success?
@@ -32,8 +32,8 @@ module ResourcePlannerViews
class SetAttributesService < ::BaseServices::SetAttributes
private
# `filters` and `filter_mode` are not view attributes; pull them out before
# `super` calls `model.attributes=`, then apply them to the query.
# `filters`/`filter_mode` are not view attributes; pull them out before
# `super` runs `model.attributes=`, then apply them to the query.
def set_attributes(params)
filters = params.delete(:filters)
filter_mode = params.delete(:filter_mode)
@@ -49,9 +49,6 @@ module ResourcePlannerViews
end
end
# Builds the query if missing and lets the view type translate the filter
# selection and mode into query filters. Non-configurable view types are
# left untouched.
def configure_query(filters:, filter_mode:)
return unless model.respond_to?(:apply_query_configuration)
@@ -67,9 +64,8 @@ module ResourcePlannerViews
query = model.build_default_query
return if query.nil?
# `query=` touches `query_id`/`query_type`; on create the model has been
# extended with ChangedBySystem so the contract does not flag them as
# user-written readonly attributes.
# `query=` touches `query_id`/`query_type`; mark them system-made so the
# contract does not flag them as user-written readonly attributes.
if model.respond_to?(:change_by_system)
model.change_by_system { model.query = query }
else
@@ -33,8 +33,8 @@ module ResourcePlanners
protected
# STI sets `type` during `.new`, before the model is extended with
# ChangedBySystem. Treat that initial change as a system change so the
# contract does not flag `type` as a user-written readonly attribute.
# ChangedBySystem; mark that initial change as system-made so the contract
# does not flag `type` as a user-written readonly attribute.
def instance(_params)
planner = model || ResourcePlanner.new
planner.extend(OpenProject::ChangedBySystem) unless planner.is_a?(OpenProject::ChangedBySystem)
@@ -52,16 +52,14 @@ module ResourcePlanners
ServiceResult.failure(errors:)
end
# Strip service-only params before they reach SetAttributesService /
# the model, since they are not planner attributes.
def set_attributes_params(params)
super.except(:default_view_class_name, :favorite)
end
def after_perform(call)
# The initial child view is created in a second dialog step by
# ResourcePlannerViewsController#create. That flow also sets
# `default_view_id` on the planner via ResourcePlannerViews::CreateService.
# No default view is set here: the initial child view (and the planner's
# `default_view_id`) is created in a second dialog step by
# ResourcePlannerViews::CreateService.
call.result.add_favoriting_user(user) if call.success? && params[:favorite]
call
end
@@ -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
@@ -39,22 +41,17 @@ en:
button_next: Next
label_resource_management: Resource planning
permission_allocate_user_resources: Allocate user resources
permission_allocate_user_resources_explanation: >
Allows users to create, update, and delete resource allocations within a
resource planner. This includes assigning users (or user filters) to a
planner and adjusting the allocated time and date range.
permission_allocate_user_resources_explanation: >-
Allows users to create, update, and delete resource allocations within a resource planner. This includes assigning users
(or user filters) to a planner and adjusting the allocated time and date range.
permission_manage_public_resource_planners: Manage public resource planners
permission_manage_public_resource_planners_explanation: >
Allows users to create and manage public resource planners. It allows them
to view, create, manage and publish their own resource planners. It does not
allow users to view resource planners created by other users that are not
shared publicly.
permission_manage_public_resource_planners_explanation: >-
Allows users to create and manage public resource planners. It allows them to view, create, manage and publish their own
resource planners. It does not allow users to view resource planners created by other users that are not shared publicly.
permission_view_resource_planners: View resource planners
permission_view_resource_planners_explanation: >
Allows users to access resource planners. It allows them to create and
manage their own resource planners and view public resource planners. It
does not allow users to view resource planners created by other users that
are not shared publicly.
permission_view_resource_planners_explanation: >-
Allows users to access resource planners. It allows them to create and manage their own resource planners and view public
resource planners. It does not allow users to view resource planners created by other users that are not shared publicly.
plugin_openproject_resource_management:
description: Provides resource management and capacity planning.
name: OpenProject Resource Management
@@ -66,6 +63,28 @@ en:
make_private: Make private
make_public: Make public
unfavorite: Remove from favorites
allocate_resource_dialog:
kind:
filter:
caption: Set filter criteria based on user attributes to create a placeholder resource.
label: Filter criteria
label: Allocation type
principal:
caption: Allocate hours for a specific user.
label: User
outside_dates:
confirm_after_finish: >-
Please confirm that you want to allocate resources after the finish date of the work package.
confirm_before_and_after: >-
Please confirm that you want to allocate resources outside the dates of the work package.
confirm_before_start: >-
Please confirm that you want to allocate resources before the start date of the work package.
description: >-
The selected resource dates, %{resource_dates}, are outside of the work package's dates, %{work_package_dates}.
title: Do you want to allocate resources outside the work package's dates?
submit: Allocate
success_message: Resource allocated.
title: Allocate resource
blankslate:
desc: Create a resource planner to start planning capacity for this project.
title: No resource planners yet
@@ -74,28 +93,24 @@ en:
filter_mode:
automatic:
caption: >-
An automatically-generated list that displays items that meet
criteria you define.
An automatically-generated list that displays items that meet criteria you define.
label: Automatically filtered
label: Item selection
manual:
caption: >-
A custom list of items you manually add and remove. Filtering is not
possible.
A custom list of items you manually add and remove. Filtering is not possible.
label: Manually hand-picked
title: Configure view
favorite_caption: >
Make this view a favorite to add it on the top section of the sidebar
menu.
favorite_caption: >-
Make this view a favorite to add it on the top section of the sidebar menu.
label_new_resource_planner: New resource planner
label_resource_planner: Resource planner
label_resource_planner_plural: Resource planners
new_view_dialog:
title: Add View
public_caption: >
Make this view public to all members of the project. This does not affect
the visibility of work packages which still depends on each user
permission.
public_caption: >-
Make this view public to all members of the project. This does not affect the visibility of work packages which still
depends on each user permission.
show:
placeholder: The detailed view for this resource planner is coming soon.
sidebar:
@@ -105,13 +120,11 @@ en:
view_types:
resource_work_package_list:
caption: >-
Create a view based on work packages and see its details and
allocation in a list
Create a view based on work packages and see its details and allocation in a list
label: Work packages list
user_card:
caption: >-
Create a view based on users and see their details and allocation in a
list of user cards
Create a view based on users and see their details and allocation in a list of user cards
label: Users card list
work_package_list:
add_work_package_dialog:
+8 -2
View File
@@ -47,8 +47,6 @@ Rails.application.routes.draw do
controller: "resource_management/resource_planner_views",
only: %i[show new create edit update destroy] do
member do
# Search-and-pick dialog for manually hand-picked views, and the
# endpoints that add/remove a work package to/from the query.
get :new_work_package
post :work_packages, action: :add_work_package
put "work_packages/:work_package_id/move", action: :move_work_package, as: :move_work_package
@@ -62,5 +60,13 @@ Rails.application.routes.draw do
get "menu" => "resource_management/menus#show"
end
end
resources :resource_allocations,
controller: "resource_management/resource_allocations",
only: %i[new create edit update destroy] do
collection do
get :step
end
end
end
end
@@ -1,5 +1,32 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
class CreateResourceAllocations < ActiveRecord::Migration[8.1]
def change
create_table :resource_allocations do |t|
@@ -0,0 +1,37 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
class AddUsersToResourceAllocations < ActiveRecord::Migration[8.1]
def change
change_table :resource_allocations, bulk: true do |t|
t.references :requested_by, foreign_key: { to_table: :users }, null: true
t.references :reviewed_by, foreign_key: { to_table: :users }, null: true
end
end
end
@@ -0,0 +1,44 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
class CreateResourceAllocationJournals < ActiveRecord::Migration[8.1]
def change
create_table :resource_allocation_journals do |t|
t.references :entity, polymorphic: true
t.references :principal, foreign_key: { to_table: :users }
t.jsonb :user_filter, default: []
t.string :state
t.date :start_date
t.date :end_date
t.integer :allocated_time
t.references :requested_by, foreign_key: { to_table: :users }
t.references :reviewed_by, foreign_key: { to_table: :users }
end
end
end
@@ -0,0 +1,35 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
class AddFilterNameToResourceAllocations < ActiveRecord::Migration[8.1]
def change
add_column :resource_allocations, :filter_name, :string, null: true
add_column :resource_allocation_journals, :filter_name, :string, null: true
end
end
@@ -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
@@ -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.
#++
# `allocated_time` is journaled in minutes; render it as a duration in hours.
class OpenProject::JournalFormatter::AllocatedTime < JournalFormatter::Attribute
private
def format_values(values)
values.map do |minutes|
next if minutes.nil?
DurationConverter.output(minutes.to_f / 60)
end
end
end
@@ -40,7 +40,7 @@ module OpenProject::ResourceManagement
OpenProject::FeatureDecisions.add :resource_management, allow_enabling: Rails.env.local?
end
replace_principal_references "ResourceAllocation" => :principal_id
replace_principal_references "ResourceAllocation" => %i[principal_id requested_by_id reviewed_by_id]
register "openproject-resource_management",
author_url: "https://www.openproject.org",
@@ -64,20 +64,17 @@ module OpenProject::ResourceManagement
},
permissible_on: :project
# `manage_public_resource_planners` adds the publish-flip action. The
# contract additionally requires the planner itself to be public.
# Beyond this permission, the contract additionally requires the planner
# itself to be public.
permission :manage_public_resource_planners,
{ "resource_management/resource_planners": %i[toggle_public] },
permissible_on: :project,
dependencies: %i[view_resource_planners]
# `allocate_user_resources` gates create/update/delete on
# ResourceAllocation records. No controller actions yet — the
# ResourceAllocations::*Contract classes consume this directly via
# `allowed_in_project?`. The `contract_actions` map keeps the
# permission discoverable for API contracts.
# The `contract_actions` map keeps the permission discoverable for the
# API contracts that consume it via `allowed_in_project?`.
permission :allocate_user_resources,
{},
{ "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] }
@@ -34,7 +34,15 @@ RSpec.describe ResourcePlannerViews::WorkPackageList::SubHeaderComponent, type:
include Rails.application.routes.url_helpers
shared_let(:project) { create(:project, enabled_module_names: %w[resource_management work_package_tracking]) }
shared_let(:user) { create(:user, member_with_permissions: { project => %i[view_resource_planners view_work_packages] }) }
shared_let(:user) do
create(:user,
member_with_permissions: {
project => %i[view_resource_planners view_work_packages allocate_user_resources]
})
end
shared_let(:viewer) do
create(:user, member_with_permissions: { project => %i[view_resource_planners view_work_packages] })
end
shared_let(:resource_planner) { create(:resource_planner, project:, principal: user) }
let(:i18n_ns) { "resource_management.work_package_list.subheader" }
@@ -68,6 +76,14 @@ RSpec.describe ResourcePlannerViews::WorkPackageList::SubHeaderComponent, type:
expect(rendered).to have_text(I18n.t("#{i18n_ns}.allocate"))
expect(rendered).to have_no_text(I18n.t("#{i18n_ns}.add_work_package"))
end
context "without permission to allocate" do
before { login_as(viewer) }
it "hides the allocate button" do
expect(rendered).to have_no_text(I18n.t("#{i18n_ns}.allocate"))
end
end
end
context "with a manually hand-picked view" do
@@ -90,5 +106,14 @@ RSpec.describe ResourcePlannerViews::WorkPackageList::SubHeaderComponent, type:
href: new_work_package_project_resource_planner_view_path(project, resource_planner, view)
)
end
context "without permission to allocate" do
before { login_as(viewer) }
it "hides the allocate option but keeps add-work-package" do
expect(rendered).to have_no_text(I18n.t("#{i18n_ns}.allocate"))
expect(rendered).to have_text(I18n.t("#{i18n_ns}.add_work_package"))
end
end
end
end
@@ -43,8 +43,8 @@ RSpec.describe ResourceAllocations::CreateContract do
let(:current_user) do
create(:user, member_with_permissions: { project => %i[view_resource_planners allocate_user_resources] })
end
let(:planner) { create(:resource_planner, project:, principal: current_user) }
let(:resource_allocation) { build_stubbed(:resource_allocation, entity: planner, principal: current_user) }
let(:work_package) { create(:work_package, project:) }
let(:resource_allocation) { build_stubbed(:resource_allocation, entity: work_package, principal: current_user) }
let(:contract) { described_class.new(resource_allocation, current_user) }
it "allows entity to be set" do
@@ -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
@@ -36,9 +36,9 @@ RSpec.describe ResourceAllocations::DeleteContract do
shared_let(:project) { create(:project, enabled_module_names: %w[resource_management]) }
shared_let(:owner) { create(:user) }
shared_let(:planner) { create(:resource_planner, project:, principal: owner) }
shared_let(:work_package) { create(:work_package, project:) }
let(:resource_allocation) { build_stubbed(:resource_allocation, entity: planner, principal: owner) }
let(:resource_allocation) { build_stubbed(:resource_allocation, entity: work_package, principal: owner) }
let(:contract) { described_class.new(resource_allocation, current_user) }
context "when user has allocate_user_resources" do
@@ -34,10 +34,10 @@ require "contracts/shared/model_contract_shared_context"
RSpec.shared_examples_for "resource allocation contract" do
shared_let(:project) { create(:project, enabled_module_names: %w[resource_management]) }
shared_let(:owner) { create(:user) }
shared_let(:planner) { create(:resource_planner, project:, principal: owner) }
shared_let(:work_package) { create(:work_package, project:) }
let(:resource_allocation) do
build_stubbed(:resource_allocation, entity: planner, principal: owner)
build_stubbed(:resource_allocation, entity: work_package, principal: owner)
end
context "when user has the allocate_user_resources permission" do
@@ -43,8 +43,8 @@ RSpec.describe ResourceAllocations::UpdateContract do
let(:current_user) do
create(:user, member_with_permissions: { project => %i[view_resource_planners allocate_user_resources] })
end
let(:planner) { create(:resource_planner, project:, principal: current_user) }
let(:resource_allocation) { build_stubbed(:resource_allocation, entity: planner, principal: current_user) }
let(:work_package) { create(:work_package, project:) }
let(:resource_allocation) { build_stubbed(:resource_allocation, entity: work_package, principal: current_user) }
let(:contract) { described_class.new(resource_allocation, current_user) }
it "does not allow entity to be set" do
@@ -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
@@ -30,18 +30,23 @@
FactoryBot.define do
factory :resource_allocation, class: "ResourceAllocation" do
entity factory: :resource_planner
entity factory: :work_package
principal factory: :user
state { "requested" }
requested_by factory: :user
reviewed_by { requested_by }
state { "allocated" }
start_date { Date.new(2026, 1, 5) }
end_date { Date.new(2026, 1, 9) }
allocated_time { 5 * 8 * 60 } # 5 days of 8 hours in minutes
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
job_title_custom_field do
UserCustomField.find_by(name: "Job title") ||
@@ -49,17 +54,28 @@ FactoryBot.define do
name: "Job title",
possible_values: ["Developer", "Designer", "Project Manager", "Product Manager"])
end
spoken_language_custom_field do
UserCustomField.find_by(name: "Spoken language") ||
create(:user_custom_field, :list,
name: "Spoken language",
multi_value: true,
possible_values: %w[German English French Spanish Italian Dutch Portuguese Polish])
end
end
# Build real UserQuery filter objects (not hashes): the serialization
# coder dumps via `filter.field`, so it only accepts filter instances.
# The filter matches developers who speak German or English ("DE-EN"),
# leaving the other languages as non-matching values to test against.
user_filter do
cf = job_title_custom_field
developer_option = cf.custom_options.find_by(value: "Developer")
[
{
"attribute" => cf.column_name,
"operator" => "=",
"values" => [developer_option.id.to_s]
}
]
job_title = job_title_custom_field
language = spoken_language_custom_field
developer_option = job_title.custom_options.find_by(value: "Developer")
language_options = language.custom_options.where(value: %w[German English])
query = UserQuery.new
query.where(job_title.column_name, "=", [developer_option.id.to_s])
query.where(language.column_name, "=", language_options.map { |option| option.id.to_s })
query.filters
end
end
end
@@ -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
@@ -0,0 +1,120 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "spec_helper"
RSpec.describe ResourceAllocation do
describe "journaling" do
shared_let(:project) { create(:project, enabled_module_names: %w[resource_management]) }
shared_let(:work_package) { create(:work_package, project:) }
shared_let(:user) { create(:user) }
current_user { user }
subject(:allocation) do
build(:resource_allocation, entity: work_package, principal: user, allocated_time: 2400)
end
it "uses the dedicated journal data class backed by its own table" do
expect(described_class.journal_class).to eq(Journal::ResourceAllocationJournal)
expect(Journal::ResourceAllocationJournal.table_name).to eq("resource_allocation_journals")
end
context "on creation" do
it "creates an initial journal capturing the data" do
allocation.save!
expect(allocation.journals.count).to eq(1)
data = allocation.last_journal.data
expect(data).to be_a(Journal::ResourceAllocationJournal)
expect(data.state).to eq(allocation.state)
expect(data.entity_type).to eq("WorkPackage")
expect(data.entity_id).to eq(work_package.id)
expect(data.principal_id).to eq(user.id)
expect(data.allocated_time).to eq(2400)
end
it "attributes the journal to the current user" do
allocation.save!
expect(allocation.last_journal.user).to eq(user)
end
end
context "when a journaled attribute changes outside the aggregation window",
with_settings: { journal_aggregation_time_minutes: 0 } do
before { allocation.save! }
it "records a new version with the diff" do
expect { allocation.update!(allocated_time: 999) }
.to change { allocation.journals.count }.from(1).to(2)
expect(allocation.last_journal.details).to include("allocated_time" => [2400, 999])
end
it "tracks filter_name changes" do
allocation.update!(principal_explicit: false, filter_name: "Full stack Developer (DE-EN)")
expect(allocation.last_journal.data.filter_name).to eq("Full stack Developer (DE-EN)")
expect(allocation.last_journal.details).to include("filter_name" => [nil, "Full stack Developer (DE-EN)"])
end
it "renders the allocated_time change in hours, not minutes" do
allocation.update!(allocated_time: 999)
rendered = allocation.last_journal.render_detail(
["allocated_time", allocation.last_journal.details["allocated_time"]], html: false
)
expect(rendered).to include("40h") # 2400 minutes
expect(rendered).not_to include("2400")
end
end
context "when nothing changes" do
before { allocation.save! }
it "does not create a new journal version" do
expect { allocation.save! }.not_to change { allocation.journals.count }
end
end
context "when the journaled user is deleted" do
before { allocation.save! }
it "rewrites the principal on the journal data to the deleted-user placeholder" do
deleted_user = create(:deleted_user)
Principals::DeleteJob.perform_now(user)
expect(allocation.last_journal.data.reload.principal_id).to eq(deleted_user.id)
end
end
end
end
@@ -32,18 +32,12 @@ require "spec_helper"
RSpec.describe ResourceAllocation do
describe "associations" do
it "belongs to a polymorphic entity" do
association = described_class.reflect_on_association(:entity)
expect(association.macro).to eq(:belongs_to)
expect(association.options[:polymorphic]).to be(true)
end
subject { described_class.new(principal_explicit: false) }
it "belongs to a principal (user), optional" do
association = described_class.reflect_on_association(:principal)
expect(association.macro).to eq(:belongs_to)
expect(association.options[:class_name]).to eq("User")
expect(association.options[:optional]).to be(true)
end
it { is_expected.to belong_to(:entity).required }
it { is_expected.to belong_to(:principal).class_name("User").inverse_of(:resource_allocations).optional }
it { is_expected.to belong_to(:requested_by).class_name("User").optional }
it { is_expected.to belong_to(:reviewed_by).class_name("User").optional }
end
describe "state enum" do
@@ -67,12 +61,240 @@ RSpec.describe ResourceAllocation do
end
end
describe "#allocated_hours" do
subject(:allocation) { described_class.new }
describe "reader" do
it "returns the persisted minutes as hours" do
allocation.allocated_time = 150
expect(allocation.allocated_hours).to eq(2.5)
end
it "is nil when allocated_time is unset" do
expect(allocation.allocated_hours).to be_nil
end
end
describe "writer" do
it "stores a numeric value of hours as minutes" do
allocation.allocated_hours = 8
expect(allocation.allocated_time).to eq(480)
end
it "parses a duration string via chronic duration" do
allocation.allocated_hours = "2h30m"
expect(allocation.allocated_time).to eq(150)
end
it "parses a decimal-hours string" do
allocation.allocated_hours = "2.5"
expect(allocation.allocated_time).to eq(150)
end
it "clears the value when given nil" do
allocation.allocated_time = 480
allocation.allocated_hours = nil
expect(allocation.allocated_time).to be_nil
end
it "falls back to nil for an unparseable string (so validation can reject it)" do
allocation.allocated_hours = "not a duration"
expect(allocation.allocated_time).to be_nil
end
end
end
describe "#user_assigned? / #filter_based? / #needs_principal_assignment?" do
let(:assignee) { build_stubbed(:user) }
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 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 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 "#schedule_violation, #entity_start_date, #entity_due_date" do
let(:work_package) { build_stubbed(:work_package, start_date: Date.new(2026, 1, 10), due_date: Date.new(2026, 1, 20)) }
def allocation_for(start_date:, end_date:, entity: work_package)
described_class.new(entity:, start_date:, end_date:)
end
it "exposes the entity's start and due dates" do
allocation = allocation_for(start_date: Date.new(2026, 1, 12), end_date: Date.new(2026, 1, 15))
expect(allocation.entity_start_date).to eq(Date.new(2026, 1, 10))
expect(allocation.entity_due_date).to eq(Date.new(2026, 1, 20))
end
context "when the allocation is fully within the entity's schedule" do
it "returns nil" do
expect(allocation_for(start_date: Date.new(2026, 1, 12), end_date: Date.new(2026, 1, 15)).schedule_violation)
.to be_nil
end
end
context "when the allocation exactly matches the entity's bounds" do
it "returns nil (bounds are inclusive)" do
expect(allocation_for(start_date: Date.new(2026, 1, 10), end_date: Date.new(2026, 1, 20)).schedule_violation)
.to be_nil
end
end
context "when the allocation starts before the entity's start date" do
it "returns :before_start" do
expect(allocation_for(start_date: Date.new(2026, 1, 5), end_date: Date.new(2026, 1, 15)).schedule_violation)
.to eq(:before_start)
end
end
context "when the allocation ends after the entity's due date" do
it "returns :after_finish" do
expect(allocation_for(start_date: Date.new(2026, 1, 12), end_date: Date.new(2026, 1, 25)).schedule_violation)
.to eq(:after_finish)
end
end
context "when the allocation spills out on both ends" do
it "returns :before_and_after" do
expect(allocation_for(start_date: Date.new(2026, 1, 5), end_date: Date.new(2026, 1, 25)).schedule_violation)
.to eq(:before_and_after)
end
end
context "when the entity has no start date" do
let(:work_package) { build_stubbed(:work_package, start_date: nil, due_date: Date.new(2026, 1, 20)) }
it "ignores the missing start bound and only flags the due date" do
expect(allocation_for(start_date: Date.new(2026, 1, 1), end_date: Date.new(2026, 1, 15)).schedule_violation)
.to be_nil
expect(allocation_for(start_date: Date.new(2026, 1, 1), end_date: Date.new(2026, 1, 25)).schedule_violation)
.to eq(:after_finish)
end
end
context "when the entity has no due date" do
let(:work_package) { build_stubbed(:work_package, start_date: Date.new(2026, 1, 10), due_date: nil) }
it "ignores the missing due bound and only flags the start date" do
expect(allocation_for(start_date: Date.new(2026, 1, 12), end_date: Date.new(2026, 1, 25)).schedule_violation)
.to be_nil
expect(allocation_for(start_date: Date.new(2026, 1, 5), end_date: Date.new(2026, 1, 25)).schedule_violation)
.to eq(:before_start)
end
end
context "when the entity has neither date" do
let(:work_package) { build_stubbed(:work_package, start_date: nil, due_date: nil) }
it "returns nil (nothing to compare against)" do
expect(allocation_for(start_date: Date.new(2026, 1, 5), end_date: Date.new(2026, 1, 25)).schedule_violation)
.to be_nil
end
end
context "when the allocation dates are blank" do
it "returns nil" do
expect(allocation_for(start_date: nil, end_date: nil).schedule_violation).to be_nil
end
end
context "without an entity" do
it "returns nil" do
allocation = described_class.new(start_date: Date.new(2026, 1, 5), end_date: Date.new(2026, 1, 25))
expect(allocation.entity_start_date).to be_nil
expect(allocation.schedule_violation).to be_nil
end
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
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 "returns only filter placeholders without a principal" do
expect(described_class.needs_principal_assignment).to contain_exactly(unassigned_placeholder)
end
end
describe "entity GlobalID handling" do
shared_let(:project) { create(:project) }
shared_let(:work_package) { create(:work_package, project:) }
subject(:allocation) { described_class.new }
describe "#entity_gid" do
it "returns the GlobalID string of the entity" do
allocation.entity = work_package
expect(allocation.entity_gid).to eq(work_package.to_gid.to_s)
end
it "is an empty string when no entity is set" do
expect(allocation.entity_gid).to eq("")
end
end
describe "#entity=" do
it "assigns a plain record directly" do
allocation.entity = work_package
expect(allocation.entity).to eq(work_package)
end
it "resolves a GlobalID string to the record" do
allocation.entity = work_package.to_gid.to_s
expect(allocation.entity).to eq(work_package)
end
it "round-trips an entity through entity_gid" do
allocation.entity = work_package.to_gid.to_s
expect(allocation.entity_gid).to eq(work_package.to_gid.to_s)
end
it "ignores a GlobalID of a type outside ALLOWED_ENTITY_TYPES" do
disallowed = create(:user)
allocation.entity = disallowed.to_gid.to_s
expect(allocation.entity).to be_nil
end
end
end
describe "validations" do
shared_let(:project) { create(:project, enabled_module_names: %w[resource_management]) }
shared_let(:owner) { create(:user, member_with_permissions: { project => %i[view_resource_planners] }) }
shared_let(:planner) { create(:resource_planner, project:, principal: owner) }
shared_let(:work_package) { create(:work_package, project:) }
let(:allocation) { build(:resource_allocation, entity: planner, principal: owner) }
let(:allocation) { build(:resource_allocation, entity: work_package, principal: owner) }
it "is valid with the factory defaults" do
expect(allocation).to be_valid
@@ -109,12 +331,38 @@ 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
describe "entity type" do
it "lists the supported entity types" do
expect(described_class::ALLOWED_ENTITY_TYPES).to eq(%w[WorkPackage])
end
it "is valid when the entity type is in the allowed list" do
allocation.entity = work_package
expect(allocation).to be_valid
end
it "is invalid when the entity type is outside the allowed list" do
allocation.entity = create(:resource_planner, project:, principal: owner)
expect(allocation).not_to be_valid
expect(allocation.errors.symbols_for(:entity_type)).to include(:inclusion)
end
end
describe "allocated_time numericality" do
it "is invalid when zero" do
allocation.allocated_time = 0
@@ -152,9 +400,8 @@ RSpec.describe ResourceAllocation do
allocation.end_date = Date.new(2026, 1, 1)
end
it "is invalid" do
expect(allocation).not_to be_valid
expect(allocation.errors.symbols_for(:end_date)).to include(:greater_than_start_date)
it "is valid (single-day allocation)" do
expect(allocation).to be_valid
end
end
@@ -170,12 +417,66 @@ RSpec.describe ResourceAllocation do
end
end
end
describe "allocation kind (principal_explicit)" do
let(:filter) do
UserQuery.new.filter_for(:name).tap do |f|
f.operator = "~"
f.values = ["alice"]
end
end
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
context "when filter-based (principal_explicit: false)" do
before do
allocation.principal_explicit = false
allocation.principal = nil
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 an unassigned placeholder with a name" do
allocation.filter_name = "Full stack Developer (DE-EN)"
expect(allocation).to be_valid
end
it "allows a real principal alongside a named filter (assigned placeholder)" do
allocation.principal = owner
allocation.filter_name = "Full stack Developer (DE-EN)"
allocation.user_filter = [filter]
expect(allocation).to be_valid
end
end
end
end
describe "user_filter serialization" do
shared_let(:project) { create(:project, enabled_module_names: %w[resource_management]) }
shared_let(:owner) { create(:user, member_with_permissions: { project => %i[view_resource_planners] }) }
shared_let(:planner) { create(:resource_planner, project:, principal: owner) }
shared_let(:work_package) { create(:work_package, project:) }
it "serializes filters using the same coder as UserQuery" do
coder = described_class.type_for_attribute(:user_filter).coder
@@ -191,7 +492,12 @@ RSpec.describe ResourceAllocation do
filter.operator = "~"
filter.values = ["alice"]
allocation = create(:resource_allocation, entity: planner, principal: owner, user_filter: [filter])
allocation = create(:resource_allocation,
entity: work_package,
principal_explicit: false,
principal: nil,
filter_name: "Alices",
user_filter: [filter])
reloaded = described_class.find(allocation.id)
expect(reloaded.user_filter.size).to eq(1)
@@ -201,8 +507,80 @@ RSpec.describe ResourceAllocation do
end
it "defaults to an empty array" do
allocation = create(:resource_allocation, entity: planner, principal: owner)
allocation = create(:resource_allocation, entity: work_package, principal: owner)
expect(allocation.reload.user_filter).to eq([])
end
it "round-trips the custom-field filters from the :with_user_filter trait" do
allocation = create(:resource_allocation, :with_user_filter, entity: work_package)
filters = allocation.reload.user_filter
expect(filters.size).to eq(2)
job_title = UserCustomField.find_by(name: "Job title")
language = UserCustomField.find_by(name: "Spoken language")
job_title_filter = filters.find { |f| f.name.to_s == job_title.column_name }
language_filter = filters.find { |f| f.name.to_s == language.column_name }
expect(job_title_filter.operator).to eq("=")
expect(job_title_filter.values).to eq(job_title.custom_options.where(value: "Developer").pluck(:id).map(&:to_s))
# "is (OR)" — matches users speaking German or English.
expect(language_filter.operator).to eq("=")
expect(language_filter.values)
.to match_array(language.custom_options.where(value: %w[German English]).pluck(:id).map(&:to_s))
end
end
describe "matching users with the :with_user_filter criteria" do
shared_let(:project) { create(:project, enabled_module_names: %w[resource_management]) }
shared_let(:work_package) { create(:work_package, project:) }
# Materializes the "Job title" and "Spoken language" custom fields and the
# Developer + (German OR English) filter.
shared_let(:allocation) { create(:resource_allocation, :with_user_filter, entity: work_package) }
shared_let(:job_title) { UserCustomField.find_by(name: "Job title") }
shared_let(:language) { UserCustomField.find_by(name: "Spoken language") }
def option_id(custom_field, value)
custom_field.custom_options.find_by(value:).id
end
def user_with(job_title_value, *languages)
create(:user).tap do |user|
user.custom_field_values = {
job_title.id => option_id(job_title, job_title_value),
language.id => languages.map { |spoken| option_id(language, spoken) }
}
user.save!(validate: false)
end
end
shared_let(:german_developer) { user_with("Developer", "German") }
shared_let(:english_developer) { user_with("Developer", "English") }
shared_let(:bilingual_developer) { user_with("Developer", "French", "English") }
shared_let(:french_developer) { user_with("Developer", "French") }
shared_let(:german_designer) { user_with("Designer", "German") }
describe "#candidate_query" do
# `UserQuery#results` is scoped to what the current user may see.
current_user { create(:admin) }
it "is a UserQuery carrying the stored filter criteria" do
query = allocation.candidate_query
expect(query).to be_a(UserQuery)
expect(query.filters.map { |f| f.name.to_s })
.to contain_exactly(job_title.column_name, language.column_name)
end
it "resolves to developers speaking German or English (is (OR)), and excludes the rest" do
results = allocation.candidate_query.results
expect(results).to include(german_developer, english_developer, bilingual_developer)
expect(results).not_to include(french_developer, german_designer)
end
end
end
end
@@ -0,0 +1,349 @@
# 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 a principal who is not a member of the project" do
shared_let(:non_member) { create(:user) }
subject(:perform) do
post project_resource_allocations_path(project),
params: {
allocation_kind: "principal",
resource_allocation: {
principal_id: non_member.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 "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
context "when the allocation dates fall outside the work package's dates" do
shared_let(:dated_work_package) do
create(:work_package, project:, start_date: Date.new(2026, 1, 15), due_date: Date.new(2026, 2, 20))
end
let(:base_params) do
{
allocation_kind: "principal",
resource_allocation: {
principal_id: assignee.id,
entity_type: "WorkPackage",
entity_id: dated_work_package.id,
start_date: "2026-02-24", # after the work package's finish date
end_date: "2026-02-25",
allocated_hours: "40h"
}
}
end
it "does not create yet and renders the confirmation step" do
expect do
post project_resource_allocations_path(project), params: base_params, as: :turbo_stream
end.not_to change(ResourceAllocation, :count)
expect(response).to have_http_status(:ok)
expect(response.body).to include("outside of the work")
expect(response.body).to include('name="confirmed"')
end
it "creates the allocation once confirmed" do
expect do
post project_resource_allocations_path(project),
params: base_params.merge(confirmed: "1"),
as: :turbo_stream
end.to change(ResourceAllocation, :count).by(1)
expect(ResourceAllocation.last.entity).to eq(dated_work_package)
end
it "returns to the editable step without creating when going back" do
expect do
post project_resource_allocations_path(project),
params: base_params.merge(back: "1"),
as: :turbo_stream
end.not_to change(ResourceAllocation, :count)
expect(response).to have_http_status(:ok)
# The editable step re-renders the user autocompleter.
expect(response.body).to include("opce-user-autocompleter")
end
end
context "when the allocation dates fall within the work package's dates" do
shared_let(:dated_work_package) do
create(:work_package, project:, start_date: Date.new(2026, 1, 15), due_date: Date.new(2026, 2, 20))
end
it "creates the allocation directly without confirmation" do
expect do
post project_resource_allocations_path(project),
params: {
allocation_kind: "principal",
resource_allocation: {
principal_id: assignee.id,
entity_type: "WorkPackage",
entity_id: dated_work_package.id,
start_date: "2026-01-20",
end_date: "2026-01-21",
allocated_hours: "40h"
}
},
as: :turbo_stream
end.to change(ResourceAllocation, :count).by(1)
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
it "denies creating an allocation" do
expect 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.not_to change(ResourceAllocation, :count)
expect(response).to have_http_status(:forbidden)
end
end
end
@@ -143,16 +143,13 @@ RSpec.describe "ResourcePlannerViews requests",
it "closes the dialog and replaces the tab nav and content in place" do
perform
# Dialog is closed via a CSS selector target (not a bare id).
expect(response.body).to include('action="closeDialog"')
expect(response.body).to include('target="#edit-resource-planner-view-dialog"')
# Tab nav and the view content are replaced rather than redirecting.
expect(response.body).to include('action="replace"')
expect(response.body).to include('target="resource-planners-sub-views-component"')
expect(response.body).to include('target="resource-planner-views-content-component"')
# The replaced tab nav reflects the new name.
expect(response.body).to include("Renamed view")
end
@@ -39,7 +39,9 @@ RSpec.describe Principals::DeleteJob, "ResourceAllocation", type: :model do
context "with a resource allocation assigned to the principal" do
let!(:allocation) { create(:resource_allocation, principal:) }
let!(:other_allocation) { create(:resource_allocation, principal: create(:user)) }
let!(:unassigned_allocation) { create(:resource_allocation, principal: nil) }
let!(:unassigned_allocation) do
create(:resource_allocation, principal: nil, principal_explicit: false, filter_name: "Devs")
end
it "rewrites the principal to the deleted user placeholder" do
job
@@ -55,4 +57,21 @@ RSpec.describe Principals::DeleteJob, "ResourceAllocation", type: :model do
expect { job }.not_to change { unassigned_allocation.reload.principal_id }
end
end
context "with a resource allocation requested or reviewed by the principal" do
let!(:requested_allocation) { create(:resource_allocation, requested_by: principal) }
let!(:reviewed_allocation) { create(:resource_allocation, reviewed_by: principal) }
it "rewrites requested_by to the deleted user placeholder" do
job
expect(requested_allocation.reload.requested_by).to eq deleted_user
end
it "rewrites reviewed_by to the deleted user placeholder" do
job
expect(reviewed_allocation.reload.reviewed_by).to eq deleted_user
end
end
end
@@ -35,12 +35,12 @@ RSpec.describe ResourceAllocations::CreateService, type: :model do
shared_let(:owner) do
create(:user, member_with_permissions: { project => %i[view_resource_planners allocate_user_resources] })
end
shared_let(:planner) { create(:resource_planner, project:, principal: owner) }
shared_let(:work_package) { create(:work_package, project:) }
let(:assignee) { create(:user, member_with_permissions: { project => %i[view_resource_planners] }) }
let(:params) do
{
entity: planner,
entity: work_package,
principal: assignee,
start_date: Date.new(2026, 1, 1),
end_date: Date.new(2026, 1, 31),
@@ -53,18 +53,34 @@ RSpec.describe ResourceAllocations::CreateService, type: :model do
it "creates a resource allocation" do
result = service_call
expect(result).to be_success, "expected success but got: #{result.errors.full_messages}"
expect(result.result.entity).to eq(planner)
expect(result.result.entity).to eq(work_package)
expect(result.result.principal).to eq(assignee)
expect(result.result.allocated_time).to eq(8)
end
it "defaults the state to requested" do
expect(service_call.result.state).to eq("requested")
it "defaults the state to allocated" do
expect(service_call.result.state).to eq("allocated")
end
it "stamps requested_by and reviewed_by with the calling user" do
result = service_call
expect(result.result.requested_by).to eq(owner)
expect(result.result.reviewed_by).to eq(owner)
end
it "ignores requested_by and reviewed_by passed in params" do
other_user = create(:user)
result = described_class.new(user: owner).call(
params.merge(requested_by: other_user, reviewed_by: other_user)
)
expect(result).to be_success, "expected success but got: #{result.errors.full_messages}"
expect(result.result.requested_by).to eq(owner)
expect(result.result.reviewed_by).to eq(owner)
end
it "honors an explicitly-passed state" do
result = described_class.new(user: owner).call(params.merge(state: "allocated"))
expect(result.result.state).to eq("allocated")
result = described_class.new(user: owner).call(params.merge(state: "requested"))
expect(result.result.state).to eq("requested")
end
context "when allocated_time is zero" do
@@ -35,9 +35,9 @@ RSpec.describe ResourceAllocations::DeleteService, type: :model do
shared_let(:owner) do
create(:user, member_with_permissions: { project => %i[view_resource_planners allocate_user_resources] })
end
shared_let(:planner) { create(:resource_planner, project:, principal: owner) }
shared_let(:work_package) { create(:work_package, project:) }
let!(:resource_allocation) { create(:resource_allocation, entity: planner, principal: owner) }
let!(:resource_allocation) { create(:resource_allocation, entity: work_package, principal: owner) }
subject(:service_call) do
described_class.new(user:, model: resource_allocation).call
@@ -35,10 +35,10 @@ RSpec.describe ResourceAllocations::UpdateService, type: :model do
shared_let(:owner) do
create(:user, member_with_permissions: { project => %i[view_resource_planners allocate_user_resources] })
end
shared_let(:planner) { create(:resource_planner, project:, principal: owner) }
shared_let(:work_package) { create(:work_package, project:) }
let!(:resource_allocation) do
create(:resource_allocation, entity: planner, principal: owner, state: "requested", allocated_time: 8)
create(:resource_allocation, entity: work_package, principal: owner, state: "requested", allocated_time: 8)
end
subject(:service_call) do
@@ -52,13 +52,13 @@ RSpec.describe ResourceAllocations::UpdateService, type: :model do
end
context "when attempting to change the entity" do
let(:other_planner) { create(:resource_planner, project:, principal: owner) }
let(:other_work_package) { create(:work_package, project:) }
it "fails because entity is not writable" do
result = described_class.new(user: owner, model: resource_allocation).call(entity: other_planner)
result = described_class.new(user: owner, model: resource_allocation).call(entity: other_work_package)
expect(result).not_to be_success
expect(result.errors.symbols_for(:entity_id)).to include(:error_readonly)
expect(resource_allocation.reload.entity).to eq(planner)
expect(resource_allocation.reload.entity).to eq(work_package)
end
end