Correct filter creation, manual adding of work packages

This commit is contained in:
Klaus Zanders
2026-06-01 15:25:48 +02:00
parent 4ac7e89709
commit 6cc78a5ec0
21 changed files with 610 additions and 20 deletions
+4 -1
View File
@@ -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
@@ -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?
@@ -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|
@@ -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|
@@ -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
%>
@@ -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
@@ -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
%>
@@ -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 }
)
@@ -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
@@ -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" }
@@ -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
@@ -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
+8 -1
View File
@@ -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
@@ -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) }