Merge pull request #23628 from opf/finish-work-package-table

[Resource Management] Finish work package table
This commit is contained in:
Klaus Zanders
2026-06-11 16:17:42 +02:00
committed by GitHub
52 changed files with 2573 additions and 54 deletions
@@ -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
@@ -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"
@@ -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(`
<turbo-frame id="frame"
data-controller="reload-frame-on-event"
data-reload-frame-on-event-event-name-value="resource-allocations:changed"
data-reload-frame-on-event-url-value="/planner/view">
content
</turbo-frame>
`);
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<ReloadFrameOnEventController>('reload-frame-on-event', frame).disconnect();
document.dispatchEvent(new CustomEvent('resource-allocations:changed'));
expect(calls.count).toBe(0);
});
});
@@ -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<FrameElement> {
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);
}
}
+2
View File
@@ -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);
@@ -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 }));
};
}
+2
View File
@@ -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() {
@@ -0,0 +1 @@
@import "resource_planner_views/work_package_list/table_component"
@@ -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
@@ -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,
@@ -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)
@@ -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
%>
@@ -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
@@ -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 %>
@@ -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
@@ -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
%>
@@ -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
@@ -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 %>
@@ -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
@@ -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 %>
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 %>
@@ -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
@@ -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? %>
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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])
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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?
@@ -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 %>
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 }
@@ -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
@@ -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
@@ -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),
@@ -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