diff --git a/app/controllers/concerns/op_turbo/component_stream.rb b/app/controllers/concerns/op_turbo/component_stream.rb
index a7ff97a9868..aabecb1ce5e 100644
--- a/app/controllers/concerns/op_turbo/component_stream.rb
+++ b/app/controllers/concerns/op_turbo/component_stream.rb
@@ -158,6 +158,15 @@ module OpTurbo
turbo_streams << OpTurbo::StreamComponent.new(action: :reloadPage, target: nil).render_in(view_context)
end
+ # Dispatches a `CustomEvent` on `document` from a turbo stream, letting the
+ # server signal a client-side change without knowing which listeners (if
+ # any) react to it. `detail` is serialized and exposed as the event's detail.
+ def dispatch_event_via_turbo_stream(name, detail: {})
+ turbo_streams << OpTurbo::StreamComponent
+ .new(action: :dispatchEvent, target: nil, "event-name": name, detail: detail.to_json)
+ .render_in(view_context)
+ end
+
def turbo_streams
@turbo_streams ||= []
end
diff --git a/frontend/src/global_styles/openproject.sass b/frontend/src/global_styles/openproject.sass
index 2e2eeea80ab..e187dfd68aa 100644
--- a/frontend/src/global_styles/openproject.sass
+++ b/frontend/src/global_styles/openproject.sass
@@ -27,6 +27,7 @@
@import "../../../modules/grids/app/components/_index.sass"
@import "../../../modules/meeting/app/components/_index.sass"
@import "../../../modules/overviews/app/components/_index.sass"
+@import "../../../modules/resource_management/app/components/_index.sass"
@import "../../../modules/storages/app/components/_index.sass"
@import "../../../modules/wikis/app/components/_index.sass"
diff --git a/frontend/src/stimulus/controllers/reload-frame-on-event.controller.spec.ts b/frontend/src/stimulus/controllers/reload-frame-on-event.controller.spec.ts
new file mode 100644
index 00000000000..7d0c0973692
--- /dev/null
+++ b/frontend/src/stimulus/controllers/reload-frame-on-event.controller.spec.ts
@@ -0,0 +1,97 @@
+/*
+ * -- 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.
+ * ++
+ */
+
+import ReloadFrameOnEventController from './reload-frame-on-event.controller';
+import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers';
+
+interface ReloadableFrame extends HTMLElement {
+ src:string;
+ reload:() => void;
+}
+
+describe('ReloadFrameOnEventController', () => {
+ let ctx:StimulusTestContext;
+
+ // Each frame gets its own reload counter. The controller listens on
+ // `document`, so a counter per frame keeps a test from observing reloads
+ // triggered on another test's (or another frame's) controller.
+ const mountFrame = async ():Promise<{ frame:ReloadableFrame; calls:{ count:number } }> => {
+ await ctx.mount(`
+
+ content
+
+ `);
+
+ const frame = ctx.container.querySelector('#frame') as unknown as ReloadableFrame;
+ const calls = { count: 0 };
+ frame.reload = () => { calls.count += 1; };
+ return { frame, calls };
+ };
+
+ beforeEach(async () => {
+ ctx = await setupStimulusTest({
+ controllers: { 'reload-frame-on-event': ReloadFrameOnEventController },
+ });
+ });
+
+ afterEach(() => ctx.dispose());
+
+ it('points the frame at its url on the first event, then reloads on later events', async () => {
+ const { frame, calls } = await mountFrame();
+
+ document.dispatchEvent(new CustomEvent('resource-allocations:changed'));
+ expect(frame.src).toContain('/planner/view');
+ expect(calls.count).toBe(0);
+
+ document.dispatchEvent(new CustomEvent('resource-allocations:changed'));
+ expect(calls.count).toBe(1);
+ });
+
+ it('ignores unrelated events', async () => {
+ const { frame, calls } = await mountFrame();
+
+ document.dispatchEvent(new CustomEvent('some-other-event'));
+
+ expect(frame.src || '').not.toContain('/planner/view');
+ expect(calls.count).toBe(0);
+ });
+
+ it('stops reacting once disconnected', async () => {
+ const { frame, calls } = await mountFrame();
+ ctx.getController('reload-frame-on-event', frame).disconnect();
+
+ document.dispatchEvent(new CustomEvent('resource-allocations:changed'));
+
+ expect(calls.count).toBe(0);
+ });
+});
diff --git a/frontend/src/stimulus/controllers/reload-frame-on-event.controller.ts b/frontend/src/stimulus/controllers/reload-frame-on-event.controller.ts
new file mode 100644
index 00000000000..c2d113a657d
--- /dev/null
+++ b/frontend/src/stimulus/controllers/reload-frame-on-event.controller.ts
@@ -0,0 +1,59 @@
+/*
+ * -- 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.
+ * ++
+ */
+
+import { Controller } from '@hotwired/stimulus';
+import { FrameElement } from '@hotwired/turbo';
+
+// Reloads the turbo-frame this controller is attached to whenever the
+// configured document event fires. The frame starts without a `src` (so it is
+// not fetched on load); the first event points it at `urlValue`, later events
+// reload it. Pair the frame with `refresh="morph"` to reload without flicker.
+export default class ReloadFrameOnEventController extends Controller {
+ static values = { eventName: String, url: String };
+
+ declare eventNameValue:string;
+ declare urlValue:string;
+
+ private readonly listener = ():void => {
+ if (this.element.src) {
+ void this.element.reload();
+ } else {
+ this.element.src = this.urlValue;
+ }
+ };
+
+ connect():void {
+ document.addEventListener(this.eventNameValue, this.listener);
+ }
+
+ disconnect():void {
+ document.removeEventListener(this.eventNameValue, this.listener);
+ }
+}
diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts
index 303e1ab973d..c49da8911ed 100644
--- a/frontend/src/stimulus/setup.ts
+++ b/frontend/src/stimulus/setup.ts
@@ -19,6 +19,7 @@ import KeepScrollPositionController from './controllers/keep-scroll-position.con
import PatternInputController from './controllers/pattern-input.controller';
import HoverCardTriggerController from './controllers/hover-card-trigger.controller';
import ScrollIntoViewController from './controllers/scroll-into-view.controller';
+import ReloadFrameOnEventController from './controllers/reload-frame-on-event.controller';
import CkeditorFocusController from './controllers/ckeditor-focus.controller';
import IndexController from './controllers/dynamic/work-packages/activities-tab/index.controller';
import AutoScrollingController from './controllers/dynamic/work-packages/activities-tab/auto-scrolling.controller';
@@ -77,6 +78,7 @@ OpenProjectStimulusApplication.preregister('work-packages--date-picker--preview'
OpenProjectStimulusApplication.preregister('keep-scroll-position', KeepScrollPositionController);
OpenProjectStimulusApplication.preregister('pattern-input', PatternInputController);
OpenProjectStimulusApplication.preregister('scroll-into-view', ScrollIntoViewController);
+OpenProjectStimulusApplication.preregister('reload-frame-on-event', ReloadFrameOnEventController);
OpenProjectStimulusApplication.preregister('ckeditor-focus', CkeditorFocusController);
OpenProjectStimulusApplication.preregister('auto-submit', AutoSubmit);
OpenProjectStimulusApplication.preregister('reveal', RevealController);
diff --git a/frontend/src/turbo/dispatch-event-stream-action.ts b/frontend/src/turbo/dispatch-event-stream-action.ts
new file mode 100644
index 00000000000..3d90b110d58
--- /dev/null
+++ b/frontend/src/turbo/dispatch-event-stream-action.ts
@@ -0,0 +1,11 @@
+import { StreamActions, StreamElement } from '@hotwired/turbo';
+
+export function registerDispatchEventStreamAction() {
+ StreamActions.dispatchEvent = function dispatchEventStreamAction(this:StreamElement) {
+ const name = this.getAttribute('event-name');
+ if (!name) { return; }
+
+ const detail = JSON.parse(this.getAttribute('detail') ?? '{}') as unknown;
+ document.dispatchEvent(new CustomEvent(name, { detail }));
+ };
+}
diff --git a/frontend/src/turbo/setup.ts b/frontend/src/turbo/setup.ts
index c86857fda03..84e0363f876 100644
--- a/frontend/src/turbo/setup.ts
+++ b/frontend/src/turbo/setup.ts
@@ -5,6 +5,7 @@ import { addTurboEventListeners } from './turbo-event-listeners';
import { registerFlashStreamAction } from './flash-stream-action';
import { registerLiveRegionStreamAction } from './live-region-stream-action';
import { registerInputCaptionStreamAction } from './input-caption-stream-action';
+import { registerDispatchEventStreamAction } from './dispatch-event-stream-action';
import { addTurboGlobalListeners } from './turbo-global-listeners';
import { applyTurboNavigationPatch } from './turbo-navigation-patch';
import { debugLog, whenDebugging } from 'core-app/shared/helpers/debug_output';
@@ -43,6 +44,7 @@ registerDialogStreamAction();
registerFlashStreamAction();
registerLiveRegionStreamAction();
registerInputCaptionStreamAction();
+registerDispatchEventStreamAction();
addTurboAngularWrapper();
StreamActions.reloadPage = function reloadPage() {
diff --git a/modules/resource_management/app/components/_index.sass b/modules/resource_management/app/components/_index.sass
new file mode 100644
index 00000000000..6d1806c4ff1
--- /dev/null
+++ b/modules/resource_management/app/components/_index.sass
@@ -0,0 +1 @@
+@import "resource_planner_views/work_package_list/table_component"
diff --git a/modules/resource_management/app/components/resource_allocations/allocation_step/footer_component.rb b/modules/resource_management/app/components/resource_allocations/allocation_step/footer_component.rb
index e964d93f5dd..8e52f7f3ba1 100644
--- a/modules/resource_management/app/components/resource_allocations/allocation_step/footer_component.rb
+++ b/modules/resource_management/app/components/resource_allocations/allocation_step/footer_component.rb
@@ -34,6 +34,16 @@ module ResourceAllocations
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
+ # `dialog_id` and `submit_label` default to the create wizard's; the edit
+ # dialog passes its own.
+ def initialize(dialog_id: ResourceAllocations::NewDialogComponent::DIALOG_ID,
+ submit_label: I18n.t("resource_management.allocate_resource_dialog.submit"))
+ super
+
+ @dialog_id = dialog_id
+ @submit_label = submit_label
+ end
+
def wrapper_key
ResourceAllocations::NewDialogComponent::FOOTER_ID
end
@@ -43,7 +53,7 @@ module ResourceAllocations
component_collection do |buttons|
buttons.with_component(
Primer::Beta::Button.new(
- data: { "close-dialog-id": ResourceAllocations::NewDialogComponent::DIALOG_ID },
+ data: { "close-dialog-id": @dialog_id },
mr: 1
)
) { I18n.t(:button_cancel) }
@@ -54,7 +64,7 @@ module ResourceAllocations
form: ResourceAllocations::NewDialogComponent::FORM_ID,
type: :submit
)
- ) { I18n.t("resource_management.allocate_resource_dialog.submit") }
+ ) { @submit_label }
end
end
end
diff --git a/modules/resource_management/app/components/resource_allocations/allocation_step/form_component.html.erb b/modules/resource_management/app/components/resource_allocations/allocation_step/form_component.html.erb
index 39ab9a45490..962fd2f7db2 100644
--- a/modules/resource_management/app/components/resource_allocations/allocation_step/form_component.html.erb
+++ b/modules/resource_management/app/components/resource_allocations/allocation_step/form_component.html.erb
@@ -3,8 +3,8 @@
primer_form_with(
model: @allocation,
scope: :resource_allocation,
- url: project_resource_allocations_path(@project),
- method: :post,
+ url: form_url,
+ method: form_method,
html: {
data: {
turbo_stream: true,
diff --git a/modules/resource_management/app/components/resource_allocations/allocation_step/form_component.rb b/modules/resource_management/app/components/resource_allocations/allocation_step/form_component.rb
index 817af14a69f..a0bac5ec018 100644
--- a/modules/resource_management/app/components/resource_allocations/allocation_step/form_component.rb
+++ b/modules/resource_management/app/components/resource_allocations/allocation_step/form_component.rb
@@ -35,11 +35,16 @@ module ResourceAllocations
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
- def initialize(allocation:, project:, allocation_kind:)
+ # `dialog_id` names the dialog hosting the form (autocompleter dropdowns
+ # attach to it): the create wizard's by default, the edit dialog's when
+ # editing a persisted allocation.
+ def initialize(allocation:, project:, allocation_kind:,
+ dialog_id: ResourceAllocations::NewDialogComponent::DIALOG_ID)
super
@allocation = allocation
@project = project
@allocation_kind = allocation_kind
+ @dialog_id = dialog_id
end
def wrapper_key
@@ -48,12 +53,24 @@ module ResourceAllocations
private
+ attr_reader :dialog_id
+
def filter_based?
@allocation_kind.to_s == "filter"
end
- def dialog_id
- ResourceAllocations::NewDialogComponent::DIALOG_ID
+ # A persisted allocation submits an update to itself; a new one goes
+ # through the create flow (with its confirmation step).
+ def form_url
+ if @allocation.persisted?
+ project_resource_allocation_path(@project, @allocation)
+ else
+ project_resource_allocations_path(@project)
+ end
+ end
+
+ def form_method
+ @allocation.persisted? ? :patch : :post
end
def form_list_component(form)
diff --git a/modules/resource_management/app/components/resource_allocations/edit_dialog_component.html.erb b/modules/resource_management/app/components/resource_allocations/edit_dialog_component.html.erb
new file mode 100644
index 00000000000..7c964ed1efb
--- /dev/null
+++ b/modules/resource_management/app/components/resource_allocations/edit_dialog_component.html.erb
@@ -0,0 +1,62 @@
+<%#-- 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::AllocationStep::FormComponent.new(
+ allocation:,
+ project:,
+ allocation_kind:,
+ dialog_id: DIALOG_ID
+ )
+ )
+ end
+
+ dialog.with_footer do
+ render(
+ ResourceAllocations::AllocationStep::FooterComponent.new(
+ dialog_id: DIALOG_ID,
+ submit_label: I18n.t("resource_management.edit_allocation_dialog.submit")
+ )
+ )
+ end
+ end
+%>
diff --git a/modules/resource_management/app/components/resource_allocations/edit_dialog_component.rb b/modules/resource_management/app/components/resource_allocations/edit_dialog_component.rb
new file mode 100644
index 00000000000..79cc73a06d9
--- /dev/null
+++ b/modules/resource_management/app/components/resource_allocations/edit_dialog_component.rb
@@ -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
+ # A single-step dialog to edit a persisted allocation, reusing the create
+ # wizard's allocation form (which submits an update for persisted records).
+ class EditDialogComponent < ApplicationComponent
+ include OpTurbo::Streamable
+ include OpPrimer::ComponentHelpers
+
+ DIALOG_ID = "edit-resource-allocation-dialog"
+
+ def initialize(project:, allocation:)
+ super
+
+ @project = project
+ @allocation = allocation
+ end
+
+ private
+
+ attr_reader :project, :allocation
+
+ def allocation_kind
+ allocation.principal_explicit? ? "principal" : "filter"
+ end
+
+ def title
+ I18n.t("resource_management.edit_allocation_dialog.title")
+ end
+ end
+end
diff --git a/modules/resource_management/app/components/resource_allocations/list_component.html.erb b/modules/resource_management/app/components/resource_allocations/list_component.html.erb
new file mode 100644
index 00000000000..1d604c51067
--- /dev/null
+++ b/modules/resource_management/app/components/resource_allocations/list_component.html.erb
@@ -0,0 +1,53 @@
+<%#-- 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.
+
+++#%>
+
+<%= component_wrapper do %>
+ <%= flex_layout do |body| %>
+ <%= body.with_row(mb: 3) do %>
+ <%= render(ResourceAllocations::ProgressComponent.new(work_package:, allocations:)) %>
+ <% end %>
+ <% if allocations.any? %>
+ <%= body.with_row do %>
+ <%= render(Primer::Beta::BorderBox.new) do |box| %>
+ <% allocations.each do |allocation| %>
+ <% box.with_row do %>
+ <%= render ResourceAllocations::ListItemComponent.new(
+ allocation:,
+ project:,
+ visible: visible_principal?(allocation),
+ overbooked: overbooked?(allocation),
+ editable: editable?
+ ) %>
+ <% end %>
+ <% end %>
+ <% end %>
+ <% end %>
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/modules/resource_management/app/components/resource_allocations/list_component.rb b/modules/resource_management/app/components/resource_allocations/list_component.rb
new file mode 100644
index 00000000000..64e486600a1
--- /dev/null
+++ b/modules/resource_management/app/components/resource_allocations/list_component.rb
@@ -0,0 +1,68 @@
+# 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
+ # The body of the work package allocations dialog: the allocation progress
+ # summary and one row per allocation. Streamable so the dialog content can be
+ # refreshed after an allocation changes. Allocations whose principal is not
+ # visible to the current user are still listed, but anonymised.
+ class ListComponent < ApplicationComponent
+ include OpTurbo::Streamable
+ include OpPrimer::ComponentHelpers
+
+ def initialize(project:, work_package:, allocations:, visible_principal_ids:, overbooked_ids: Set.new)
+ super
+
+ @project = project
+ @work_package = work_package
+ @allocations = allocations
+ @visible_principal_ids = visible_principal_ids
+ @overbooked_ids = overbooked_ids
+ end
+
+ private
+
+ attr_reader :project, :work_package, :allocations, :visible_principal_ids, :overbooked_ids
+
+ def visible_principal?(allocation)
+ allocation.principal_id.nil? || visible_principal_ids.include?(allocation.principal_id)
+ end
+
+ def overbooked?(allocation)
+ overbooked_ids.include?(allocation.id)
+ end
+
+ def editable?
+ return @editable if defined?(@editable)
+
+ @editable = User.current.allowed_in_project?(:allocate_user_resources, project)
+ end
+ end
+end
diff --git a/modules/resource_management/app/components/resource_allocations/list_dialog_component.html.erb b/modules/resource_management/app/components/resource_allocations/list_dialog_component.html.erb
new file mode 100644
index 00000000000..28d463c6e78
--- /dev/null
+++ b/modules/resource_management/app/components/resource_allocations/list_dialog_component.html.erb
@@ -0,0 +1,70 @@
+<%#-- 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 do
+ render(ResourceAllocations::ListComponent.new(project:, work_package:, allocations:, visible_principal_ids:, overbooked_ids:))
+ end
+
+ dialog.with_footer do
+ # `mr: :auto` pushes the allocate action to the left of the footer's
+ # flex row, leaving the close button right-aligned.
+ concat(
+ render(
+ Primer::Beta::Button.new(
+ scheme: :link,
+ tag: :a,
+ href: allocate_resource_path,
+ mr: :auto,
+ data: { controller: "async-dialog" }
+ )
+ ) do |button|
+ button.with_leading_visual_icon(icon: :"person-add")
+ t("resource_management.work_package_allocations_dialog.allocate_resource")
+ end
+ )
+ concat(
+ render(Primer::Beta::Button.new(data: { "close-dialog-id": DIALOG_ID })) do
+ t(:button_close)
+ end
+ )
+ end
+ end
+%>
diff --git a/modules/resource_management/app/components/resource_allocations/list_dialog_component.rb b/modules/resource_management/app/components/resource_allocations/list_dialog_component.rb
new file mode 100644
index 00000000000..20d563ca899
--- /dev/null
+++ b/modules/resource_management/app/components/resource_allocations/list_dialog_component.rb
@@ -0,0 +1,62 @@
+# 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
+ # The dialog shell around a work package's allocation list, with a footer to
+ # allocate another resource.
+ class ListDialogComponent < ApplicationComponent
+ include OpTurbo::Streamable
+ include OpPrimer::ComponentHelpers
+
+ DIALOG_ID = "work-package-allocations-dialog"
+
+ def initialize(project:, work_package:, allocations:, visible_principal_ids:, overbooked_ids: Set.new)
+ super
+
+ @project = project
+ @work_package = work_package
+ @allocations = allocations
+ @visible_principal_ids = visible_principal_ids
+ @overbooked_ids = overbooked_ids
+ end
+
+ private
+
+ attr_reader :project, :work_package, :allocations, :visible_principal_ids, :overbooked_ids
+
+ def title
+ I18n.t("resource_management.work_package_allocations_dialog.title")
+ end
+
+ def allocate_resource_path
+ new_project_resource_allocation_path(project, work_package_id: work_package.id)
+ end
+ end
+end
diff --git a/modules/resource_management/app/components/resource_allocations/list_item_component.html.erb b/modules/resource_management/app/components/resource_allocations/list_item_component.html.erb
new file mode 100644
index 00000000000..b8afad2a68b
--- /dev/null
+++ b/modules/resource_management/app/components/resource_allocations/list_item_component.html.erb
@@ -0,0 +1,61 @@
+<%#-- 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.
+
+++#%>
+
+<%= flex_layout(align_items: :center) do |row| %>
+ <%= row.with_column(mr: 2) do %>
+ <%= render(leading_visual) %>
+ <% end %>
+ <%= row.with_column(flex: 1) do %>
+ <%= render(Primer::Beta::Text.new(font_size: :small)) { name } %>
+ <% end %>
+ <%= row.with_column do %>
+ <%= render(Primer::Beta::Label.new(size: :large)) { duration } %>
+ <% end %>
+ <% if overbooked? %>
+ <%= row.with_column(ml: 2, classes: "d-flex") do %>
+ <%= render Primer::Beta::Octicon.new(
+ icon: :"alert-fill",
+ color: :danger,
+ id: overbooked_icon_id,
+ "aria-label": overbooked_message
+ ) %>
+ <%= render Primer::Alpha::Tooltip.new(
+ for_id: overbooked_icon_id,
+ type: :description,
+ text: overbooked_message,
+ direction: :s
+ ) %>
+ <% end %>
+ <% end %>
+ <% if menu? %>
+ <%= row.with_column(ml: 2) do %>
+ <%= context_menu %>
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/modules/resource_management/app/components/resource_allocations/list_item_component.rb b/modules/resource_management/app/components/resource_allocations/list_item_component.rb
new file mode 100644
index 00000000000..d92288cf1fd
--- /dev/null
+++ b/modules/resource_management/app/components/resource_allocations/list_item_component.rb
@@ -0,0 +1,167 @@
+# 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
+ # A single allocation row in the work package allocations dialog: the member's
+ # avatar and name (or an anonymous placeholder when the principal is not
+ # visible to the current user) and the allocated hours.
+ class ListItemComponent < ApplicationComponent
+ include OpPrimer::ComponentHelpers
+ include AvatarHelper
+
+ AVATAR_SIZE = 24
+
+ # `editable` enables the row's edit/delete menu (the caller checks the
+ # permission). It stays hidden for an anonymised row regardless, since the
+ # edit form would reveal the hidden user.
+ def initialize(allocation:, project:, visible:, overbooked: false, editable: false)
+ super
+
+ @allocation = allocation
+ @project = project
+ @visible = visible
+ @overbooked = overbooked
+ @editable = editable
+ end
+
+ private
+
+ attr_reader :allocation, :project
+
+ def visible?
+ @visible
+ end
+
+ def overbooked?
+ @overbooked
+ end
+
+ def menu?
+ @editable && visible?
+ end
+
+ def context_menu
+ render(Primer::Alpha::ActionMenu.new(size: :small, anchor_align: :end)) do |menu|
+ menu.with_show_button(icon: "kebab-horizontal",
+ "aria-label": t("resource_management.work_package_allocations_dialog.context_menu_label"),
+ scheme: :invisible)
+
+ edit_item(menu)
+ delete_item(menu)
+ end
+ end
+
+ def edit_item(menu)
+ menu.with_item(
+ label: I18n.t(:button_edit),
+ tag: :a,
+ href: helpers.edit_project_resource_allocation_path(project, allocation),
+ content_arguments: { data: { controller: "async-dialog" } }
+ ) do |item|
+ item.with_leading_visual_icon(icon: :pencil)
+ end
+ end
+
+ def delete_item(menu)
+ menu.with_item(
+ label: I18n.t(:button_delete),
+ scheme: :danger,
+ href: helpers.project_resource_allocation_path(project, allocation),
+ form_arguments: {
+ method: :delete,
+ data: {
+ turbo_confirm: t("resource_management.work_package_allocations_dialog.delete_confirmation"),
+ turbo_stream: true
+ }
+ }
+ ) do |item|
+ item.with_leading_visual_icon(icon: :trash)
+ end
+ end
+
+ def name
+ if allocation.principal
+ visible? ? allocation.principal.name : hidden_label
+ else
+ allocation.filter_name.presence || unassigned_label
+ end
+ end
+
+ # An allocation without a user yet (filter placeholder or lost principal)
+ # shows a person-add icon instead of an avatar. Sized to match the avatar so
+ # the leading column keeps the same width and the row stays aligned.
+ def leading_visual
+ if allocation.principal
+ Primer::OpenProject::AvatarWithFallback.new(size: AVATAR_SIZE, **avatar_options)
+ else
+ Primer::Beta::Octicon.new(icon: :"person-add", size: :medium, color: :muted, "aria-hidden": true)
+ end
+ end
+
+ # Only a visible principal exposes a real avatar (and an identity-revealing
+ # initials/colour seed). A hidden one falls back to a generated avatar keyed
+ # to the allocation, so the user cannot be correlated.
+ def avatar_options
+ if visible?
+ {
+ src: avatar_url(allocation.principal),
+ alt: allocation.principal.name,
+ unique_id: allocation.principal.id
+ }
+ else
+ {
+ alt: name,
+ unique_id: "resource-allocation-#{allocation.id}"
+ }
+ end
+ end
+
+ # Formatted per the instance's duration format setting, e.g. "2d 4h".
+ def duration
+ DurationConverter.output(allocation.allocated_hours)
+ end
+
+ def overbooked_icon_id
+ "resource-allocation-overbooked-#{allocation.id}"
+ end
+
+ def overbooked_message
+ t("resource_management.work_package_allocations_dialog.overbooked")
+ end
+
+ def hidden_label
+ t("resource_management.work_package_allocations_dialog.hidden_user")
+ end
+
+ def unassigned_label
+ t("resource_management.work_package_list.allocated_members.unassigned")
+ end
+ end
+end
diff --git a/modules/resource_management/app/components/resource_allocations/progress_component.html.erb b/modules/resource_management/app/components/resource_allocations/progress_component.html.erb
new file mode 100644
index 00000000000..2a7bfe38653
--- /dev/null
+++ b/modules/resource_management/app/components/resource_allocations/progress_component.html.erb
@@ -0,0 +1,61 @@
+<%#-- 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.
+
+++#%>
+
+<% if work_scheduled? %>
+ <%= flex_layout do |column| %>
+ <%= column.with_row(mb: 1) do %>
+ <%= flex_layout(justify_content: :space_between, align_items: :center) do |line| %>
+ <%= line.with_column do %>
+ <%= render(Primer::Beta::Text.new(font_size: :small)) { summary } %>
+ <% end %>
+ <%= line.with_column do %>
+ <%= render(Primer::Beta::Text.new(font_size: :small, color: :muted)) { percentage_label } %>
+ <% end %>
+ <% end %>
+ <% end %>
+ <%= column.with_row do %>
+ <%= render(Primer::Beta::ProgressBar.new) do |bar| %>
+ <% bar.with_item(percentage: bar_percentage, bg: bar_color, aria: { label: percentage_label }) %>
+ <% end %>
+ <% end %>
+ <% end %>
+<% else %>
+ <%= render Primer::Beta::Octicon.new(
+ icon: :"alert-fill",
+ color: :danger,
+ id: no_work_tooltip_id,
+ "aria-label": no_work_message
+ ) %>
+ <%= render Primer::Alpha::Tooltip.new(
+ for_id: no_work_tooltip_id,
+ type: :description,
+ text: no_work_message,
+ direction: :e
+ ) %>
+<% end %>
diff --git a/modules/resource_management/app/components/resource_allocations/progress_component.rb b/modules/resource_management/app/components/resource_allocations/progress_component.rb
new file mode 100644
index 00000000000..ed633a98c6c
--- /dev/null
+++ b/modules/resource_management/app/components/resource_allocations/progress_component.rb
@@ -0,0 +1,111 @@
+# 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
+ # Renders how much of a work package's scheduled work is covered by resource
+ # allocations: an "12h / 35h" label (allocated / scheduled), a percentage,
+ # and a colored bar. Shared by the work package list column and the
+ # allocations dialog.
+ class ProgressComponent < ApplicationComponent
+ include OpPrimer::ComponentHelpers
+
+ def initialize(work_package:, allocations:)
+ super
+
+ @work_package = work_package
+ @allocations = allocations
+ end
+
+ # Without scheduled work there is nothing to allocate against (and no
+ # sensible denominator), so fall back to the muted placeholder.
+ def work_scheduled?
+ scheduled_hours.positive?
+ end
+
+ private
+
+ attr_reader :work_package, :allocations
+
+ def allocated_hours
+ allocations.sum { |allocation| allocation.allocated_time.to_i } / 60.0
+ end
+
+ # Total work: the rolled-up value for a parent, falling back to the work
+ # package's own estimate for a leaf (where `derived_estimated_hours` is nil).
+ def scheduled_hours
+ (work_package.derived_estimated_hours || work_package.estimated_hours).to_f
+ end
+
+ # Share of the scheduled work covered by allocations. Capped at 100 for the
+ # bar width; the raw value still drives the label and over-allocation color.
+ def ratio
+ return 0 unless work_scheduled?
+
+ ((allocated_hours / scheduled_hours) * 100).round
+ end
+
+ def bar_percentage
+ ratio.clamp(0, 100)
+ end
+
+ def bar_color
+ if ratio > 100
+ :danger_emphasis
+ elsif ratio == 100
+ :success_emphasis
+ else
+ :accent_emphasis
+ end
+ end
+
+ def summary
+ t("resource_management.allocation.summary",
+ allocated: hours_label(allocated_hours),
+ scheduled: hours_label(scheduled_hours))
+ end
+
+ def percentage_label
+ helpers.number_to_percentage(ratio, precision: 0)
+ end
+
+ def hours_label(hours)
+ t("resource_management.allocation.hours",
+ value: helpers.number_with_precision(hours, precision: 1, strip_insignificant_zeros: true))
+ end
+
+ def no_work_message
+ t("resource_management.allocation.no_work")
+ end
+
+ def no_work_tooltip_id
+ "allocation-no-work-#{work_package.id}"
+ end
+ end
+end
diff --git a/modules/resource_management/app/components/resource_allocations/warning_step/footer_component.rb b/modules/resource_management/app/components/resource_allocations/warning_step/footer_component.rb
index f0ad6bb7069..b7ae1df705c 100644
--- a/modules/resource_management/app/components/resource_allocations/warning_step/footer_component.rb
+++ b/modules/resource_management/app/components/resource_allocations/warning_step/footer_component.rb
@@ -34,6 +34,14 @@ module ResourceAllocations
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
+ # `dialog_id` names the dialog the cancel button closes: the create
+ # wizard's by default, the edit dialog's when confirming an update.
+ def initialize(dialog_id: ResourceAllocations::NewDialogComponent::DIALOG_ID)
+ super
+
+ @dialog_id = dialog_id
+ end
+
def wrapper_key
ResourceAllocations::NewDialogComponent::FOOTER_ID
end
@@ -58,7 +66,7 @@ module ResourceAllocations
footer.with_column(mr: 1) do
render(
Primer::Beta::Button.new(
- data: { "close-dialog-id": ResourceAllocations::NewDialogComponent::DIALOG_ID }
+ data: { "close-dialog-id": @dialog_id }
)
) { I18n.t(:button_cancel) }
end
diff --git a/modules/resource_management/app/components/resource_allocations/warning_step/form_component.html.erb b/modules/resource_management/app/components/resource_allocations/warning_step/form_component.html.erb
index 2d9dace93f9..1b738ca3308 100644
--- a/modules/resource_management/app/components/resource_allocations/warning_step/form_component.html.erb
+++ b/modules/resource_management/app/components/resource_allocations/warning_step/form_component.html.erb
@@ -84,8 +84,8 @@
body.with_row(w: :full) do
form_with(
- url: project_resource_allocations_path(@project),
- method: :post,
+ url: form_url,
+ method: form_method,
id: ResourceAllocations::NewDialogComponent::FORM_ID,
data: { turbo_stream: true }
) do
diff --git a/modules/resource_management/app/components/resource_allocations/warning_step/form_component.rb b/modules/resource_management/app/components/resource_allocations/warning_step/form_component.rb
index c5761e47cf3..15235ba5e9f 100644
--- a/modules/resource_management/app/components/resource_allocations/warning_step/form_component.rb
+++ b/modules/resource_management/app/components/resource_allocations/warning_step/form_component.rb
@@ -59,6 +59,20 @@ module ResourceAllocations
private
+ # A confirmed resubmit goes back to where the values came from: the
+ # update of a persisted allocation or the create flow for a new one.
+ def form_url
+ if @allocation.persisted?
+ project_resource_allocation_path(@project, @allocation)
+ else
+ project_resource_allocations_path(@project)
+ end
+ end
+
+ def form_method
+ @allocation.persisted? ? :patch : :post
+ end
+
def overbooking_heading
t("resource_management.allocate_resource_dialog.overbooking.title")
end
diff --git a/modules/resource_management/app/components/resource_planner_views/content_component.rb b/modules/resource_management/app/components/resource_planner_views/content_component.rb
index f770036c471..8d645e01531 100644
--- a/modules/resource_management/app/components/resource_planner_views/content_component.rb
+++ b/modules/resource_management/app/components/resource_planner_views/content_component.rb
@@ -32,12 +32,15 @@ module ResourcePlannerViews
class ContentComponent < ApplicationComponent
include OpTurbo::Streamable
- def initialize(view:, project:, resource_planner:)
+ def initialize(view:, project:, resource_planner:, work_packages: [], allocations: {}, visible_principal_ids: nil)
super
@view = view
@project = project
@resource_planner = resource_planner
+ @work_packages = work_packages
+ @allocations = allocations
+ @visible_principal_ids = visible_principal_ids
end
private
@@ -48,7 +51,10 @@ module ResourcePlannerViews
ResourcePlannerViews::WorkPackageList::ContentComponent.new(
view: @view,
project: @project,
- resource_planner: @resource_planner
+ resource_planner: @resource_planner,
+ work_packages: @work_packages,
+ allocations: @allocations,
+ visible_principal_ids: @visible_principal_ids
)
end
end
diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.html.erb b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.html.erb
new file mode 100644
index 00000000000..f3a08bf7c9d
--- /dev/null
+++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.html.erb
@@ -0,0 +1,50 @@
+<%#-- 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.
+
+++#%>
+
+<% if named? %>
+ <%= flex_layout(align_items: :center) do |container| %>
+ <%= container.with_column(flex_shrink: 0) do %>
+ <%= render(Primer::OpenProject::AvatarStack.new(tooltipped: true, body_arguments: { label: tooltip_label })) do |stack| %>
+ <% avatar_options.each do |options| %>
+ <% stack.with_avatar_with_fallback(**options) %>
+ <% end %>
+ <% end %>
+ <% end %>
+ <%= container.with_column(ml: 2, classes: "min-width-0") do %>
+ <%= render(Primer::Beta::Truncate.new) { lead_name } %>
+ <% end %>
+ <% if additional? %>
+ <%= container.with_column(ml: 1, flex_shrink: 0) do %>
+ <%= render(Primer::Beta::Label.new(scheme: :secondary, inline: true)) { additional_label } %>
+ <% end %>
+ <% end %>
+ <% end %>
+<% else %>
+ <%= render(Primer::Beta::Text.new(color: :muted)) { anonymous_label } %>
+<% end %>
diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.rb b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.rb
new file mode 100644
index 00000000000..abf9f26f44b
--- /dev/null
+++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/allocated_members_component.rb
@@ -0,0 +1,137 @@
+# 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 ResourcePlannerViews::WorkPackageList
+ # Renders the members allocated to a work package as an avatar stack (with the
+ # stack's built-in "+N" overflow). An allocation with an assigned principal
+ # shows that user's avatar; a filter-based allocation with no principal yet
+ # shows a generated avatar derived from its filter name.
+ class AllocatedMembersComponent < ApplicationComponent
+ include OpPrimer::ComponentHelpers
+ include AvatarHelper
+
+ AVATAR_SIZE = 20
+
+ # `visible_principal_ids` are the principals the current user may see. Members
+ # whose principal is not in that set are still counted, but never named or
+ # given a (potentially identity-revealing) avatar. A nil set means no
+ # restriction.
+ def initialize(allocations:, visible_principal_ids: nil)
+ super
+
+ @allocations = allocations
+ @visible_principal_ids = visible_principal_ids
+ end
+
+ def render?
+ allocations.any?
+ end
+
+ # Whether at least one member can be named — a visible user or a filter
+ # placeholder. When false, every member is hidden and only a count is shown.
+ def named?
+ identifiable.any?
+ end
+
+ private
+
+ attr_reader :allocations, :visible_principal_ids
+
+ # Members we may reveal: filter placeholders (no user) and allocations whose
+ # principal is visible to the current user.
+ def identifiable
+ @identifiable ||= allocations.select do |allocation|
+ allocation.principal_id.nil? ||
+ visible_principal_ids.nil? ||
+ visible_principal_ids.include?(allocation.principal_id)
+ end
+ end
+
+ def avatar_options
+ identifiable.map { |allocation| avatar_options_for(allocation) }
+ end
+
+ # The name shown beside the stack. The stack's own overflow indicator is not
+ # numeric, so the count of the remaining members is spelled out separately.
+ def lead_name
+ member_name(identifiable.first)
+ end
+
+ # Members beyond the named lead, including those hidden from this user, so
+ # hidden allocations are surfaced as a count rather than silently dropped.
+ def additional_count
+ allocations.size - 1
+ end
+
+ def additional?
+ additional_count.positive?
+ end
+
+ def additional_label
+ t("resource_management.work_package_list.allocated_members.additional", count: additional_count)
+ end
+
+ # Shown when none of the members may be named: a bare count of the members.
+ def anonymous_label
+ t("resource_management.work_package_list.allocated_members.other_users", count: allocations.size)
+ end
+
+ # A real principal resolves to their avatar (falling back to generated
+ # initials when they have no image); otherwise the fallback generates a
+ # deterministic avatar from the member name.
+ def avatar_options_for(allocation)
+ user = allocation.principal
+
+ {
+ src: (avatar_url(user) if user),
+ alt: member_name(allocation),
+ unique_id: user&.id || "resource-allocation-#{allocation.id}",
+ size: AVATAR_SIZE
+ }
+ end
+
+ # Shown in the stack's hover tooltip, since the names are not rendered inline.
+ # Only names members we may reveal.
+ def tooltip_label
+ identifiable.map { |allocation| member_name(allocation) }.join(", ")
+ end
+
+ # The assigned user's name, the filter name for an unassigned filter
+ # allocation, or a generic label for an allocation that lost its principal
+ # (e.g. the assigned user was deleted) — so the avatar always has a label.
+ def member_name(allocation)
+ allocation.principal&.name.presence || allocation.filter_name.presence || unassigned_label
+ end
+
+ def unassigned_label
+ t("resource_management.work_package_list.allocated_members.unassigned")
+ end
+ end
+end
diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.html.erb b/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.html.erb
index 57e448c2ae3..47672e7c80e 100644
--- a/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.html.erb
+++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.html.erb
@@ -39,7 +39,9 @@ See COPYRIGHT and LICENSE files for more details.
rows: work_packages,
view: @view,
project: @project,
- resource_planner: @resource_planner
+ resource_planner: @resource_planner,
+ allocations: @allocations,
+ visible_principal_ids: @visible_principal_ids
)
%>
<% if @view.manually_picked? %>
diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.rb b/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.rb
index e582a72c6fb..b5ca7009642 100644
--- a/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.rb
+++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/content_component.rb
@@ -30,18 +30,19 @@
module ResourcePlannerViews::WorkPackageList
class ContentComponent < ApplicationComponent
- def initialize(view:, project:, resource_planner:)
+ def initialize(view:, project:, resource_planner:, work_packages: [], allocations: {}, visible_principal_ids: nil)
super
@view = view
@project = project
@resource_planner = resource_planner
+ @work_packages = work_packages
+ @allocations = allocations
+ @visible_principal_ids = visible_principal_ids
end
private
- def work_packages
- @view.effective_query&.results&.work_packages || WorkPackage.none
- end
+ attr_reader :work_packages, :allocations, :visible_principal_ids
end
end
diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/row_component.rb b/modules/resource_management/app/components/resource_planner_views/work_package_list/row_component.rb
index 8bcd81e4607..35ec40f36e3 100644
--- a/modules/resource_management/app/components/resource_planner_views/work_package_list/row_component.rb
+++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/row_component.rb
@@ -99,11 +99,13 @@ module ResourcePlannerViews::WorkPackageList
end
def allocation
- render(Primer::Beta::Text.new(color: :muted)) { allocation_placeholder }
+ render(ResourceAllocations::ProgressComponent.new(work_package:, allocations:))
end
def allocated_members
- render(Primer::Beta::Text.new(color: :muted)) { allocation_placeholder }
+ return render(Primer::Beta::Text.new(color: :muted)) { allocation_placeholder } if allocations.empty?
+
+ render(AllocatedMembersComponent.new(allocations:, visible_principal_ids: table.visible_principal_ids))
end
def button_links
@@ -112,6 +114,10 @@ module ResourcePlannerViews::WorkPackageList
private
+ def allocations
+ @allocations ||= table.allocations_for(work_package)
+ end
+
def allocation_placeholder
I18n.t("resource_management.work_package_list.allocation_placeholder")
end
@@ -136,8 +142,12 @@ module ResourcePlannerViews::WorkPackageList
end
def see_allocation_item(menu)
- menu.with_item(label: t("resource_management.work_package_list.context_menu.see_allocation"),
- disabled: true) do |item|
+ menu.with_item(
+ label: t("resource_management.work_package_list.context_menu.see_allocation"),
+ tag: :a,
+ href: helpers.project_work_package_resource_allocations_path(table.project, work_package),
+ content_arguments: { data: { controller: "async-dialog" } }
+ ) do |item|
item.with_leading_visual_icon(icon: :hourglass)
end
end
diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.rb b/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.rb
index 73f8f8ba042..3b96ff7db13 100644
--- a/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.rb
+++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.rb
@@ -34,18 +34,26 @@ module ResourcePlannerViews::WorkPackageList
mobile_columns :subject, :priority
- attr_reader :view, :project, :resource_planner
+ attr_reader :view, :project, :resource_planner, :visible_principal_ids
- def initialize(view:, project:, resource_planner:, **)
+ def initialize(view:, project:, resource_planner:, allocations: {}, visible_principal_ids: nil, **)
super(**)
@view = view
@project = project
@resource_planner = resource_planner
+ @allocations = allocations
+ @visible_principal_ids = visible_principal_ids
end
def manual? = view.manually_picked?
+ # The allocations of a work package, taken from the page-wide map the
+ # controller loaded; shared by the allocation progress and members columns.
+ def allocations_for(work_package)
+ @allocations[work_package.id] || []
+ end
+
main_column :subject
def sortable? = false
@@ -54,6 +62,10 @@ module ResourcePlannerViews::WorkPackageList
def has_actions? = true
+ # Scopes this table's styling (see table_component.sass) without touching the
+ # shared border-box grid defaults used by every other table.
+ def container_class = "op-resource-work-package-list"
+
def mobile_title
I18n.t("resource_management.work_package_list.mobile_title")
end
diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.sass b/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.sass
new file mode 100644
index 00000000000..86f430504b4
--- /dev/null
+++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/table_component.sass
@@ -0,0 +1,27 @@
+@import "helpers"
+
+// The shared border-box grid top-aligns row content and distributes columns
+// evenly. The resource planner work package list has a fixed, known set of
+// columns, so we center the content and hardcode the column widths from the
+// design. Scoped via the table's container_class so no other table is affected.
+@media screen and (min-width: $breakpoint-md)
+ .op-resource-work-package-list
+ .op-border-box-grid
+ align-items: center
+ // Column widths as fractions of the 1200px design (376/80/200/200/200),
+ // so they scale with the available width. Only the action button is fixed.
+ // The header and body are independent grids; sharing one template keeps
+ // their tracks aligned. minmax(0, …) lets a track shrink below its content.
+ grid-template-columns: minmax(0, 376fr) minmax(0, 80fr) minmax(0, 200fr) minmax(0, 200fr) minmax(0, 200fr) 40px
+
+ // Subject keeps its main-column semantics (row header role, no truncation)
+ // but must occupy a single track in the explicit template above rather than
+ // spanning two as the shared grid would have it.
+ .op-border-box-grid__header--main-column,
+ .op-border-box-grid__row-item--main-column
+ grid-column: auto
+
+ // Content is centered here, so the top-alignment nudge the shared component
+ // applies to the action button is not wanted.
+ .op-border-box-grid__row-action
+ margin-top: 0
diff --git a/modules/resource_management/app/controllers/concerns/resource_management/planner_view_content.rb b/modules/resource_management/app/controllers/concerns/resource_management/planner_view_content.rb
new file mode 100644
index 00000000000..a5951ba79d0
--- /dev/null
+++ b/modules/resource_management/app/controllers/concerns/resource_management/planner_view_content.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module ResourceManagement
+ # Builds the content component for a resource planner view. Loads the view's
+ # work packages and their allocations in one place so the allocation columns
+ # (progress bar and members) share a single query rather than each issuing
+ # their own. Requires @project and @resource_planner to be set.
+ module PlannerViewContent
+ def work_package_list_content(view)
+ work_packages = view.is_a?(ResourceWorkPackageList) ? view.work_packages.to_a : []
+ allocations = ResourceAllocation.allocated_for_work_packages(work_packages)
+
+ ResourcePlannerViews::ContentComponent.new(
+ view:,
+ project: @project,
+ resource_planner: @resource_planner,
+ work_packages:,
+ allocations:,
+ visible_principal_ids: ResourceAllocation.visible_principal_ids(allocations.values.flatten, current_user)
+ )
+ end
+ end
+end
diff --git a/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb b/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb
index b605de16c18..7723ebbc517 100644
--- a/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb
+++ b/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb
@@ -35,6 +35,7 @@ module ::ResourceManagement
before_action :find_project_by_project_id
before_action :authorize
+ before_action :find_resource_allocation, only: %i[edit update destroy]
def new
respond_with_dialog ResourceAllocations::NewDialogComponent.new(
@@ -54,30 +55,61 @@ module ::ResourceManagement
# date picker. Uses the EmptyContract so in-progress input never surfaces
# validation errors while the user types.
def refresh_form
- allocation = set_attributes(create_params, contract_class: EmptyContract).result
+ allocation = set_attributes(allocation_params, contract_class: EmptyContract).result
replace_via_turbo_stream(
component: ResourceAllocations::AllocationStep::ScheduleViolationBannerComponent.new(allocation:)
)
respond_with_turbo_streams
end
- def edit; end
+ def edit
+ respond_with_dialog ResourceAllocations::EditDialogComponent.new(
+ project: @project,
+ allocation: @resource_allocation
+ )
+ 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)
+ validation = set_attributes(allocation_params)
return render_allocation_step(validation.result, status: :unprocessable_entity) if validation.failure?
return render_warning_step(validation.result) if needs_confirmation?(validation.result)
persist_allocation
end
- def update; end
+ def update
+ # The confirmation step's "Back" button resubmits the carried form values
+ # so the editable step can be re-rendered pre-filled.
+ return render_edit_form(set_update_attributes.result) if params[:back].present?
- def destroy; end
+ validation = set_update_attributes
+ return render_edit_form(validation.result, status: :unprocessable_entity) if validation.failure?
+
+ if needs_confirmation?(validation.result)
+ return render_warning_step(validation.result,
+ dialog_id: ResourceAllocations::EditDialogComponent::DIALOG_ID)
+ end
+
+ persist_update
+ end
+
+ def destroy
+ entity = @resource_allocation.entity
+ call = ResourceAllocations::DeleteService
+ .new(user: current_user, model: @resource_allocation)
+ .call
+
+ if call.success?
+ render_destroy_success(entity)
+ else
+ render_error_flash_message_via_turbo_stream(message: call.errors.full_messages.to_sentence)
+ respond_with_turbo_streams
+ end
+ end
private
@@ -94,7 +126,7 @@ module ::ResourceManagement
respond_with_turbo_streams(status:)
end
- def render_warning_step(allocation)
+ def render_warning_step(allocation, dialog_id: ResourceAllocations::NewDialogComponent::DIALOG_ID)
ranges = overbooked_ranges(allocation)
replace_via_turbo_stream(
@@ -109,7 +141,7 @@ module ::ResourceManagement
)
)
replace_via_turbo_stream(
- component: ResourceAllocations::WarningStep::FooterComponent.new
+ component: ResourceAllocations::WarningStep::FooterComponent.new(dialog_id:)
)
respond_with_turbo_streams
end
@@ -117,17 +149,17 @@ module ::ResourceManagement
def persist_allocation
call = ResourceAllocations::CreateService
.new(user: current_user, model: ResourceAllocation.new)
- .call(create_params)
+ .call(allocation_params)
if call.success?
- render_create_success
+ render_create_success(call.result)
else
render_allocation_step(call.result, status: :unprocessable_entity)
end
end
def render_back_step
- render_allocation_step(set_attributes(create_params).result)
+ render_allocation_step(set_attributes(allocation_params).result)
end
# A final confirmation step is shown only when the allocation would overbook
@@ -149,11 +181,14 @@ module ::ResourceManagement
def compute_overbooked_ranges(allocation)
return [] unless overbooking_checkable?(allocation)
+ # `exclude_id` drops the persisted version of an allocation being edited,
+ # so its old booking does not count against the new one.
availability(allocation).overbooking_with(
start_date: allocation.start_date,
end_date: allocation.end_date,
minutes: allocation.allocated_time,
- work_package_id: allocation.entity_id
+ work_package_id: allocation.entity_id,
+ exclude_id: allocation.id
)
end
@@ -183,14 +218,99 @@ module ::ResourceManagement
.call(attributes)
end
- def render_create_success
+ def render_create_success(allocation)
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}")
+ refresh_allocations_list(allocation.entity)
+ notify_allocation_change(allocation.entity)
respond_with_turbo_streams
end
+ def set_update_attributes
+ ResourceAllocations::SetAttributesService
+ .new(user: current_user, model: @resource_allocation, contract_class: ResourceAllocations::UpdateContract)
+ .call(allocation_params)
+ end
+
+ def persist_update
+ call = ResourceAllocations::UpdateService
+ .new(user: current_user, model: @resource_allocation)
+ .call(allocation_params)
+
+ if call.success?
+ render_update_success(call.result)
+ else
+ render_edit_form(call.result, status: :unprocessable_entity)
+ end
+ end
+
+ def render_edit_form(allocation, status: :ok)
+ replace_via_turbo_stream(
+ component: ResourceAllocations::AllocationStep::FormComponent.new(
+ allocation:,
+ project: @project,
+ allocation_kind:,
+ dialog_id: ResourceAllocations::EditDialogComponent::DIALOG_ID
+ ),
+ status:
+ )
+ replace_via_turbo_stream(
+ component: ResourceAllocations::AllocationStep::FooterComponent.new(
+ dialog_id: ResourceAllocations::EditDialogComponent::DIALOG_ID,
+ submit_label: I18n.t("resource_management.edit_allocation_dialog.submit")
+ )
+ )
+ respond_with_turbo_streams(status:)
+ end
+
+ def render_update_success(allocation)
+ render_success_flash_message_via_turbo_stream(
+ message: I18n.t("resource_management.edit_allocation_dialog.success_message")
+ )
+ close_dialog_via_turbo_stream("##{ResourceAllocations::EditDialogComponent::DIALOG_ID}")
+ refresh_allocations_list(allocation.entity)
+ notify_allocation_change(allocation.entity)
+ respond_with_turbo_streams
+ end
+
+ def render_destroy_success(entity)
+ render_success_flash_message_via_turbo_stream(
+ message: I18n.t("resource_management.work_package_allocations_dialog.delete_success")
+ )
+ refresh_allocations_list(entity)
+ notify_allocation_change(entity)
+ respond_with_turbo_streams
+ end
+
+ # Re-renders the allocation list of the work package's allocations dialog.
+ # The stream is a no-op on the client when that dialog is not open.
+ def refresh_allocations_list(work_package)
+ return unless work_package.is_a?(WorkPackage)
+
+ allocations = ResourceAllocation.allocated_for_work_packages([work_package])[work_package.id] || []
+ replace_via_turbo_stream(
+ component: ResourceAllocations::ListComponent.new(
+ project: @project,
+ work_package:,
+ allocations:,
+ visible_principal_ids: ResourceAllocation.visible_principal_ids(allocations, current_user),
+ overbooked_ids: ResourceAllocation.overbooked_ids(allocations)
+ )
+ )
+ end
+
+ # Announces that an allocation of the work package changed. A resource
+ # planner table open on the page reloads the affected work package in
+ # response; the controller stays unaware of which view (if any) is on
+ # screen. The stream is a harmless no-op when nothing listens.
+ def notify_allocation_change(entity)
+ return unless entity.is_a?(WorkPackage)
+
+ dispatch_event_via_turbo_stream("resource-allocations:changed", detail: { work_package_id: entity.id })
+ end
+
def allocation_kind
params[:allocation_kind].presence || "principal"
end
@@ -214,7 +334,16 @@ module ::ResourceManagement
.to_h
end
- def create_params
+ # Only allocations of work packages reachable by the current user within
+ # the project may be touched; anything else 404s.
+ def find_resource_allocation
+ @resource_allocation = ResourceAllocation
+ .where(entity_type: "WorkPackage",
+ entity_id: WorkPackage.visible(current_user).where(project: @project))
+ .find(params.expect(:id))
+ end
+
+ def allocation_params
permitted = params
.expect(resource_allocation: %i[principal_id filter_name start_date end_date allocated_hours
entity_type entity_id])
diff --git a/modules/resource_management/app/controllers/resource_management/resource_planner_views_controller.rb b/modules/resource_management/app/controllers/resource_management/resource_planner_views_controller.rb
index 3c3721ec5ae..baa9889f218 100644
--- a/modules/resource_management/app/controllers/resource_management/resource_planner_views_controller.rb
+++ b/modules/resource_management/app/controllers/resource_management/resource_planner_views_controller.rb
@@ -30,6 +30,7 @@
module ::ResourceManagement
class ResourcePlannerViewsController < BaseController
include OpTurbo::ComponentStream
+ include PlannerViewContent
menu_item :resource_management
@@ -46,7 +47,9 @@ module ::ResourceManagement
only: %i[new_work_package add_work_package remove_work_package
move_work_package reorder_work_package]
- def show; end
+ def show
+ @content_component = work_package_list_content(@view)
+ end
def new
if params[:view_class_name].present?
@@ -188,13 +191,7 @@ module ::ResourceManagement
end
def replace_work_package_list
- replace_via_turbo_stream(
- component: ResourcePlannerViews::ContentComponent.new(
- view: @view,
- project: @project,
- resource_planner: @resource_planner
- )
- )
+ replace_via_turbo_stream(component: work_package_list_content(@view))
end
def render_configure_step(view, status: :ok)
@@ -242,13 +239,7 @@ module ::ResourceManagement
selected_view: view
)
)
- replace_via_turbo_stream(
- component: ResourcePlannerViews::ContentComponent.new(
- view:,
- project: @project,
- resource_planner: @resource_planner
- )
- )
+ replace_via_turbo_stream(component: work_package_list_content(view))
close_dialog_via_turbo_stream("##{ResourcePlannerViews::EditDialogComponent::DIALOG_ID}")
respond_with_turbo_streams
end
diff --git a/modules/resource_management/app/controllers/resource_management/resource_planners_controller.rb b/modules/resource_management/app/controllers/resource_management/resource_planners_controller.rb
index 648109dd9fb..816ec37e4fd 100644
--- a/modules/resource_management/app/controllers/resource_management/resource_planners_controller.rb
+++ b/modules/resource_management/app/controllers/resource_management/resource_planners_controller.rb
@@ -30,6 +30,7 @@
module ::ResourceManagement
class ResourcePlannersController < BaseController
include OpTurbo::ComponentStream
+ include PlannerViewContent
menu_item :resource_management
@@ -47,6 +48,7 @@ module ::ResourceManagement
def show
@view = default_view
+ @content_component = work_package_list_content(@view)
render "resource_management/resource_planner_views/show"
end
diff --git a/modules/resource_management/app/controllers/resource_management/work_package_resource_allocations_controller.rb b/modules/resource_management/app/controllers/resource_management/work_package_resource_allocations_controller.rb
new file mode 100644
index 00000000000..123424e24e6
--- /dev/null
+++ b/modules/resource_management/app/controllers/resource_management/work_package_resource_allocations_controller.rb
@@ -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 ::ResourceManagement
+ # Lists the allocations of a single work package in a dialog. Visible to users
+ # who may view the work package and hold the `view_resource_planners`
+ # permission in its project.
+ class WorkPackageResourceAllocationsController < BaseController
+ include OpTurbo::ComponentStream
+
+ menu_item :resource_management
+
+ before_action :find_project_by_project_id
+ before_action :find_work_package
+ before_action :authorize
+
+ def index
+ respond_with_dialog ResourceAllocations::ListDialogComponent.new(
+ project: @project,
+ work_package: @work_package,
+ allocations:,
+ visible_principal_ids: ResourceAllocation.visible_principal_ids(allocations, current_user),
+ overbooked_ids: ResourceAllocation.overbooked_ids(allocations)
+ )
+ end
+
+ private
+
+ def allocations
+ @allocations ||=
+ ResourceAllocation.allocated_for_work_packages([@work_package])[@work_package.id] || []
+ end
+
+ # `WorkPackage.visible` enforces the view-work-package permission; a
+ # non-visible (or out-of-project) id therefore 404s. The
+ # `view_resource_planners` permission is enforced by `authorize`.
+ def find_work_package
+ @work_package = WorkPackage
+ .visible(current_user)
+ .where(project: @project)
+ .find(params.expect(:work_package_id))
+ end
+ end
+end
diff --git a/modules/resource_management/app/models/resource_allocation.rb b/modules/resource_management/app/models/resource_allocation.rb
index dc6d794dae9..d7f07b163f6 100644
--- a/modules/resource_management/app/models/resource_allocation.rb
+++ b/modules/resource_management/app/models/resource_allocation.rb
@@ -31,6 +31,11 @@
class ResourceAllocation < ApplicationRecord
ALLOWED_ENTITY_TYPES = %w[WorkPackage].freeze
+ # `allocated_time` is stored in minutes in an integer column. Cap it at 5000
+ # hours so an absurdly large input is rejected with a validation error rather
+ # than overflowing the column and raising ActiveModel::RangeError on save.
+ MAX_ALLOCATED_TIME = (5000.hours / 1.minute).to_i
+
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
@@ -58,11 +63,69 @@ class ResourceAllocation < ApplicationRecord
scope :needs_principal_assignment, -> { where(principal_explicit: false, principal_id: nil) }
scope :for_principal, ->(principal) { where(principal:) }
+ # The `allocated` allocations for the given work packages, grouped by work
+ # package id and with principals eager-loaded. Loaded once per page so the
+ # allocation columns (progress bar and members) share a single query.
+ def self.allocated_for_work_packages(work_packages)
+ allocated
+ .where(entity_type: "WorkPackage", entity_id: work_packages.map(&:id))
+ .includes(:principal)
+ .order(:id)
+ .group_by(&:entity_id)
+ end
+
+ # The subset of the given allocations' principal ids that `user` may see.
+ # Used to anonymise members the current user is not allowed to know about.
+ def self.visible_principal_ids(allocations, user)
+ principal_ids = allocations.filter_map(&:principal_id).uniq
+ return Set.new if principal_ids.empty?
+
+ Principal.visible(user).where(id: principal_ids).pluck(:id).to_set
+ end
+
+ # The ids of the given allocations that fall into a range in which their
+ # assigned user is overbooked. Users without configured working hours are
+ # skipped — their capacity is unknown, not zero (mirroring the check made
+ # when an allocation is created). The users' working hours and booked
+ # allocations are each fetched in one query; only the per-user capacity
+ # calendar still queries per checked user.
+ def self.overbooked_ids(allocations)
+ checkable = overbooking_checkable_principals(allocations)
+ return Set.new if checkable.empty?
+
+ booked = allocated.for_principal(checkable).group_by(&:principal_id)
+ overbooked = checkable.flat_map { |principal| overbooked_ids_of(principal, booked.fetch(principal.id, [])) }
+
+ overbooked.to_set & allocations.map(&:id)
+ end
+
+ # The ids of all allocations falling into a range in which the user is
+ # overbooked, given the user's booked allocations.
+ def self.overbooked_ids_of(principal, booked)
+ ResourceAllocations::Availability
+ .new(user: principal, allocations: booked)
+ .overbooked_ranges
+ .flat_map { |range| range.items.map(&:id) }
+ end
+ private_class_method :overbooked_ids_of
+
+ # The given allocations' assigned users whose capacity is known, i.e. who
+ # have working hours configured, fetched in a single query.
+ def self.overbooking_checkable_principals(allocations)
+ principals = allocations.filter_map(&:principal).uniq
+ checkable_ids = UserWorkingHours.for_user(principals).distinct.pluck(:user_id).to_set
+
+ principals.select { |principal| checkable_ids.include?(principal.id) }
+ end
+ private_class_method :overbooking_checkable_principals
+
validates :state, :start_date, :end_date, presence: true
validates :allocated_time,
presence: true,
numericality: { only_integer: true, greater_than: 0 }
+ validate :allocated_time_within_limit
+
validates :entity_type,
inclusion: { in: ALLOWED_ENTITY_TYPES },
allow_blank: true
@@ -151,6 +214,17 @@ class ResourceAllocation < ApplicationRecord
private
+ # Capped above to keep `allocated_time` within the integer column. The field is
+ # entered in hours, so the message reports the limit in the same duration
+ # format rather than the raw minutes the column stores.
+ def allocated_time_within_limit
+ return if allocated_time.blank?
+ return if allocated_time <= MAX_ALLOCATED_TIME
+
+ errors.add(:allocated_time, :less_than_or_equal_to,
+ count: DurationConverter.output(MAX_ALLOCATED_TIME / 60.0))
+ end
+
def starts_before_entity?
entity_start_date.present? && start_date.present? && start_date < entity_start_date
end
diff --git a/modules/resource_management/app/models/resource_work_package_list.rb b/modules/resource_management/app/models/resource_work_package_list.rb
index 608e509b1a1..c4128c4b6bd 100644
--- a/modules/resource_management/app/models/resource_work_package_list.rb
+++ b/modules/resource_management/app/models/resource_work_package_list.rb
@@ -63,6 +63,11 @@ class ResourceWorkPackageList < PersistedView
effective_query&.manually_sorted? || false
end
+ # The work packages selected by this view's query.
+ def work_packages
+ effective_query&.results&.work_packages || WorkPackage.none
+ end
+
private
def manual_mode?(filter_mode)
diff --git a/modules/resource_management/app/services/resource_allocations/availability.rb b/modules/resource_management/app/services/resource_allocations/availability.rb
index 908e77ddd09..d228210b854 100644
--- a/modules/resource_management/app/services/resource_allocations/availability.rb
+++ b/modules/resource_management/app/services/resource_allocations/availability.rb
@@ -40,8 +40,12 @@ module ResourceAllocations
# checking a not-yet-persisted allocation.
CANDIDATE_ID = :candidate
- def initialize(user:)
+ # `allocations` takes the user's `allocated` allocations when the caller
+ # already loaded them (e.g. in bulk for several users); they are queried
+ # lazily otherwise.
+ def initialize(user:, allocations: nil)
@user = user
+ @allocations = allocations
end
def overbooked?
diff --git a/modules/resource_management/app/views/resource_management/resource_planner_views/show.html.erb b/modules/resource_management/app/views/resource_management/resource_planner_views/show.html.erb
index 5ba10e34226..56890cca37b 100644
--- a/modules/resource_management/app/views/resource_management/resource_planner_views/show.html.erb
+++ b/modules/resource_management/app/views/resource_management/resource_planner_views/show.html.erb
@@ -41,4 +41,25 @@ See COPYRIGHT and LICENSE files for more details.
end %>
<%= render(ResourcePlanners::SubViewsComponent.new(resource_planner: @resource_planner, selected_view: @view)) %>
-<%= render(ResourcePlannerViews::ContentComponent.new(view: @view, project: @project, resource_planner: @resource_planner)) %>
+<%#
+ Reloadable frame so allocation changes made in the dialogs refresh the table
+ in place. The frame has no `src`, so it is not fetched on load — it just holds
+ the server-rendered content. The reload-frame-on-event controller points its
+ `src` at this same view and morphs it in when an allocation of any listed work
+ package changes.
+%>
+<%
+ # When the planner does not have a view yet, we cannot add frame data
+ frame_data = if @view
+ {
+ controller: "reload-frame-on-event",
+ "reload-frame-on-event-event-name-value": "resource-allocations:changed",
+ "reload-frame-on-event-url-value": project_resource_planner_view_path(@project, @resource_planner, @view)
+ }
+ else
+ {}
+ end
+%>
+<%= turbo_frame_tag("resource-planner-view-content", refresh: :morph, data: frame_data) do %>
+ <%= render(@content_component) %>
+<% end %>
diff --git a/modules/resource_management/config/locales/en.yml b/modules/resource_management/config/locales/en.yml
index 57d68a0434f..4b6991cea58 100644
--- a/modules/resource_management/config/locales/en.yml
+++ b/modules/resource_management/config/locales/en.yml
@@ -63,6 +63,10 @@ en:
make_private: Make private
make_public: Make public
unfavorite: Remove from favorites
+ allocation:
+ hours: "%{value}h"
+ no_work: No work planned on this work package
+ summary: "%{allocated} / %{scheduled}"
allocate_resource_dialog:
kind:
filter:
@@ -104,6 +108,10 @@ en:
A custom list of items you manually add and remove. Filtering is not possible.
label: Manually hand-picked
title: Configure view
+ edit_allocation_dialog:
+ submit: Save
+ success_message: Resource allocation updated.
+ title: Edit allocation
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
@@ -129,9 +137,23 @@ en:
caption: >-
Create a view based on users and see their details and allocation in a list of user cards
label: Users card list
+ work_package_allocations_dialog:
+ allocate_resource: Allocate resource
+ context_menu_label: Allocation actions
+ delete_confirmation: Are you sure you want to delete this allocation? This cannot be undone.
+ delete_success: Resource allocation deleted.
+ hidden_user: Hidden user
+ overbooked: The user is allocated beyond their working hours during this period.
+ title: Allocation
work_package_list:
add_work_package_dialog:
title: Add work package
+ allocated_members:
+ additional: "+%{count}"
+ other_users:
+ one: "%{count} user"
+ other: "%{count} users"
+ unassigned: Unassigned
allocation_placeholder: —
blank:
description: There are no work packages matching this view's filters yet.
diff --git a/modules/resource_management/config/routes.rb b/modules/resource_management/config/routes.rb
index 1f8cb18d2e2..1f5d1c3f16f 100644
--- a/modules/resource_management/config/routes.rb
+++ b/modules/resource_management/config/routes.rb
@@ -69,5 +69,11 @@ Rails.application.routes.draw do
get :refresh_form
end
end
+
+ resources :work_packages, only: [] do
+ resources :resource_allocations,
+ controller: "resource_management/work_package_resource_allocations",
+ only: :index
+ end
end
end
diff --git a/modules/resource_management/lib/open_project/resource_management/engine.rb b/modules/resource_management/lib/open_project/resource_management/engine.rb
index ceb80d80b91..7678bcd16b2 100644
--- a/modules/resource_management/lib/open_project/resource_management/engine.rb
+++ b/modules/resource_management/lib/open_project/resource_management/engine.rb
@@ -60,6 +60,7 @@ module OpenProject::ResourceManagement
new_work_package add_work_package
remove_work_package move_work_package
reorder_work_package],
+ "resource_management/work_package_resource_allocations": %i[index],
"resource_management/menus": %i[show]
},
permissible_on: :project
diff --git a/modules/resource_management/spec/components/resource_allocations/list_item_component_spec.rb b/modules/resource_management/spec/components/resource_allocations/list_item_component_spec.rb
new file mode 100644
index 00000000000..8ed51262b0a
--- /dev/null
+++ b/modules/resource_management/spec/components/resource_allocations/list_item_component_spec.rb
@@ -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 "rails_helper"
+
+RSpec.describe ResourceAllocations::ListItemComponent, type: :component do
+ shared_let(:work_package) { create(:work_package) }
+ shared_let(:member) { create(:user, firstname: "Sarah", lastname: "Smith") }
+
+ let(:visible) { true }
+ let(:overbooked) { false }
+ let(:editable) { false }
+
+ subject(:rendered) do
+ render_inline(described_class.new(allocation:, project: work_package.project, visible:, overbooked:, editable:))
+ page
+ end
+
+ before { login_as(create(:admin)) }
+
+ context "with a visible assigned member" do
+ let(:allocation) { create(:resource_allocation, entity: work_package, principal: member, allocated_time: 720) }
+
+ it "shows the member's name, avatar and allocated hours" do
+ expect(rendered).to have_text("Sarah Smith")
+ expect(rendered).to have_css(".Label", text: "12h")
+ expect(rendered).to have_css("avatar-fallback[data-unique-id='#{member.id}']")
+ expect(rendered).to have_no_css(".octicon-alert-fill")
+ end
+ end
+
+ context "with an overbooked assigned member" do
+ let(:allocation) { create(:resource_allocation, entity: work_package, principal: member, allocated_time: 720) }
+ let(:overbooked) { true }
+
+ it "shows a danger warning icon" do
+ expect(rendered).to have_css(".octicon-alert-fill#resource-allocation-overbooked-#{allocation.id}")
+ end
+ end
+
+ context "when the user may manage allocations" do
+ let(:allocation) { create(:resource_allocation, entity: work_package, principal: member, allocated_time: 720) }
+ let(:editable) { true }
+
+ it "offers an edit/delete menu" do
+ expect(rendered).to have_css("action-menu")
+ expect(rendered).to have_css("a[href*='/resource_allocations/#{allocation.id}/edit']", visible: :all)
+ expect(rendered).to have_button(I18n.t(:button_delete), visible: :all)
+ end
+ end
+
+ context "when the user may manage allocations but not see the member" do
+ let(:allocation) { create(:resource_allocation, entity: work_package, principal: member, allocated_time: 720) }
+ let(:editable) { true }
+ let(:visible) { false }
+
+ it "offers no menu, since editing would reveal the hidden user" do
+ expect(rendered).to have_no_css("action-menu")
+ end
+ end
+
+ context "when the user may not manage allocations" do
+ let(:allocation) { create(:resource_allocation, entity: work_package, principal: member, allocated_time: 720) }
+
+ it "offers no menu" do
+ expect(rendered).to have_no_css("action-menu")
+ end
+ end
+
+ context "with an assigned member the user may not see" do
+ let(:allocation) { create(:resource_allocation, entity: work_package, principal: member, allocated_time: 720) }
+ let(:visible) { false }
+
+ it "anonymises the member without revealing the name" do
+ expect(rendered).to have_no_text("Sarah Smith")
+ expect(rendered).to have_no_css("avatar-fallback[data-unique-id='#{member.id}']")
+ expect(rendered).to have_text(I18n.t("resource_management.work_package_allocations_dialog.hidden_user"))
+ end
+ end
+
+ context "with a filter-based allocation" do
+ let(:allocation) do
+ create(:resource_allocation,
+ entity: work_package, principal_explicit: false, principal: nil, filter_name: "Full stack developer")
+ end
+
+ it "shows the filter name with a person-add icon instead of an avatar" do
+ expect(rendered).to have_text("Full stack developer")
+ expect(rendered).to have_css(".octicon-person-add")
+ expect(rendered).to have_no_css("avatar-fallback")
+ end
+ end
+end
diff --git a/modules/resource_management/spec/components/resource_allocations/progress_component_spec.rb b/modules/resource_management/spec/components/resource_allocations/progress_component_spec.rb
new file mode 100644
index 00000000000..0ba305f4fc6
--- /dev/null
+++ b/modules/resource_management/spec/components/resource_allocations/progress_component_spec.rb
@@ -0,0 +1,122 @@
+# 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 "rails_helper"
+
+RSpec.describe ResourceAllocations::ProgressComponent, type: :component do
+ # `derived_hours` mimics a parent's rolled-up total work; `own_hours` mimics a
+ # leaf whose work lives in `estimated_hours` (where derived stays nil).
+ def work_package_with(derived_hours: nil, own_hours: nil)
+ create(:work_package).tap do |wp|
+ wp.update_columns(derived_estimated_hours: derived_hours, estimated_hours: own_hours)
+ end
+ end
+
+ # Allocations are only summed by their `allocated_time`, so stubbed records suffice.
+ def allocations_totaling(*hours)
+ hours.map { |value| build_stubbed(:resource_allocation, allocated_time: (value * 60).to_i) }
+ end
+
+ subject(:rendered) do
+ render_inline(described_class.new(work_package:, allocations:))
+ page
+ end
+
+ context "when allocations cover only part of the scheduled work" do
+ let(:work_package) { work_package_with(derived_hours: 35) }
+ let(:allocations) { allocations_totaling(8, 4) }
+
+ it "sums the allocations and renders allocated / scheduled with a partial accent bar" do
+ expect(rendered).to have_text("12h / 35h")
+ expect(rendered).to have_text("34%")
+ expect(rendered).to have_css(".Progress-item.color-bg-accent-emphasis[style*='width: 34%']")
+ end
+ end
+
+ context "when the work package is a leaf with only its own estimated work" do
+ let(:work_package) { work_package_with(own_hours: 35) }
+ let(:allocations) { allocations_totaling(12) }
+
+ it "falls back to estimated_hours for the total work" do
+ expect(rendered).to have_text("12h / 35h")
+ expect(rendered).to have_text("34%")
+ end
+ end
+
+ context "when allocations exactly cover the scheduled work" do
+ let(:work_package) { work_package_with(derived_hours: 80) }
+ let(:allocations) { allocations_totaling(80) }
+
+ it "renders a full success bar at 100%" do
+ expect(rendered).to have_text("100%")
+ expect(rendered).to have_css(".Progress-item.color-bg-success-emphasis[style*='width: 100%']")
+ end
+ end
+
+ context "when allocations exceed the scheduled work" do
+ let(:work_package) { work_package_with(derived_hours: 20) }
+ let(:allocations) { allocations_totaling(40) }
+
+ it "renders a danger bar capped at 100% while the label shows the real ratio" do
+ expect(rendered).to have_text("40h / 20h")
+ expect(rendered).to have_text("200%")
+ expect(rendered).to have_css(".Progress-item.color-bg-danger-emphasis[style*='width: 100%']")
+ end
+ end
+
+ context "when nothing is allocated yet" do
+ let(:work_package) { work_package_with(derived_hours: 100) }
+ let(:allocations) { [] }
+
+ it "renders an empty accent bar at 0%" do
+ expect(rendered).to have_text("0h / 100h")
+ expect(rendered).to have_text("0%")
+ expect(rendered).to have_css(".Progress-item[style*='width: 0%']")
+ end
+ end
+
+ context "when the work package has no scheduled work" do
+ let(:work_package) { work_package_with }
+ let(:allocations) { allocations_totaling(12) }
+
+ it "renders a danger alert icon instead of a bar" do
+ expect(rendered).to have_no_css(".Progress-item")
+ expect(rendered).to have_css(".octicon-alert-fill")
+ expect(rendered).to have_text(I18n.t("resource_management.allocation.no_work"))
+ end
+
+ it "returns a zero ratio instead of dividing by zero" do
+ component = described_class.new(work_package:, allocations:)
+
+ expect { component.send(:ratio) }.not_to raise_error
+ expect(component.send(:ratio)).to eq(0)
+ end
+ end
+end
diff --git a/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocated_members_component_spec.rb b/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocated_members_component_spec.rb
new file mode 100644
index 00000000000..d308c876401
--- /dev/null
+++ b/modules/resource_management/spec/components/resource_planner_views/work_package_list/allocated_members_component_spec.rb
@@ -0,0 +1,153 @@
+# 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 "rails_helper"
+
+RSpec.describe ResourcePlannerViews::WorkPackageList::AllocatedMembersComponent, type: :component do
+ shared_let(:work_package) { create(:work_package) }
+ shared_let(:assignee) { create(:user, firstname: "Michael", lastname: "Johnson") }
+
+ def assigned_allocation(principal)
+ create(:resource_allocation, entity: work_package, principal:)
+ end
+
+ def filter_allocation(name)
+ create(:resource_allocation,
+ entity: work_package,
+ principal_explicit: false,
+ principal: nil,
+ filter_name: name)
+ end
+
+ # nil means no visibility restriction (every principal is visible).
+ let(:visible_principal_ids) { nil }
+
+ subject(:rendered) do
+ render_inline(described_class.new(allocations:, visible_principal_ids:))
+ page
+ end
+
+ before { login_as(create(:admin)) }
+
+ context "with a single assigned member" do
+ let(:allocations) { [assigned_allocation(assignee)] }
+
+ it "renders an avatar stack with the member's name and no extra count" do
+ expect(rendered).to have_css(".AvatarStack")
+ expect(rendered).to have_css("avatar-fallback[data-unique-id='#{assignee.id}'][data-alt-text='Michael Johnson']")
+ expect(rendered).to have_text("Michael Johnson")
+ expect(rendered).to have_no_text("+")
+ end
+ end
+
+ context "with several members" do
+ let(:others) { create_list(:user, 2) }
+ let(:allocations) { [assigned_allocation(assignee), *others.map { |u| assigned_allocation(u) }] }
+
+ it "stacks an avatar per member and shows the lead name with a +N count of the rest" do
+ expect(rendered).to have_css("avatar-fallback", count: 3)
+ expect(rendered).to have_text("Michael Johnson")
+ expect(rendered).to have_text("+2")
+ end
+ end
+
+ context "with exactly two members" do
+ let(:allocations) { [assigned_allocation(assignee), assigned_allocation(create(:user))] }
+
+ it "shows the lead name and a +1 count" do
+ expect(rendered).to have_css("avatar-fallback", count: 2)
+ expect(rendered).to have_text("Michael Johnson")
+ expect(rendered).to have_text("+1")
+ end
+ end
+
+ context "with a filter-based allocation that has no assigned user" do
+ let(:allocations) { [filter_allocation("Full stack Developer (DE-EN)")] }
+
+ it "renders a generated avatar keyed to the allocation, labelled with the filter name" do
+ expect(rendered).to have_css("avatar-fallback[data-unique-id='resource-allocation-#{allocations.first.id}']")
+ expect(rendered).to have_css("avatar-fallback[data-alt-text='Full stack Developer (DE-EN)']")
+ expect(rendered).to have_text("Full stack Developer (DE-EN)")
+ end
+ end
+
+ context "with an allocation whose principal was removed" do
+ # Mimics `dependent: :nullify` after the assigned user is deleted: an
+ # explicit allocation left without a principal and without a filter name.
+ let(:allocations) do
+ allocation = assigned_allocation(assignee)
+ allocation.update_column(:principal_id, nil)
+ [allocation]
+ end
+
+ it "renders a generated 'Unassigned' avatar instead of raising" do
+ label = I18n.t("resource_management.work_package_list.allocated_members.unassigned")
+ expect(rendered).to have_css("avatar-fallback[data-alt-text='#{label}']")
+ expect(rendered).to have_text(label)
+ end
+ end
+
+ context "with a member the current user cannot see" do
+ let(:hidden_user) { create(:user, firstname: "Hidden", lastname: "Person") }
+ let(:allocations) { [assigned_allocation(assignee), assigned_allocation(hidden_user)] }
+ let(:visible_principal_ids) { Set[assignee.id] }
+
+ it "names only the visible member but still counts the hidden one" do
+ expect(rendered).to have_css("avatar-fallback[data-unique-id='#{assignee.id}']")
+ expect(rendered).to have_text("Michael Johnson")
+ expect(rendered).to have_text("+1")
+ end
+
+ it "does not reveal the hidden member's avatar or name" do
+ expect(rendered).to have_no_css("avatar-fallback[data-unique-id='#{hidden_user.id}']")
+ expect(rendered).to have_no_text("Hidden Person")
+ end
+ end
+
+ context "when every member is hidden from the current user" do
+ let(:allocations) { [assigned_allocation(assignee), assigned_allocation(create(:user))] }
+ let(:visible_principal_ids) { Set.new }
+
+ it "shows an anonymous count instead of names or avatars" do
+ expect(rendered).to have_no_css("avatar-fallback")
+ expect(rendered).to have_no_text("Michael Johnson")
+ expect(rendered).to have_text("2 users")
+ end
+ end
+
+ context "without any allocations" do
+ let(:allocations) { [] }
+
+ it "renders nothing" do
+ expect(rendered).to have_no_css(".AvatarStack")
+ expect(rendered).to have_no_css("avatar-fallback")
+ end
+ end
+end
diff --git a/modules/resource_management/spec/components/resource_planner_views/work_package_list/row_component_spec.rb b/modules/resource_management/spec/components/resource_planner_views/work_package_list/row_component_spec.rb
index fbce73b9970..68659046aea 100644
--- a/modules/resource_management/spec/components/resource_planner_views/work_package_list/row_component_spec.rb
+++ b/modules/resource_management/spec/components/resource_planner_views/work_package_list/row_component_spec.rb
@@ -42,9 +42,10 @@ RSpec.describe ResourcePlannerViews::WorkPackageList::RowComponent, type: :compo
let(:view) do
ResourceWorkPackageList.create!(name: "List", parent: resource_planner, project:, principal: user, query:)
end
+ let(:allocations) { {} }
let(:table) do
ResourcePlannerViews::WorkPackageList::TableComponent.new(
- rows: work_packages, view:, project:, resource_planner:
+ rows: work_packages, view:, project:, resource_planner:, allocations:
)
end
@@ -88,6 +89,17 @@ RSpec.describe ResourcePlannerViews::WorkPackageList::RowComponent, type: :compo
end
end
+ context "with members allocated to the work package" do
+ let(:query) { automatic_query }
+ let(:member) { create(:user, firstname: "Michael", lastname: "Johnson") }
+ let(:allocation) { create(:resource_allocation, entity: work_packages.first, principal: member) }
+ let(:allocations) { { work_packages.first.id => [allocation] } }
+
+ it "renders the allocated members' avatar stack instead of the placeholder" do
+ expect(rendered).to have_css("avatar-fallback[data-unique-id='#{member.id}']")
+ end
+ end
+
context "with a manually hand-picked view" do
let(:query) { manual_query }
diff --git a/modules/resource_management/spec/models/resource_allocation_spec.rb b/modules/resource_management/spec/models/resource_allocation_spec.rb
index 4496de26357..e5d47a6e826 100644
--- a/modules/resource_management/spec/models/resource_allocation_spec.rb
+++ b/modules/resource_management/spec/models/resource_allocation_spec.rb
@@ -401,6 +401,17 @@ RSpec.describe ResourceAllocation do
allocation.allocated_time = 1
expect(allocation).to be_valid
end
+
+ it "is valid at the upper cap" do
+ allocation.allocated_time = described_class::MAX_ALLOCATED_TIME
+ expect(allocation).to be_valid
+ end
+
+ it "is invalid (rather than overflowing the column) above the cap" do
+ allocation.allocated_time = described_class::MAX_ALLOCATED_TIME + 1
+ expect(allocation).not_to be_valid
+ expect(allocation.errors.symbols_for(:allocated_time)).to include(:less_than_or_equal_to)
+ end
end
describe "date range" do
@@ -604,4 +615,88 @@ RSpec.describe ResourceAllocation do
end
end
end
+
+ describe ".allocated_for_work_packages" do
+ shared_let(:work_package) { create(:work_package) }
+ shared_let(:other_work_package) { create(:work_package) }
+
+ def allocations_for(*work_packages)
+ described_class.allocated_for_work_packages(work_packages)
+ end
+
+ it "groups the work package's allocations by entity id" do
+ first = create(:resource_allocation, entity: work_package, allocated_time: 120)
+ second = create(:resource_allocation, entity: work_package, allocated_time: 300)
+
+ expect(allocations_for(work_package)).to eq(work_package.id => [first, second])
+ end
+
+ it "includes only allocations in the 'allocated' state" do
+ allocated = create(:resource_allocation, entity: work_package, allocated_time: 120)
+ create(:resource_allocation, :requested, entity: work_package, allocated_time: 999)
+ create(:resource_allocation, :rejected, entity: work_package, allocated_time: 999)
+ create(:resource_allocation, :canceled, entity: work_package, allocated_time: 999)
+
+ expect(allocations_for(work_package)).to eq(work_package.id => [allocated])
+ end
+
+ it "covers several work packages and omits those without allocations" do
+ mine = create(:resource_allocation, entity: work_package, allocated_time: 120)
+ create(:resource_allocation, entity: other_work_package, allocated_time: 500)
+
+ result = allocations_for(work_package, other_work_package)
+
+ expect(result[work_package.id]).to eq([mine])
+ expect(result).to have_key(other_work_package.id)
+ end
+
+ it "is empty when the work packages have no allocations" do
+ expect(allocations_for(work_package)).to eq({})
+ end
+ end
+
+ describe ".overbooked_ids" do
+ shared_let(:work_package) { create(:work_package) }
+ shared_let(:assignee) { create(:user) }
+
+ # The factory books Mon-Fri 2026-01-05..09 with 40 hours — exactly the
+ # capacity of a full-time (8h/day) week.
+ def allocation(**attributes)
+ create(:resource_allocation, entity: work_package, principal: assignee, **attributes)
+ end
+
+ def overbooked_allocation
+ allocation(start_date: Date.new(2026, 2, 2), end_date: Date.new(2026, 2, 2), allocated_time: 16 * 60)
+ end
+
+ context "when the assigned user has working hours configured" do
+ shared_let(:working_hours) { create(:user_working_hours, user: assignee, valid_from: Date.new(2025, 1, 1)) }
+
+ it "returns the ids of the allocations in overbooked ranges" do
+ fitting = allocation
+ overbooked = overbooked_allocation
+
+ expect(described_class.overbooked_ids([fitting, overbooked])).to eq(Set[overbooked.id])
+ end
+
+ it "is empty when every allocation fits" do
+ fitting = allocation
+
+ expect(described_class.overbooked_ids([fitting])).to be_empty
+ end
+ end
+
+ context "when the assigned user has no working hours configured" do
+ it "is empty, since the user's capacity is unknown rather than zero" do
+ expect(described_class.overbooked_ids([overbooked_allocation])).to be_empty
+ end
+ end
+
+ it "ignores filter-based allocations without an assigned user" do
+ filter = create(:resource_allocation,
+ entity: work_package, principal_explicit: false, principal: nil, filter_name: "Developer")
+
+ expect(described_class.overbooked_ids([filter])).to be_empty
+ end
+ end
end
diff --git a/modules/resource_management/spec/requests/resource_allocations_spec.rb b/modules/resource_management/spec/requests/resource_allocations_spec.rb
index 4d659c95b32..41f4b15cfba 100644
--- a/modules/resource_management/spec/requests/resource_allocations_spec.rb
+++ b/modules/resource_management/spec/requests/resource_allocations_spec.rb
@@ -161,6 +161,14 @@ RSpec.describe "ResourceAllocations requests",
expect(allocation.user_filter).to eq([])
expect(allocation.requested_by).to eq(user)
end
+
+ it "refreshes the open allocations list and announces the change for the planner table" do
+ perform
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('target="resource-allocations-list-component"')
+ expect_allocation_change_announced_for(work_package)
+ end
end
context "for a filter-criteria placeholder" do
@@ -451,8 +459,175 @@ RSpec.describe "ResourceAllocations requests",
end
end
+ describe "GET edit" do
+ shared_let(:allocation) { create(:resource_allocation, entity: work_package, principal: assignee) }
+
+ it "opens the edit dialog with the allocation form" do
+ get edit_project_resource_allocation_path(project, allocation), as: :turbo_stream
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(I18n.t("resource_management.edit_allocation_dialog.title"))
+ expect(response.body).to include("resource_allocation[allocated_hours]")
+ end
+
+ context "for an allocation of another project's work package" do
+ let(:other_allocation) { create(:resource_allocation) }
+
+ it "is not found" do
+ get edit_project_resource_allocation_path(project, other_allocation), as: :turbo_stream
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+
+ describe "PATCH update" do
+ let!(:allocation) do
+ create(:resource_allocation, entity: work_package, principal: assignee, allocated_time: 600)
+ end
+
+ def perform(allocated_hours: "16h")
+ patch project_resource_allocation_path(project, allocation),
+ 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-06",
+ allocated_hours:
+ }
+ },
+ as: :turbo_stream
+ end
+
+ it "updates the allocation and confirms it" do
+ perform
+
+ expect(response).to have_http_status(:ok)
+ expect(allocation.reload.allocated_time).to eq(16 * 60)
+ expect(allocation.start_date).to eq(Date.new(2026, 3, 2))
+ expect(allocation.end_date).to eq(Date.new(2026, 3, 6))
+ expect(response.body).to include(I18n.t("resource_management.edit_allocation_dialog.success_message"))
+ end
+
+ it "announces the change so the planner table can refresh" do
+ perform
+
+ expect_allocation_change_announced_for(work_package)
+ end
+
+ context "with invalid input" do
+ it "re-renders the form unprocessable and keeps the allocation unchanged" do
+ perform(allocated_hours: "")
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(allocation.reload.allocated_time).to eq(600)
+ end
+
+ # Regression: an absurdly large value used to overflow the integer column
+ # and raise ActiveModel::RangeError (500) instead of failing validation.
+ it "rejects a value above the maximum with an hours-formatted message" do
+ perform(allocated_hours: "999999999999h")
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(allocation.reload.allocated_time).to eq(600)
+ expect(response.body).to include(
+ DurationConverter.output(ResourceAllocation::MAX_ALLOCATED_TIME / 60.0)
+ )
+ end
+ end
+
+ context "when the update would overbook the assigned user" do
+ shared_let(:working_assignee) do
+ create(:user, member_with_permissions: { project => %i[view_work_packages] }).tap do |member|
+ # Mon-Fri 8h => 480 minutes/day of capacity.
+ create(:user_working_hours, user: member, valid_from: Date.new(2025, 1, 1))
+ end
+ end
+
+ # Books 10h across Mon-Tue (16h of capacity).
+ let!(:allocation) do
+ create(:resource_allocation,
+ entity: work_package, principal: working_assignee,
+ start_date: Date.new(2026, 3, 2), end_date: Date.new(2026, 3, 3), allocated_time: 600)
+ end
+
+ def perform(extra = {})
+ patch project_resource_allocation_path(project, allocation),
+ params: {
+ allocation_kind: "principal",
+ resource_allocation: {
+ principal_id: working_assignee.id,
+ entity_type: "WorkPackage",
+ entity_id: work_package.id,
+ start_date: "2026-03-02",
+ end_date: "2026-03-03",
+ allocated_hours: "40h"
+ }
+ }.deep_merge(extra),
+ as: :turbo_stream
+ end
+
+ it "does not save yet and renders the overbooking confirmation step" do
+ perform
+
+ expect(response).to have_http_status(:ok)
+ expect(allocation.reload.allocated_time).to eq(600)
+ expect(response.body).to include(I18n.t("resource_management.allocate_resource_dialog.overbooking.title"))
+ expect(response.body).to include('name="confirmed"')
+ end
+
+ it "applies the update once confirmed" do
+ perform(confirmed: "1")
+
+ expect(allocation.reload.allocated_time).to eq(40 * 60)
+ expect(response.body).to include(I18n.t("resource_management.edit_allocation_dialog.success_message"))
+ end
+
+ it "returns to the pre-filled edit form when going back from the confirmation" do
+ perform(back: "1")
+
+ expect(response).to have_http_status(:ok)
+ expect(allocation.reload.allocated_time).to eq(600)
+ expect(response.body).to include("resource_allocation[allocated_hours]")
+ end
+
+ it "does not count the allocation's previous booking against its own update" do
+ # 16h exactly fills Mon-Tue only if the allocation's persisted 10h are
+ # excluded from the check; no confirmation step expected.
+ perform(resource_allocation: { allocated_hours: "16h" })
+
+ expect(allocation.reload.allocated_time).to eq(16 * 60)
+ expect(response.body).to include(I18n.t("resource_management.edit_allocation_dialog.success_message"))
+ end
+ end
+ end
+
+ describe "DELETE destroy" do
+ let!(:allocation) { create(:resource_allocation, entity: work_package, principal: assignee) }
+
+ it "deletes the allocation and confirms it" do
+ expect do
+ delete project_resource_allocation_path(project, allocation), as: :turbo_stream
+ end.to change(ResourceAllocation, :count).by(-1)
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(I18n.t("resource_management.work_package_allocations_dialog.delete_success"))
+ end
+
+ it "refreshes the open allocations list and announces the change for the planner table" do
+ delete project_resource_allocation_path(project, allocation), as: :turbo_stream
+
+ expect(response.body).to include('target="resource-allocations-list-component"')
+ expect_allocation_change_announced_for(work_package)
+ end
+ end
+
context "without the allocate_user_resources permission" do
shared_let(:viewer) { create(:user, member_with_permissions: { project => %i[view_resource_planners] }) }
+ shared_let(:allocation) { create(:resource_allocation, entity: work_package, principal: assignee) }
before { login_as viewer }
@@ -462,6 +637,28 @@ RSpec.describe "ResourceAllocations requests",
expect(response).to have_http_status(:forbidden)
end
+ it "denies access to the edit dialog" do
+ get edit_project_resource_allocation_path(project, allocation), as: :turbo_stream
+
+ expect(response).to have_http_status(:forbidden)
+ end
+
+ it "denies updating an allocation" do
+ patch project_resource_allocation_path(project, allocation),
+ params: { allocation_kind: "principal", resource_allocation: { allocated_hours: "1h" } },
+ as: :turbo_stream
+
+ expect(response).to have_http_status(:forbidden)
+ end
+
+ it "denies deleting an allocation" do
+ expect do
+ delete project_resource_allocation_path(project, allocation), as: :turbo_stream
+ end.not_to change(ResourceAllocation, :count)
+
+ expect(response).to have_http_status(:forbidden)
+ end
+
it "denies creating an allocation" do
expect do
post project_resource_allocations_path(project),
@@ -482,4 +679,14 @@ RSpec.describe "ResourceAllocations requests",
expect(response).to have_http_status(:forbidden)
end
end
+
+ # The controller emits a `dispatchEvent` turbo stream carrying the changed
+ # work package so an open resource planner table can reload it.
+ def expect_allocation_change_announced_for(work_package)
+ event = Nokogiri::HTML5.fragment(response.body).at_css('turbo-stream[action="dispatchEvent"]')
+
+ expect(event).to be_present
+ expect(event["event-name"]).to eq("resource-allocations:changed")
+ expect(JSON.parse(event["detail"])).to eq("work_package_id" => work_package.id)
+ end
end
diff --git a/modules/resource_management/spec/requests/resource_planner_views_spec.rb b/modules/resource_management/spec/requests/resource_planner_views_spec.rb
index d202bf3370b..8fe668a33bf 100644
--- a/modules/resource_management/spec/requests/resource_planner_views_spec.rb
+++ b/modules/resource_management/spec/requests/resource_planner_views_spec.rb
@@ -45,6 +45,30 @@ RSpec.describe "ResourcePlannerViews requests",
before { login_as user }
+ describe "GET show" do
+ it "renders the view's work package list" do
+ get project_resource_planner_view_path(project, resource_planner, view)
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ # Regression: the planner's own show page renders the same template and
+ # must build the list content as well.
+ it "renders the planner's show page with its default view" do
+ view
+
+ get project_resource_planner_path(project, resource_planner)
+
+ expect(response).to have_http_status(:ok)
+ end
+
+ it "renders the planner's show page when it has no views yet" do
+ get project_resource_planner_path(project, resource_planner)
+
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
describe "POST create" do
subject(:perform) do
post project_resource_planner_views_path(project, resource_planner),
diff --git a/modules/resource_management/spec/requests/work_package_resource_allocations_spec.rb b/modules/resource_management/spec/requests/work_package_resource_allocations_spec.rb
new file mode 100644
index 00000000000..7292a60307c
--- /dev/null
+++ b/modules/resource_management/spec/requests/work_package_resource_allocations_spec.rb
@@ -0,0 +1,159 @@
+# 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 "WorkPackage resource allocations requests", 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 view_work_packages] })
+ end
+ # A project member the current user can see.
+ shared_let(:assignee) do
+ create(:user, firstname: "Sarah", lastname: "Smith", member_with_permissions: { project => %i[view_work_packages] })
+ end
+ # No shared project or group, so invisible to the (non-admin) current user.
+ shared_let(:hidden_user) { create(:user, firstname: "Secret", lastname: "Agent") }
+ shared_let(:work_package) { create(:work_package, project:) }
+
+ let(:path) { project_work_package_resource_allocations_path(project, work_package) }
+
+ before { login_as(user) }
+
+ describe "GET index" do
+ before do
+ create(:resource_allocation, entity: work_package, principal: assignee, allocated_time: 720)
+ create(:resource_allocation, entity: work_package, principal: hidden_user, allocated_time: 300)
+ create(:resource_allocation,
+ entity: work_package, principal_explicit: false, principal: nil, filter_name: "Full stack developer")
+ end
+
+ it "renders the allocations dialog" do
+ get path, as: :turbo_stream
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(I18n.t("resource_management.work_package_allocations_dialog.title"))
+ end
+
+ # An inline delete submits a form inside the dialog; without this flag the
+ # global submit-end handler would close the dialog on success.
+ it "keeps the dialog open across in-dialog form submissions" do
+ get path, as: :turbo_stream
+
+ dialog = Nokogiri::HTML5.fragment(response.body).at_css("dialog")
+ expect(dialog["data-keep-open-on-submit"]).to eq("true")
+ end
+
+ it "names the visible member and the filter allocation" do
+ get path, as: :turbo_stream
+
+ expect(response.body).to include("Sarah Smith")
+ expect(response.body).to include("Full stack developer")
+ end
+
+ it "lists the invisible member anonymously, never revealing the name" do
+ get path, as: :turbo_stream
+
+ expect(response.body).not_to include("Secret Agent")
+ expect(response.body).to include(I18n.t("resource_management.work_package_allocations_dialog.hidden_user"))
+ end
+
+ it "offers no row actions to a user who may not manage allocations" do
+ get path, as: :turbo_stream
+
+ expect(response.body).not_to include("/edit")
+ end
+
+ context "when the user may manage allocations" do
+ shared_let(:manager) do
+ create(:user,
+ member_with_permissions: {
+ project => %i[view_resource_planners allocate_user_resources view_work_packages]
+ })
+ end
+
+ before { login_as(manager) }
+
+ it "offers edit and delete row actions" do
+ allocation = ResourceAllocation.find_by(principal: assignee)
+
+ get path, as: :turbo_stream
+
+ expect(response.body).to include(edit_project_resource_allocation_path(project, allocation))
+ expect(response.body).to include(I18n.t(:button_delete))
+ end
+ end
+
+ context "when an assigned user is overbooked" do
+ let!(:overbooked_allocation) do
+ create(:resource_allocation,
+ entity: work_package, principal: assignee,
+ start_date: Date.new(2026, 2, 2), end_date: Date.new(2026, 2, 2), allocated_time: 16 * 60)
+ end
+
+ before do
+ create(:user_working_hours, user: assignee, valid_from: Date.new(2025, 1, 1))
+ end
+
+ it "flags the overbooked allocation with a warning" do
+ get path, as: :turbo_stream
+
+ expect(response.body).to include("resource-allocation-overbooked-#{overbooked_allocation.id}")
+ end
+ end
+ end
+
+ describe "authorization" do
+ context "without the view_resource_planners permission" do
+ shared_let(:other_user) do
+ create(:user, member_with_permissions: { project => %i[view_work_packages] })
+ end
+
+ before { login_as(other_user) }
+
+ it "is forbidden" do
+ get path, as: :turbo_stream
+
+ expect(response).to have_http_status(:forbidden)
+ end
+ end
+
+ context "when the work package is not visible to the user" do
+ let(:other_project) { create(:project, enabled_module_names: %w[resource_management]) }
+ let(:invisible_work_package) { create(:work_package, project: other_project) }
+
+ it "returns not found" do
+ get project_work_package_resource_allocations_path(project, invisible_work_package), as: :turbo_stream
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+end