From 6cc78a5ec0914e368cdd82ae542e9bbf830e8cdf Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Mon, 1 Jun 2026 15:25:48 +0200 Subject: [PATCH] Correct filter creation, manual adding of work packages --- app/models/persisted_view.rb | 5 +- .../configure_step/form_component.rb | 6 +- .../edit_dialog_component.html.erb | 1 + .../new_dialog_component.html.erb | 1 + ...add_work_package_dialog_component.html.erb | 66 +++++++++ .../add_work_package_dialog_component.rb | 59 ++++++++ .../sub_header_component.html.erb | 40 +++++- .../new_dialog_component.html.erb | 1 + .../resource_planner_views_controller.rb | 63 +++++++- .../forms/configure_form.rb | 9 +- .../add_work_package_form.rb | 59 ++++++++ .../resource_management/categorized.rb | 48 +++++++ .../app/models/resource_planner.rb | 6 +- .../app/models/resource_work_package_list.rb | 62 ++++++++ .../app/models/user_card.rb | 2 + .../set_attributes_service.rb | 41 ++++++ .../resource_management/config/locales/en.yml | 5 + modules/resource_management/config/routes.rb | 9 +- .../resource_management/engine.rb | 3 +- .../requests/resource_planner_views_spec.rb | 134 +++++++++++++++++- .../update_service_spec.rb | 10 ++ 21 files changed, 610 insertions(+), 20 deletions(-) create mode 100644 modules/resource_management/app/components/resource_planner_views/work_package_list/add_work_package_dialog_component.html.erb create mode 100644 modules/resource_management/app/components/resource_planner_views/work_package_list/add_work_package_dialog_component.rb create mode 100644 modules/resource_management/app/forms/resource_planner_views/work_package_list/add_work_package_form.rb create mode 100644 modules/resource_management/app/models/concerns/resource_management/categorized.rb diff --git a/app/models/persisted_view.rb b/app/models/persisted_view.rb index fa7a405215a..32cadfc15f4 100644 --- a/app/models/persisted_view.rb +++ b/app/models/persisted_view.rb @@ -31,7 +31,10 @@ class PersistedView < ApplicationRecord belongs_to :project, optional: true belongs_to :principal, optional: true, inverse_of: :persisted_views - belongs_to :query, polymorphic: true, optional: true + # `autosave` so that filter/sort changes made to an already-persisted query + # (e.g. when editing a view's configuration) are written when the view is + # saved. For a brand new query the foreign key is filled in on save anyway. + belongs_to :query, polymorphic: true, optional: true, autosave: true belongs_to :parent, class_name: "PersistedView", optional: true has_many :children, class_name: "PersistedView", foreign_key: "parent_id", dependent: :destroy, inverse_of: :parent diff --git a/modules/resource_management/app/components/resource_planner_views/configure_step/form_component.rb b/modules/resource_management/app/components/resource_planner_views/configure_step/form_component.rb index 3db3e365aeb..a2de7c3671b 100644 --- a/modules/resource_management/app/components/resource_planner_views/configure_step/form_component.rb +++ b/modules/resource_management/app/components/resource_planner_views/configure_step/form_component.rb @@ -65,8 +65,12 @@ module ResourcePlannerViews private + # Whether the filter form is shown on first render. It is hidden for + # manually hand-picked views so it matches the initially-checked radio + # in ConfigureForm; the show-when-value-selected controller takes over + # once the user toggles the mode. def initial_filter_mode_automatic? - @view.errors.empty? + !(@view.respond_to?(:manually_picked?) && @view.manually_picked?) end def has_filter_query? diff --git a/modules/resource_management/app/components/resource_planner_views/edit_dialog_component.html.erb b/modules/resource_management/app/components/resource_planner_views/edit_dialog_component.html.erb index 2e0940cc570..54dc51d2fd0 100644 --- a/modules/resource_management/app/components/resource_planner_views/edit_dialog_component.html.erb +++ b/modules/resource_management/app/components/resource_planner_views/edit_dialog_component.html.erb @@ -33,6 +33,7 @@ See COPYRIGHT and LICENSE files for more details. id: DIALOG_ID, title:, size: :large, + position: :right, data: { "keep-open-on-submit": true } ) ) do |dialog| diff --git a/modules/resource_management/app/components/resource_planner_views/new_dialog_component.html.erb b/modules/resource_management/app/components/resource_planner_views/new_dialog_component.html.erb index f3ae2184403..c15faea3210 100644 --- a/modules/resource_management/app/components/resource_planner_views/new_dialog_component.html.erb +++ b/modules/resource_management/app/components/resource_planner_views/new_dialog_component.html.erb @@ -33,6 +33,7 @@ See COPYRIGHT and LICENSE files for more details. id: DIALOG_ID, title:, size: :large, + position: :right, data: { "keep-open-on-submit": true } ) ) do |dialog| diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/add_work_package_dialog_component.html.erb b/modules/resource_management/app/components/resource_planner_views/work_package_list/add_work_package_dialog_component.html.erb new file mode 100644 index 00000000000..62a6119d7dd --- /dev/null +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/add_work_package_dialog_component.html.erb @@ -0,0 +1,66 @@ +<%#-- 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: :medium_portrait + ) + ) do |dialog| + dialog.with_header(variant: :large) + + # This is a content-sized centered modal, so without a body min-height the + # single autocompleter field collapses the dialog and its dropdown is + # clipped. `Overlay-body_autocomplete_height` is the shared fix for + # autocompleters in centered dialogs (see WorkPackageRelationsTab pickers). + dialog.with_body(classes: "Overlay-body_autocomplete_height") do + primer_form_with( + url: form_url, + method: :post, + html: { data: { turbo_stream: true }, id: FORM_ID } + ) do |f| + render( + ResourcePlannerViews::WorkPackageList::AddWorkPackageForm.new( + f, + project: @project, + append_to: DIALOG_ID + ) + ) + end + end + + dialog.with_footer do + render( + Primer::Beta::Button.new(type: :submit, form: FORM_ID, scheme: :primary) + ) { I18n.t(:button_add) } + end + end +%> diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/add_work_package_dialog_component.rb b/modules/resource_management/app/components/resource_planner_views/work_package_list/add_work_package_dialog_component.rb new file mode 100644 index 00000000000..4f079d9232a --- /dev/null +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/add_work_package_dialog_component.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module ResourcePlannerViews::WorkPackageList + # Dialog that lets the user search the current project's work packages and + # add the chosen one to a manually hand-picked view's query. + class AddWorkPackageDialogComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + DIALOG_ID = "rm-add-work-package-dialog" + FORM_ID = "rm-add-work-package-form" + + def initialize(view:, project:, resource_planner:) + super + + @view = view + @project = project + @resource_planner = resource_planner + end + + private + + def title + I18n.t("resource_management.work_package_list.add_work_package_dialog.title") + end + + def form_url + work_packages_project_resource_planner_view_path(@project, @resource_planner, @view) + end + end +end diff --git a/modules/resource_management/app/components/resource_planner_views/work_package_list/sub_header_component.html.erb b/modules/resource_management/app/components/resource_planner_views/work_package_list/sub_header_component.html.erb index f50a881eca5..4a5bd658fa0 100644 --- a/modules/resource_management/app/components/resource_planner_views/work_package_list/sub_header_component.html.erb +++ b/modules/resource_management/app/components/resource_planner_views/work_package_list/sub_header_component.html.erb @@ -77,12 +77,40 @@ See COPYRIGHT and LICENSE files for more details. data: { controller: "async-dialog" } ) - subheader.with_action_button( - leading_icon: :plus, - scheme: :primary, - label: t("resource_management.work_package_list.subheader.add") - ) do - t("resource_management.work_package_list.subheader.add") + if @view.manually_picked? + # Hand-picked views can both allocate and pull in existing work packages, + # so the primary action becomes a dropdown. + subheader.with_action_menu( + leading_icon: :plus, + trailing_icon: :"triangle-down", + label: t("resource_management.work_package_list.subheader.add"), + anchor_align: :end, + button_arguments: { + scheme: :primary, + "aria-label": t("resource_management.work_package_list.subheader.add") + } + ) do |menu| + menu.with_item(label: t("resource_management.work_package_list.subheader.allocate")) do |item| + item.with_leading_visual_icon(icon: :people) + end + + menu.with_item( + label: t("resource_management.work_package_list.subheader.add_work_package"), + tag: :a, + href: new_work_package_project_resource_planner_view_path(@project, @resource_planner, @view), + content_arguments: { data: { controller: "async-dialog" } } + ) do |item| + item.with_leading_visual_icon(icon: :"op-relations") + end + end + else + subheader.with_action_button( + leading_icon: :plus, + scheme: :primary, + label: t("resource_management.work_package_list.subheader.allocate") + ) do + t("resource_management.work_package_list.subheader.allocate") + end end end %> diff --git a/modules/resource_management/app/components/resource_planners/new_dialog_component.html.erb b/modules/resource_management/app/components/resource_planners/new_dialog_component.html.erb index a1b11c3a0e0..3a3bc6314c5 100644 --- a/modules/resource_management/app/components/resource_planners/new_dialog_component.html.erb +++ b/modules/resource_management/app/components/resource_planners/new_dialog_component.html.erb @@ -32,6 +32,7 @@ See COPYRIGHT and LICENSE files for more details. Primer::Alpha::Dialog.new( title:, size: :large, + position: :right, id: DIALOG_ID, data: { "keep-open-on-submit": true } ) diff --git a/modules/resource_management/app/controllers/resource_management/resource_planner_views_controller.rb b/modules/resource_management/app/controllers/resource_management/resource_planner_views_controller.rb index b3b34ef3458..767b0428a52 100644 --- a/modules/resource_management/app/controllers/resource_management/resource_planner_views_controller.rb +++ b/modules/resource_management/app/controllers/resource_management/resource_planner_views_controller.rb @@ -36,7 +36,7 @@ module ::ResourceManagement before_action :find_project_by_project_id before_action :authorize before_action :find_resource_planner - before_action :find_view, only: %i[show edit update destroy] + before_action :find_view, only: %i[show edit update destroy new_work_package add_work_package] def show; end @@ -80,8 +80,53 @@ module ::ResourceManagement def destroy; end + # Opens the search dialog for manually hand-picked views. + def new_work_package + respond_with_dialog ResourcePlannerViews::WorkPackageList::AddWorkPackageDialogComponent.new( + view: @view, + project: @project, + resource_planner: @resource_planner + ) + end + + # Appends the chosen work package to the view's query and re-renders the + # list in place. + def add_work_package + work_package = WorkPackage + .visible(current_user) + .where(project: @project) + .find_by(id: params[:work_package_id]) + + return render_400(message: I18n.t(:notice_file_not_found)) if work_package.nil? + + append_work_package(work_package) + render_work_package_added + end + private + def append_work_package(work_package) + query = @view.effective_query + return if query.ordered_work_packages.exists?(work_package_id: work_package.id) + + next_position = (query.ordered_work_packages.maximum(:position) || 0) + 1 + query.ordered_work_packages.create!(work_package:, position: next_position) + end + + def render_work_package_added + replace_via_turbo_stream( + component: ResourcePlannerViews::ContentComponent.new( + view: @view, + project: @project, + resource_planner: @resource_planner + ) + ) + close_dialog_via_turbo_stream( + "##{ResourcePlannerViews::WorkPackageList::AddWorkPackageDialogComponent::DIALOG_ID}" + ) + respond_with_turbo_streams + end + def render_configure_step(view, status: :ok) update_dialog_title_via_turbo_stream( ResourcePlannerViews::NewDialogComponent::DIALOG_ID, @@ -158,7 +203,21 @@ module ::ResourceManagement end def view_params - params.expect(view: %i[name]).to_h + params.expect(view: %i[name]).to_h.merge(query_configuration_params) + end + + # The configure form renders inside a `scope: :view` form, so the + # automatic/manual radio is submitted as `view[filter_mode]` even though + # it is not a view attribute (the filters JSON, emitted via a plain + # `hidden_field_tag`, stays top-level). Read the toggle from the view + # scope, falling back to a top-level param. The SetAttributesService + # consumes both to configure the backing query. + def query_configuration_params + { filters: params[:filters], filter_mode: filter_mode_param } + end + + def filter_mode_param + params.dig(:view, :filter_mode) || params[:filter_mode] end def create_params diff --git a/modules/resource_management/app/forms/resource_planner_views/forms/configure_form.rb b/modules/resource_management/app/forms/resource_planner_views/forms/configure_form.rb index cfded4f2d82..714285373be 100644 --- a/modules/resource_management/app/forms/resource_planner_views/forms/configure_form.rb +++ b/modules/resource_management/app/forms/resource_planner_views/forms/configure_form.rb @@ -44,6 +44,12 @@ module ResourcePlannerViews # persisted on the view itself. The `show-when-value-selected` # Stimulus controller (one level above this form) listens for # changes and toggles the filter form sibling accordingly. + # + # The initially-checked radio reflects the view's persisted query so + # that editing a hand-picked view does not silently reset it back to + # automatic (which would re-apply the default status filter on save). + manual = model.respond_to?(:manually_picked?) && model.manually_picked? + f.advanced_radio_button_group( name: :filter_mode, label: I18n.t("resource_management.configure_view_dialog.filter_mode.label"), @@ -52,13 +58,14 @@ module ResourcePlannerViews ) do |group| group.radio_button( value: "automatic", - checked: true, + checked: !manual, label: I18n.t("resource_management.configure_view_dialog.filter_mode.automatic.label"), caption: I18n.t("resource_management.configure_view_dialog.filter_mode.automatic.caption"), data: { target_name: "filter_mode", "show-when-value-selected-target": "cause" } ) group.radio_button( value: "manual", + checked: manual, label: I18n.t("resource_management.configure_view_dialog.filter_mode.manual.label"), caption: I18n.t("resource_management.configure_view_dialog.filter_mode.manual.caption"), data: { target_name: "filter_mode", "show-when-value-selected-target": "cause" } diff --git a/modules/resource_management/app/forms/resource_planner_views/work_package_list/add_work_package_form.rb b/modules/resource_management/app/forms/resource_planner_views/work_package_list/add_work_package_form.rb new file mode 100644 index 00000000000..67a09c73617 --- /dev/null +++ b/modules/resource_management/app/forms/resource_planner_views/work_package_list/add_work_package_form.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module ResourcePlannerViews + module WorkPackageList + # Single-field form used inside the "add existing work package" dialog of a + # manually hand-picked view. The autocompleter is scoped to the current + # project and its dropdown is appended to the dialog so it is not clipped. + class AddWorkPackageForm < ApplicationForm + form do |f| + f.work_package_autocompleter( + name: :work_package_id, + label: WorkPackage.model_name.human, + required: true, + autocomplete_options: { + openDirectly: false, + focusDirectly: true, + dropdownPosition: "bottom", + appendTo: "##{@append_to}", + filters: [{ name: "project_id", operator: "=", values: [@project.id] }] + } + ) + end + + def initialize(project:, append_to:) + super() + @project = project + @append_to = append_to + end + end + end +end diff --git a/modules/resource_management/app/models/concerns/resource_management/categorized.rb b/modules/resource_management/app/models/concerns/resource_management/categorized.rb new file mode 100644 index 00000000000..670da01f04d --- /dev/null +++ b/modules/resource_management/app/models/concerns/resource_management/categorized.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module ResourceManagement + # Tags persisted views that belong to the resource management area with the + # matching `category` so they can be told apart from work-package or project + # views. Applies to the planner and every view type nested below it. + module Categorized + extend ActiveSupport::Concern + + included do + after_initialize :set_default_category + end + + private + + def set_default_category + self.category ||= "resource_management" if new_record? + end + end +end diff --git a/modules/resource_management/app/models/resource_planner.rb b/modules/resource_management/app/models/resource_planner.rb index 864cad9c523..39ea26ccc7b 100644 --- a/modules/resource_management/app/models/resource_planner.rb +++ b/modules/resource_management/app/models/resource_planner.rb @@ -50,7 +50,7 @@ class ResourcePlanner < PersistedView validate :end_date_after_start_date - after_initialize :set_default_category + include ResourceManagement::Categorized def visible?(user) return false if project.nil? @@ -61,10 +61,6 @@ class ResourcePlanner < PersistedView private - def set_default_category - self.category ||= "resource_management" if new_record? - end - def end_date_after_start_date return if start_date.blank? || end_date.blank? return if end_date > start_date diff --git a/modules/resource_management/app/models/resource_work_package_list.rb b/modules/resource_management/app/models/resource_work_package_list.rb index 15528d65dbe..9b284a36095 100644 --- a/modules/resource_management/app/models/resource_work_package_list.rb +++ b/modules/resource_management/app/models/resource_work_package_list.rb @@ -29,6 +29,13 @@ #++ class ResourceWorkPackageList < PersistedView + include ResourceManagement::Categorized + + # Name of the work-package filter that represents a manually hand-picked + # selection. Items live in the query's `ordered_work_packages` and the + # filter restricts the result set to exactly those (operator `ow`). + MANUAL_FILTER_NAME = "manual_sort" + validate :query_must_be_work_package_query # See `UserCard#build_default_query` for context. The work-package Query @@ -40,8 +47,63 @@ class ResourceWorkPackageList < PersistedView ::Query.new_default(project:, user: principal) end + # Translates the configure form's serialized filter selection and the + # automatic/manual toggle into the backing work-package query. Called by + # the SetAttributes service on both create and update; the modified query + # is persisted alongside the view through the `autosave` association. + def apply_query_configuration(filters_json:, filter_mode:) + query = effective_query + return if query.nil? + + query.name = configured_query_name + query.filters.clear + + if manual_mode?(filter_mode) + configure_manual(query) + else + configure_automatic(query, filters_json) + end + end + + # Whether this view's items are hand-picked rather than filtered. Drives + # the sub header's add control (dropdown vs. plain allocate button). + def manually_picked? + effective_query&.manually_sorted? || false + end + private + def manual_mode?(filter_mode) + filter_mode.to_s == "manual" + end + + def configured_query_name + I18n.t("resource_management.work_package_list.query_name", name:) + end + + def configure_manual(query) + query.add_filter(MANUAL_FILTER_NAME, "ow", []) + query.sort_criteria = [%w[manual_sorting asc], %w[id asc]] + end + + def configure_automatic(query, filters_json) + # Leaving a manual sort in place would require ordered_work_packages that + # no longer make sense once the view is filtered again, so reset it. + query.sort_criteria = [%w[id asc]] if query.manually_sorted? + + parse_filters(filters_json).each do |filter| + query.add_filter(filter[:attribute], filter[:operator], filter[:values]) + end + end + + def parse_filters(filters_json) + return [] if filters_json.blank? + + ::Queries::ParamsParser::APIV3FiltersParser.parse(filters_json) + rescue JSON::ParserError + [] + end + def query_must_be_work_package_query resolved = effective_query return if resolved.nil? || resolved.is_a?(::Query) diff --git a/modules/resource_management/app/models/user_card.rb b/modules/resource_management/app/models/user_card.rb index fb199b9fa98..e3a7d716158 100644 --- a/modules/resource_management/app/models/user_card.rb +++ b/modules/resource_management/app/models/user_card.rb @@ -29,6 +29,8 @@ #++ class UserCard < PersistedView + include ResourceManagement::Categorized + SECONDARY_INFO = %w[role email login none].freeze TAG_SOURCES = %w[groups roles none].freeze CARD_SIZES = %w[compact default expanded].freeze diff --git a/modules/resource_management/app/services/resource_planner_views/set_attributes_service.rb b/modules/resource_management/app/services/resource_planner_views/set_attributes_service.rb index 1ee363c5fb0..5b29dc414c3 100644 --- a/modules/resource_management/app/services/resource_planner_views/set_attributes_service.rb +++ b/modules/resource_management/app/services/resource_planner_views/set_attributes_service.rb @@ -32,10 +32,51 @@ module ResourcePlannerViews class SetAttributesService < ::BaseServices::SetAttributes private + # `filters` (the serialized filter selection) and `filter_mode` are not + # view attributes — pull them out before `super` hands the params to + # `model.attributes=`, then translate them into the backing query. + def set_attributes(params) + filters = params.delete(:filters) + filter_mode = params.delete(:filter_mode) + + super + + configure_query(filters:, filter_mode:) + end + def set_default_attributes(_params) model.change_by_system do model.principal ||= user end end + + # Builds the view's query if it does not have one yet and lets the view + # type translate the configure form's filter selection (and the + # automatic/manual toggle) into concrete query filters. View types that + # are not query-configurable (or have no query at all) are left untouched. + def configure_query(filters:, filter_mode:) + return unless model.respond_to?(:apply_query_configuration) + + ensure_query + return if model.query.nil? + + model.apply_query_configuration(filters_json: filters, filter_mode:) + end + + def ensure_query + return if model.query.present? + + query = model.build_default_query + return if query.nil? + + # `query=` touches `query_id`/`query_type`; on create the model has been + # extended with ChangedBySystem so the contract does not flag them as + # user-written readonly attributes. + if model.respond_to?(:change_by_system) + model.change_by_system { model.query = query } + else + model.query = query + end + end end end diff --git a/modules/resource_management/config/locales/en.yml b/modules/resource_management/config/locales/en.yml index dba1a0f63de..c5e0f0ca3b0 100644 --- a/modules/resource_management/config/locales/en.yml +++ b/modules/resource_management/config/locales/en.yml @@ -113,6 +113,8 @@ en: list of user cards label: Users card list work_package_list: + add_work_package_dialog: + title: Add existing work package allocation_placeholder: — blank: description: There are no work packages matching this view's filters yet. @@ -130,9 +132,12 @@ en: remove: Remove see_allocation: See allocation mobile_title: Work packages + query_name: "Resource management work packages: %{name}" subheader: add: Add + add_work_package: Add existing work package all_filters: All filters + allocate: Allocate hierarchy: Display hierarchy search: Search settings: Configure view diff --git a/modules/resource_management/config/routes.rb b/modules/resource_management/config/routes.rb index 99cd0a29ee5..c316fa3f9fd 100644 --- a/modules/resource_management/config/routes.rb +++ b/modules/resource_management/config/routes.rb @@ -45,7 +45,14 @@ Rails.application.routes.draw do resources :views, controller: "resource_management/resource_planner_views", - only: %i[show new create edit update destroy] + only: %i[show new create edit update destroy] do + member do + # Search-and-pick dialog for manually hand-picked views, and the + # endpoint that appends the chosen work package to the query. + get :new_work_package + post :work_packages, action: :add_work_package + end + end collection do get "menu" => "resource_management/menus#show" diff --git a/modules/resource_management/lib/open_project/resource_management/engine.rb b/modules/resource_management/lib/open_project/resource_management/engine.rb index e1da2f9c800..3e25b9dbbeb 100644 --- a/modules/resource_management/lib/open_project/resource_management/engine.rb +++ b/modules/resource_management/lib/open_project/resource_management/engine.rb @@ -56,7 +56,8 @@ module OpenProject::ResourceManagement permission :view_resource_planners, { "resource_management/resource_planners": %i[index show overview new create edit update destroy], - "resource_management/resource_planner_views": %i[show new create edit update destroy], + "resource_management/resource_planner_views": %i[show new create edit update destroy + new_work_package add_work_package], "resource_management/menus": %i[show] }, permissible_on: :project diff --git a/modules/resource_management/spec/requests/resource_planner_views_spec.rb b/modules/resource_management/spec/requests/resource_planner_views_spec.rb index 7ba46daa856..ff1e2f343a0 100644 --- a/modules/resource_management/spec/requests/resource_planner_views_spec.rb +++ b/modules/resource_management/spec/requests/resource_planner_views_spec.rb @@ -33,9 +33,9 @@ require "spec_helper" RSpec.describe "ResourcePlannerViews requests", :skip_csrf, type: :rails_request do - shared_let(:project) { create(:project, enabled_module_names: %w[resource_management]) } + 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] }) + create(:user, member_with_permissions: { project => %i[view_resource_planners view_work_packages] }) end let(:resource_planner) { create(:resource_planner, project:, principal: user) } @@ -45,6 +45,62 @@ RSpec.describe "ResourcePlannerViews requests", before { login_as user } + describe "POST create" do + subject(:perform) do + post project_resource_planner_views_path(project, resource_planner), + params: { + view_class_name: "ResourceWorkPackageList", + # `filter_mode` is submitted scoped to the `view` form, exactly as + # the configure form renders it. + view: { name: "Work packages", filter_mode: "automatic" }, + filters: [{ status_id: { operator: "o", values: [] } }].to_json + }, + as: :turbo_stream + end + + it "persists the view together with a query carrying the submitted filters" do + expect { perform }.to change(ResourceWorkPackageList, :count).by(1) + + view = ResourceWorkPackageList.last + expect(view.name).to eq("Work packages") + expect(view.category).to eq("resource_management") + expect(view.query).to be_a(Query) + expect(view.query.name).to eq(I18n.t("resource_management.work_package_list.query_name", name: "Work packages")) + expect(view.query.filters.map(&:name)).to contain_exactly(:status_id) + end + + context "when the view is manually hand-picked" do + subject(:perform) do + post project_resource_planner_views_path(project, resource_planner), + params: { + view_class_name: "ResourceWorkPackageList", + view: { name: "Hand-picked", filter_mode: "manual" }, + # The hidden filter form still serializes its (ignored) default state. + filters: [{ status_id: { operator: "o", values: [] } }].to_json + }, + as: :turbo_stream + end + + it "sets up the query for manual sorting instead of applying the filters" do + perform + + query = ResourceWorkPackageList.last.query + expect(query).to be_manually_sorted + expect(query.filters.map(&:name)).to contain_exactly(:manual_sort) + end + + it "opens the edit dialog pre-selected on manual rather than automatic" do + perform + view = ResourceWorkPackageList.last + + get edit_project_resource_planner_view_path(project, resource_planner, view), as: :turbo_stream + + manual_radio = response.body[/]*value="manual"[^>]*>/] + expect(manual_radio).to include("checked") + end + end + end + describe "PATCH update" do subject(:perform) do patch project_resource_planner_view_path(project, resource_planner, view), @@ -59,6 +115,19 @@ RSpec.describe "ResourcePlannerViews requests", expect(view.reload.name).to eq("Renamed view") end + it "switches an automatic view to manual via the view-scoped filter_mode" do + patch project_resource_planner_view_path(project, resource_planner, view), + params: { + view: { name: "Original", filter_mode: "manual" }, + filters: [{ status_id: { operator: "o", values: [] } }].to_json + }, + as: :turbo_stream + + query = view.reload.query + expect(query.filters.map(&:name)).to contain_exactly(:manual_sort) + expect(query).to be_manually_sorted + end + it "closes the dialog and replaces the tab nav and content in place" do perform @@ -88,4 +157,65 @@ RSpec.describe "ResourcePlannerViews requests", end end end + + describe "work package picker for manually hand-picked views" do + shared_let(:work_package) { create(:work_package, project:) } + + let(:manual_view) do + ResourceWorkPackageList.create!( + name: "Hand-picked", + parent: resource_planner, + project:, + principal: user, + query: Query.new_default(project:, user:).tap do |query| + query.name = "Hand-picked query" + query.add_filter("manual_sort", "ow", []) + query.sort_criteria = [%w[manual_sorting asc]] + query.save! + end + ) + end + + describe "GET new_work_package" do + it "renders the search dialog" do + get new_work_package_project_resource_planner_view_path(project, resource_planner, manual_view), + as: :turbo_stream + + expect(response).to have_http_status(:ok) + expect(response.body).to include(ResourcePlannerViews::WorkPackageList::AddWorkPackageDialogComponent::DIALOG_ID) + end + end + + describe "POST add_work_package" do + subject(:perform) do + post work_packages_project_resource_planner_view_path(project, resource_planner, manual_view), + params: { work_package_id: work_package.id }, + as: :turbo_stream + end + + it "appends the work package to the query and re-renders the list" do + expect { perform }.to change { manual_view.query.ordered_work_packages.count }.by(1) + + expect(response).to have_http_status(:ok) + expect(manual_view.query.ordered_work_packages.map(&:work_package)).to include(work_package) + expect(response.body).to include('target="resource-planner-views-content-component"') + end + + it "does not add the same work package twice" do + manual_view.query.ordered_work_packages.create!(work_package:, position: 1) + + expect { perform }.not_to(change { manual_view.query.ordered_work_packages.count }) + end + + it "returns a client error for a work package outside the project" do + other = create(:work_package) + + post work_packages_project_resource_planner_view_path(project, resource_planner, manual_view), + params: { work_package_id: other.id }, + as: :turbo_stream + + expect(response).to have_http_status(:bad_request) + end + end + end end diff --git a/modules/resource_management/spec/services/resource_planner_views/update_service_spec.rb b/modules/resource_management/spec/services/resource_planner_views/update_service_spec.rb index 28e08612949..bb2d0b3bc42 100644 --- a/modules/resource_management/spec/services/resource_planner_views/update_service_spec.rb +++ b/modules/resource_management/spec/services/resource_planner_views/update_service_spec.rb @@ -52,6 +52,16 @@ RSpec.describe ResourcePlannerViews::UpdateService, type: :model do expect(view.reload.name).to eq("Updated") end + it "persists filter changes onto the associated query" do + described_class + .new(user:, model: view) + .call(name: "Updated", + filter_mode: "automatic", + filters: [{ assigned_to_id: { operator: "=", values: [user.id.to_s] } }].to_json) + + expect(view.query.reload.filters.map(&:name)).to contain_exactly(:assigned_to_id) + end + context "when the user is not allowed to manage the parent planner" do let(:other_user) { create(:user) }