mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
Merge pull request #23628 from opf/finish-work-package-table
[Resource Management] Finish work package table
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
+12
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+20
-3
@@ -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)
|
||||
|
||||
+62
@@ -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
|
||||
%>
|
||||
+59
@@ -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
|
||||
+53
@@ -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
|
||||
+70
@@ -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
|
||||
%>
|
||||
+62
@@ -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
|
||||
+61
@@ -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 %>
|
||||
+167
@@ -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
|
||||
+61
@@ -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 %>
|
||||
+111
@@ -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
|
||||
+9
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+14
@@ -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
|
||||
|
||||
+8
-2
@@ -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
|
||||
|
||||
+50
@@ -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 %>
|
||||
+137
@@ -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
|
||||
+3
-1
@@ -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? %>
|
||||
|
||||
+5
-4
@@ -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
|
||||
|
||||
+14
-4
@@ -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
|
||||
|
||||
+14
-2
@@ -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
|
||||
|
||||
+27
@@ -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
|
||||
+51
@@ -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
|
||||
+142
-13
@@ -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])
|
||||
|
||||
+6
-15
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
|
||||
+71
@@ -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?
|
||||
|
||||
+22
-1
@@ -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
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) the OpenProject GmbH
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License version 3.
|
||||
#
|
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
# Copyright (C) 2010-2013 the ChiliProject Team
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License
|
||||
# as published by the Free Software Foundation; either version 2
|
||||
# of the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
#
|
||||
# See COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
require "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
|
||||
+122
@@ -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
|
||||
+153
@@ -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
|
||||
+13
-1
@@ -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
|
||||
Reference in New Issue
Block a user