mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
Correct filter creation, manual adding of work packages
This commit is contained in:
@@ -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
|
||||
|
||||
+5
-1
@@ -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?
|
||||
|
||||
+1
@@ -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|
|
||||
|
||||
+1
@@ -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|
|
||||
|
||||
+66
@@ -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
|
||||
%>
|
||||
+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 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
|
||||
+34
-6
@@ -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
|
||||
%>
|
||||
|
||||
+1
@@ -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 }
|
||||
)
|
||||
|
||||
+61
-2
@@ -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
|
||||
|
||||
+8
-1
@@ -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" }
|
||||
|
||||
+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 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+41
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[/<input[^>]*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
|
||||
|
||||
+10
@@ -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) }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user