mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
Merge pull request #23512 from opf/resource-allocation-modals
Resource allocation modals
This commit is contained in:
@@ -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
|
||||
|
||||
+63
@@ -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
|
||||
+16
@@ -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
|
||||
%>
|
||||
+92
@@ -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
|
||||
+63
@@ -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
|
||||
+14
@@ -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
|
||||
%>
|
||||
+49
@@ -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
|
||||
+55
@@ -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
|
||||
%>
|
||||
+56
@@ -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
|
||||
+90
@@ -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
|
||||
+38
@@ -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
|
||||
%>
|
||||
+78
@@ -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
|
||||
+2
-4
@@ -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)
|
||||
|
||||
+6
-9
@@ -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
|
||||
|
||||
+3
-3
@@ -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(
|
||||
|
||||
+1
-1
@@ -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|
|
||||
|
||||
+2
-3
@@ -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,
|
||||
|
||||
+3
-3
@@ -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" }
|
||||
|
||||
+7
-17
@@ -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
|
||||
|
||||
+14
-6
@@ -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
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+3
-4
@@ -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
|
||||
|
||||
|
||||
+205
@@ -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
|
||||
+9
-21
@@ -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
|
||||
|
||||
+48
@@ -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
|
||||
+3
-4
@@ -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(
|
||||
|
||||
+2
-6
@@ -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
|
||||
|
||||
+5
-1
@@ -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?
|
||||
|
||||
|
||||
+4
-8
@@ -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:
|
||||
|
||||
@@ -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|
|
||||
|
||||
+37
@@ -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
|
||||
+44
@@ -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
|
||||
+35
@@ -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
|
||||
+42
@@ -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] }
|
||||
|
||||
+26
-1
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
+120
@@ -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
|
||||
|
||||
|
||||
+20
-1
@@ -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
|
||||
|
||||
+23
-7
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user