diff --git a/app/components/admin/custom_fields/custom_field_projects/row_component.rb b/app/components/admin/custom_fields/custom_field_projects/row_component.rb
index 56b53d143ff3..c2a520950ebf 100644
--- a/app/components/admin/custom_fields/custom_field_projects/row_component.rb
+++ b/app/components/admin/custom_fields/custom_field_projects/row_component.rb
@@ -30,7 +30,7 @@
module Admin
module CustomFields
module CustomFieldProjects
- class RowComponent < Projects::RowComponent
+ class RowComponent < ::Projects::RowComponent
include OpTurbo::Streamable
def wrapper_uniq_by
diff --git a/app/components/admin/custom_fields/custom_field_projects/table_component.rb b/app/components/admin/custom_fields/custom_field_projects/table_component.rb
index 8ef733b50e01..b7e2472bad8c 100644
--- a/app/components/admin/custom_fields/custom_field_projects/table_component.rb
+++ b/app/components/admin/custom_fields/custom_field_projects/table_component.rb
@@ -30,7 +30,7 @@
module Admin
module CustomFields
module CustomFieldProjects
- class TableComponent < Projects::TableComponent
+ class TableComponent < ::Projects::TableComponent
include ::Projects::Concerns::TableComponent::StreamablePaginationLinksConstraints
def columns
diff --git a/app/components/admin/custom_fields/edit_form_header_component.rb b/app/components/admin/custom_fields/edit_form_header_component.rb
index c67b936dcd15..addc901f510f 100644
--- a/app/components/admin/custom_fields/edit_form_header_component.rb
+++ b/app/components/admin/custom_fields/edit_form_header_component.rb
@@ -32,11 +32,12 @@ module CustomFields
class EditFormHeaderComponent < ApplicationComponent
include ::CustomFields::AdminRoutes
- def initialize(custom_field:, selected:, section_label:, page_title:, **)
+ def initialize(custom_field:, selected:, section_label:, page_title:, project: nil, **)
@custom_field = custom_field
@selected = selected
@section_label = section_label
@page_title = page_title
+ @project = project
super(custom_field, **)
end
@@ -61,7 +62,8 @@ def items_tab
def extra_tabs
return [] unless @custom_field.is_a?(WorkPackageCustomField)
- [{ name: "attribute_help_text", path: attribute_help_text_path(@custom_field),
+ [{ name: "projects", path: projects_path(@custom_field), label: t(:label_project_plural) },
+ { name: "attribute_help_text", path: attribute_help_text_path(@custom_field),
label: AttributeHelpText.human_attribute_name(:help_text) }]
end
@@ -75,10 +77,22 @@ def breadcrumbs_items
{ href: admin_index_path, text: t(:label_administration) },
{ href: index_path(@custom_field), text: @section_label },
{ href: index_path(@custom_field), text: @page_title },
- helpers.nested_breadcrumb_element(helpers.label_for_custom_field_format(model.field_format),
- @custom_field.attribute_in_database("name"))
+ *custom_field_breadcrumbs_items
]
end
+
+ def custom_field_breadcrumbs_items
+ name = @custom_field.attribute_in_database("name")
+
+ if @project
+ [
+ { href: projects_path(@custom_field), text: name },
+ helpers.nested_breadcrumb_element(Project.model_name.human, @project.name)
+ ]
+ else
+ [helpers.nested_breadcrumb_element(helpers.label_for_custom_field_format(model.field_format), name)]
+ end
+ end
end
end
end
diff --git a/app/components/admin/custom_fields/projects/row_component.rb b/app/components/admin/custom_fields/projects/row_component.rb
new file mode 100644
index 000000000000..9e41a26e0b9c
--- /dev/null
+++ b/app/components/admin/custom_fields/projects/row_component.rb
@@ -0,0 +1,76 @@
+# 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 Admin
+ module CustomFields
+ module Projects
+ class RowComponent < OpPrimer::BorderBoxRowComponent
+ include ::CustomFields::AdminRoutes
+
+ alias_method :project, :model
+
+ def row_data = { test_selector: "custom-field-projects-row-#{project.id}" }
+
+ def name
+ render(::Projects::InfoLineComponent.new(project:))
+ end
+
+ def types
+ safe_join(table.types_of(project).map { |type| link_to(type.name, type_settings_path(type_id: type.id)) }, ", ")
+ end
+
+ def project_specific_values
+ I18n.t("custom_fields.admin.projects.enabled_values",
+ count: table.usable_value_counts.fetch(project.id),
+ total: table.value_count)
+ end
+
+ def button_links
+ [configure_button]
+ end
+
+ private
+
+ def configure_button
+ render(
+ Primer::Beta::IconButton.new(
+ icon: :"project-symlink",
+ tag: :a,
+ href: project_values_path(table.custom_field, project),
+ scheme: :default,
+ aria: { label: I18n.t("custom_fields.admin.projects.configure", project: project.name) },
+ tooltip_direction: :w
+ )
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/app/components/admin/custom_fields/projects/table_component.rb b/app/components/admin/custom_fields/projects/table_component.rb
new file mode 100644
index 000000000000..116a406c74e4
--- /dev/null
+++ b/app/components/admin/custom_fields/projects/table_component.rb
@@ -0,0 +1,112 @@
+# 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 Admin
+ module CustomFields
+ module Projects
+ class TableComponent < OpPrimer::BorderBoxTableComponent
+ FRAME_ID = "custom-field-projects-list"
+
+ columns :name, :types, :project_specific_values
+ main_column :name
+ mobile_labels :types, :project_specific_values
+
+ attr_reader :custom_field
+
+ def initialize(rows:, custom_field:, type_ids_by_project_id:, filtered: false)
+ super(rows:)
+
+ @custom_field = custom_field
+ @type_ids_by_project_id = type_ids_by_project_id
+ @filtered = filtered
+ end
+
+ def headers
+ [
+ [:name, { caption: Project.model_name.human }],
+ [:types, { caption: I18n.t("custom_fields.admin.projects.types_column") }],
+ if project_specific_values?
+ [:project_specific_values,
+ { caption: CustomField.human_attribute_name(:project_specific_value_restriction) }]
+ end
+ ].compact
+ end
+
+ def skip_column?(column)
+ column == :project_specific_values && !project_specific_values?
+ end
+
+ def project_specific_values?
+ custom_field.project_specific_value_restriction.present?
+ end
+
+ def types_of(project)
+ types_by_id.values_at(*@type_ids_by_project_id.fetch(project.id, [])).compact.sort_by(&:position)
+ end
+
+ def value_count
+ @value_count ||= custom_field.restrictable_values.count
+ end
+
+ def usable_value_counts
+ @usable_value_counts ||= custom_field.usable_value_counts_in(Project.where(id: rows.map(&:id)))
+ end
+
+ def has_actions? = project_specific_values?
+
+ def mobile_title = I18n.t(:label_project_plural)
+
+ def blank_title
+ if @filtered
+ I18n.t("custom_fields.admin.projects.filter_no_results.title")
+ else
+ I18n.t("custom_fields.admin.projects.empty_state.title")
+ end
+ end
+
+ def blank_description
+ if @filtered
+ I18n.t("custom_fields.admin.projects.filter_no_results.description")
+ else
+ I18n.t("custom_fields.admin.projects.empty_state.description")
+ end
+ end
+
+ def blank_icon = @filtered ? :search : :project
+
+ private
+
+ def types_by_id
+ @types_by_id ||= ::Type.where(id: @type_ids_by_project_id.values.flatten).index_by(&:id)
+ end
+ end
+ end
+ end
+end
diff --git a/app/components/admin/custom_fields/projects/values_component.html.erb b/app/components/admin/custom_fields/projects/values_component.html.erb
new file mode 100644
index 000000000000..b529fc791f14
--- /dev/null
+++ b/app/components/admin/custom_fields/projects/values_component.html.erb
@@ -0,0 +1,99 @@
+<%#-- 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, see .
+
+See COPYRIGHT and LICENSE files for more details.
+
+++#%>
+
+<%= helpers.turbo_frame_tag("custom-field-project-values", **frame_arguments) do %>
+<%=
+ render(Primer::Beta::BorderBox.new(test_selector: "custom-field-project-values", padding: :condensed)) do |box|
+ box.with_header do
+ flex_layout(justify_content: :space_between, align_items: :center) do |header|
+ header.with_column do
+ render(Primer::Beta::Text.new(font_weight: :bold)) { custom_field.name }
+ end
+
+ header.with_column do
+ safe_join(
+ [
+ render(
+ Primer::Beta::Button.new(
+ scheme: :invisible,
+ size: :small,
+ tag: :a,
+ href: enable_all_path,
+ data: { turbo_method: :post }
+ )
+ ) do |button|
+ button.with_leading_visual_icon(icon: :"check-circle")
+ t("custom_fields.admin.projects.enable_all")
+ end,
+ render(
+ Primer::Beta::Button.new(
+ scheme: :invisible,
+ size: :small,
+ tag: :a,
+ href: disable_all_path,
+ data: { turbo_method: :post }
+ )
+ ) do |button|
+ button.with_leading_visual_icon(icon: :"x-circle")
+ t("custom_fields.admin.projects.disable_all")
+ end
+ ]
+ )
+ end
+ end
+ end
+
+ rows.each do |row|
+ box.with_row(test_selector: "custom-field-project-value-#{row.id}") do
+ flex_layout(justify_content: :space_between, align_items: :center) do |line|
+ line.with_column(pl: indentation(row)) do
+ safe_join(
+ [
+ render(Primer::Beta::Text.new) { row.label },
+ (render(Primer::Beta::Text.new(color: :muted, font_size: :small, ml: 2)) { row.caption } if row.caption)
+ ]
+ )
+ end
+
+ line.with_column do
+ render(
+ Primer::Alpha::ToggleSwitch.new(
+ src: toggle_path(row),
+ csrf_token: helpers.form_authenticity_token,
+ checked: row.enabled,
+ size: :small,
+ aria: { label: row.label }
+ )
+ )
+ end
+ end
+ end
+ end
+ end
+%>
+<% end %>
diff --git a/app/components/admin/custom_fields/projects/values_component.rb b/app/components/admin/custom_fields/projects/values_component.rb
new file mode 100644
index 000000000000..96e98303e446
--- /dev/null
+++ b/app/components/admin/custom_fields/projects/values_component.rb
@@ -0,0 +1,128 @@
+# 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 Admin
+ module CustomFields
+ module Projects
+ class ValuesComponent < ApplicationComponent
+ include OpPrimer::ComponentHelpers
+
+ Row = Data.define(:id, :label, :depth, :caption, :enabled)
+
+ MAX_INDENTATION = 12
+
+ def initialize(custom_field:, project:)
+ super()
+
+ @custom_field = custom_field
+ @project = project
+ end
+
+ private
+
+ attr_reader :custom_field, :project
+
+ def rows
+ custom_field.list? ? option_rows : item_rows
+ end
+
+ def option_rows
+ custom_field.custom_options.map do |option|
+ Row.new(id: option.id, label: option.value, depth: 0, caption: nil, enabled: enabled?(option))
+ end
+ end
+
+ # The items come in pre-order, so every parent's depth is known before its children.
+ def item_rows
+ depths = { custom_field.hierarchy_root.id => -1 }
+
+ hierarchy_items.map do |item|
+ depths[item.id] = depths.fetch(item.parent_id) + 1
+ item_row(item, depths[item.id])
+ end
+ end
+
+ def item_row(item, depth)
+ Row.new(id: item.id, label: item.label, depth:, caption: item_caption(item), enabled: enabled?(item))
+ end
+
+ def hierarchy_items
+ ::CustomFields::Hierarchy::HierarchicalItemService
+ .new
+ .get_descendants(item: custom_field.hierarchy_root, include_self: false)
+ .value_or([])
+ end
+
+ def item_caption(item)
+ parts = []
+ parts << NumberFormatHelper.number_with_limit(item.weight) if custom_field.field_format_weighted_item_list?
+ parts << I18n.t("custom_fields.admin.hierarchy.subitems", count: item.children_count) if item.children_count.positive?
+ parts.join(" · ").presence
+ end
+
+ def enabled?(value)
+ custom_field.usable_value_id?(project, value.id)
+ end
+
+ def indentation(row)
+ [row.depth * 3, MAX_INDENTATION].min
+ end
+
+ # Toggling a hierarchy item can change other items as well, so the list is reloaded afterwards.
+ def frame_arguments
+ {
+ refresh: :morph,
+ data: {
+ controller: "reload-frame-on-event",
+ reload_frame_on_event_event_name_value: "toggleSwitchSuccess",
+ reload_frame_on_event_url_value: values_path
+ }
+ }
+ end
+
+ def values_path
+ project_values_admin_settings_work_package_custom_field_path(custom_field, project)
+ end
+
+ def toggle_path(row)
+ toggle_project_value_admin_settings_work_package_custom_field_path(custom_field, project, row.id)
+ end
+
+ def enable_all_path
+ enable_all_project_values_admin_settings_work_package_custom_field_path(custom_field, project)
+ end
+
+ def disable_all_path
+ disable_all_project_values_admin_settings_work_package_custom_field_path(custom_field, project)
+ end
+ end
+ end
+ end
+end
diff --git a/app/components/custom_fields/details_component.html.erb b/app/components/custom_fields/details_component.html.erb
index 4c6560c51711..4fbe3b8322ea 100644
--- a/app/components/custom_fields/details_component.html.erb
+++ b/app/components/custom_fields/details_component.html.erb
@@ -15,14 +15,19 @@
end
end
- content.with_row do
- settings_primer_form_with(
- model:,
- scope: :custom_field,
- id: "custom_field_form",
- url: form_url,
- method: form_method
- ) { |form| render CustomFields::DetailsForm.new(form) }
+ content.with_row(**form_row_arguments) do
+ safe_join(
+ [
+ settings_primer_form_with(
+ model:,
+ scope: :custom_field,
+ id: "custom_field_form",
+ url: form_url,
+ method: form_method
+ ) { |form| render CustomFields::DetailsForm.new(form) },
+ (disable_project_specific_values_dialog if confirm_disabling_project_specific_values?)
+ ]
+ )
end
end
end
diff --git a/app/components/custom_fields/details_component.rb b/app/components/custom_fields/details_component.rb
index 90cd4ddf9cda..86a9229be0ba 100644
--- a/app/components/custom_fields/details_component.rb
+++ b/app/components/custom_fields/details_component.rb
@@ -54,6 +54,34 @@ def form_method
model.new_record? ? :post : :put
end
+ def confirm_disabling_project_specific_values?
+ custom_field.persisted? && custom_field.project_specific_value_restriction_allow?
+ end
+
+ def form_row_arguments
+ return {} unless confirm_disabling_project_specific_values?
+
+ { data: { controller: "checkbox-confirmation" } }
+ end
+
+ def disable_project_specific_values_dialog
+ render(
+ Primer::OpenProject::DangerDialog.new(
+ id: "disable-project-specific-values-dialog",
+ title: t("custom_fields.disable_project_specific_values.title"),
+ confirm_button_text: t("custom_fields.disable_project_specific_values.button"),
+ cancel_button_text: t(:button_close),
+ data: { checkbox_confirmation_target: "dialog" }
+ )
+ ) do |dialog|
+ dialog.with_confirmation_message do |message|
+ message.with_heading(tag: :h2) { t("custom_fields.disable_project_specific_values.title") }
+ message.with_description_content(t("custom_fields.disable_project_specific_values.description"))
+ end
+ dialog.with_confirmation_check_box_content(t("custom_fields.disable_project_specific_values.confirmation"))
+ end
+ end
+
def enterprise_addon_key
ENTERPRISE_GUARDED.dig(custom_field.field_format, :key)
end
diff --git a/app/controllers/admin/custom_fields/hierarchy/items_controller.rb b/app/controllers/admin/custom_fields/hierarchy/items_controller.rb
index 1f193d12a96a..e4249295afbc 100644
--- a/app/controllers/admin/custom_fields/hierarchy/items_controller.rb
+++ b/app/controllers/admin/custom_fields/hierarchy/items_controller.rb
@@ -31,7 +31,7 @@ module Admin
module CustomFields
module Hierarchy
class ItemsController < ItemsBaseController
- menu_item :custom_fields
+ menu_item :work_package_custom_fields
private
diff --git a/app/controllers/admin/settings/work_package_custom_fields_controller.rb b/app/controllers/admin/settings/work_package_custom_fields_controller.rb
index df0c08f3fb5b..90f4b126d71e 100644
--- a/app/controllers/admin/settings/work_package_custom_fields_controller.rb
+++ b/app/controllers/admin/settings/work_package_custom_fields_controller.rb
@@ -30,16 +30,84 @@
module Admin
module Settings
class WorkPackageCustomFieldsController < CustomFieldsBaseController
+ include PaginationHelper
+
menu_item :work_package_custom_fields
+ PROJECT_VALUES_ACTIONS = %i[project_values toggle_project_value enable_all_project_values
+ disable_all_project_values].freeze
+
+ # Redeclaring :find_custom_field by name would drop the base controller's callback for its own actions.
+ before_action(only: [:projects, *PROJECT_VALUES_ACTIONS]) { find_custom_field }
+ before_action :require_project_specific_values, only: PROJECT_VALUES_ACTIONS
+ before_action :find_project_using_custom_field, only: PROJECT_VALUES_ACTIONS
+
def index
super
@project_counts = WorkPackageCustomField.project_counts
end
+ def projects
+ @query = projects_query
+ @filtered = @query.find_active_filter(:name_and_identifier).present? ||
+ @query.find_active_filter(:project_specific_values).present?
+ @type_ids_by_project_id = @custom_field.type_ids_by_project_id
+ @projects = @query.results.paginate(page: page_param, per_page: per_page_param)
+
+ render layout: false if turbo_frame_request?
+ end
+
+ def project_values; end
+
+ def toggle_project_value
+ call = ::CustomFields::ProjectSpecificValues::SetService
+ .new(user: current_user, model: @custom_field)
+ .call(project: @configured_project,
+ value: @custom_field.restrictable_values.find(params.expect(:value_id)),
+ enabled: params[:value] == "1")
+
+ render json: {}, status: call.success? ? :ok : :unprocessable_entity
+ end
+
+ def enable_all_project_values
+ update_all_project_values(::CustomFields::ProjectSpecificValues::EnableAllService)
+ end
+
+ def disable_all_project_values
+ update_all_project_values(::CustomFields::ProjectSpecificValues::DisableAllService)
+ end
+
protected
+ def require_project_specific_values
+ render_404 if @custom_field.project_specific_value_restriction.nil?
+ end
+
+ # Not @project, as that would render the project menu instead of the administration menu.
+ def find_project_using_custom_field
+ @configured_project = Project.active
+ .with_available_custom_fields([@custom_field.id])
+ .find(params.expect(:project_id))
+ end
+
+ def projects_query
+ ParamsToQueryService
+ .new(Project, current_user, query_class: ProjectQuery)
+ .call(params)
+ .where(:active, "=", OpenProject::Database::DB_VALUE_TRUE)
+ .where(:available_custom_fields_projects, "=", [@custom_field.id.to_s])
+ .order(name: :asc)
+ end
+
+ def update_all_project_values(service_class)
+ call = service_class.new(user: current_user, model: @custom_field).call(project: @configured_project)
+ flash[:error] = call.message unless call.success?
+
+ redirect_to project_values_admin_settings_work_package_custom_field_path(@custom_field, @configured_project),
+ status: :see_other
+ end
+
def custom_field_class
WorkPackageCustomField
end
diff --git a/app/forms/custom_fields/details_form.rb b/app/forms/custom_fields/details_form.rb
index 12d4add85df2..66a86b53ee31 100644
--- a/app/forms/custom_fields/details_form.rb
+++ b/app/forms/custom_fields/details_form.rb
@@ -181,6 +181,18 @@ class DetailsForm < ApplicationForm
)
end
+ if show_project_specific_value_restriction_field?
+ details_form.check_box(
+ name: :project_specific_value_restriction,
+ label: I18n.t("custom_fields.project_specific_value_restriction_label"),
+ caption: instructions(:project_specific_value_restriction),
+ value: "allow",
+ unchecked_value: "",
+ checked: model.project_specific_value_restriction_allow?,
+ data: { checkbox_confirmation_target: "checkbox" }
+ )
+ end
+
if show_is_filter_field?
details_form.check_box(
name: :is_filter,
@@ -309,6 +321,10 @@ def show_is_filter_field?
model.is_a?(WorkPackageCustomField)
end
+ def show_project_specific_value_restriction_field?
+ model.is_a?(WorkPackageCustomField) && %w[list hierarchy weighted_item_list].include?(model.field_format)
+ end
+
def show_is_searchable_field?
(model.is_a?(WorkPackageCustomField) || model.is_a?(ProjectCustomField)) &&
%w[bool date datetime float int user version hierarchy weighted_item_list calculated_value].exclude?(model.field_format)
diff --git a/app/helpers/custom_fields/admin_routes.rb b/app/helpers/custom_fields/admin_routes.rb
index 16b0e9d94ce2..58ae55e1a27c 100644
--- a/app/helpers/custom_fields/admin_routes.rb
+++ b/app/helpers/custom_fields/admin_routes.rb
@@ -63,6 +63,14 @@ def reorder_alphabetical_path(custom_field)
op_routes.public_send(:"reorder_alphabetical_admin_settings_#{cf_singular(custom_field)}_path", custom_field)
end
+ def projects_path(custom_field)
+ op_routes.public_send(:"projects_admin_settings_#{cf_singular(custom_field)}_path", custom_field)
+ end
+
+ def project_values_path(custom_field, project)
+ op_routes.public_send(:"project_values_admin_settings_#{cf_singular(custom_field)}_path", custom_field, project)
+ end
+
def attribute_help_text_path(custom_field)
op_routes.public_send(:"attribute_help_text_admin_settings_#{cf_singular(custom_field)}_path", custom_field)
end
diff --git a/app/models/custom_field/project_specific_values.rb b/app/models/custom_field/project_specific_values.rb
index 4751f17bc38c..02a7c808dcda 100644
--- a/app/models/custom_field/project_specific_values.rb
+++ b/app/models/custom_field/project_specific_values.rb
@@ -70,6 +70,12 @@ def keep_usable_values(values, project)
values.is_a?(Array) ? values & usable_ids : values.presence_in(usable_ids)
end
+ def usable_value_counts_in(projects)
+ count_sql = usable_values_in_reach("SELECT projects.id").unscope(:order).select(Arel.star.count).to_sql
+
+ projects.unscope(:order).pluck(:id, Arel.sql("(#{count_sql})")).to_h
+ end
+
# SQL condition on whether the value with the id given by value_id_sql is usable in the project
# selected (as id) by project_sql. Both may reference columns of the surrounding query.
def usable_value_condition(value_id_sql, project_sql)
diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb
index 631816a2f9ec..6324bf2ace17 100644
--- a/app/models/permitted_params.rb
+++ b/app/models/permitted_params.rb
@@ -524,6 +524,7 @@ def self.permitted_attributes
:allow_non_open_versions,
:has_comment,
:visible_on_user_card,
+ :project_specific_value_restriction,
{ custom_options_attributes: %i(id value default_value position) },
{ type_ids: [] }
],
diff --git a/app/models/queries/filters/available_filters.rb b/app/models/queries/filters/available_filters.rb
index 7f45a71c66e2..e9783499a5de 100644
--- a/app/models/queries/filters/available_filters.rb
+++ b/app/models/queries/filters/available_filters.rb
@@ -63,7 +63,9 @@ def available_filters
def available_advanced_filters
available_filters.reject do |filter|
# Skip the name filter as we have it present as a permanent filter with a text input.
- filter.is_a?(::Queries::Projects::Filters::NameAndIdentifierFilter)
+ filter.is_a?(::Queries::Projects::Filters::NameAndIdentifierFilter) ||
+ # Only used by the projects tab of a custom field.
+ filter.is_a?(::Queries::Projects::Filters::ProjectSpecificValuesFilter)
end
end
diff --git a/app/models/queries/projects.rb b/app/models/queries/projects.rb
index 963d047c6770..2f49830ec401 100644
--- a/app/models/queries/projects.rb
+++ b/app/models/queries/projects.rb
@@ -48,6 +48,7 @@ module Queries::Projects
filter Filters::PortfolioFilter
filter Filters::PrincipalFilter
filter Filters::ProgramFilter
+ filter Filters::ProjectSpecificValuesFilter
filter Filters::ProjectStatusFilter
filter Filters::PublicFilter
filter Filters::TemplatedFilter
diff --git a/app/models/queries/projects/filters/project_specific_values_filter.rb b/app/models/queries/projects/filters/project_specific_values_filter.rb
new file mode 100644
index 000000000000..be7a6be8547f
--- /dev/null
+++ b/app/models/queries/projects/filters/project_specific_values_filter.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+class Queries::Projects::Filters::ProjectSpecificValuesFilter < Queries::Projects::Filters::Base
+ validate :administrator
+
+ def self.key
+ :project_specific_values
+ end
+
+ def type
+ :list
+ end
+
+ def allowed_values
+ @allowed_values ||= WorkPackageCustomField
+ .where.not(project_specific_value_restriction: nil)
+ .order(:name)
+ .pluck(:name, :id)
+ end
+
+ def available?
+ User.current.admin?
+ end
+
+ def available_operators
+ [Queries::Operators::Equals]
+ end
+
+ def apply_to(_query_scope)
+ super.where(id: CustomField::ProjectSpecificValueRestriction.where(custom_field_id: values).select(:project_id))
+ end
+
+ def where
+ nil
+ end
+
+ private
+
+ # Usable through the API, too, where availability is not checked.
+ def administrator
+ errors.add(:base, I18n.t(:"activerecord.errors.messages.filter_does_not_exist")) unless User.current.admin?
+ end
+end
diff --git a/app/models/work_package_custom_field.rb b/app/models/work_package_custom_field.rb
index 77b664a27611..813abd171964 100644
--- a/app/models/work_package_custom_field.rb
+++ b/app/models/work_package_custom_field.rb
@@ -28,9 +28,16 @@
#++
class WorkPackageCustomField < CustomField
+ def self.project_counts
+ project_type_usages
+ .group("#{FormConfigurationAttribute.table_name}.custom_field_id")
+ .distinct
+ .count(:project_id)
+ end
+
# A field reaches a project when the variant that project applies shows it. An archived project
# is no reach, which is the same answer CustomFields::DetailsComponent gives.
- def self.project_counts
+ def self.project_type_usages
memberships = FormConfigurationAttribute.table_name
form_join, form_configuration_id, excluded =
TypeVariant.form_configuration_join("project_types.variant_id")
@@ -42,9 +49,15 @@ def self.project_counts
"AND #{memberships}.custom_field_id IS NOT NULL " \
"AND #{memberships}.form_configuration_group_id IS NOT NULL AND #{exclusion}"))
.where(project_id: Project.active.select(:id))
- .group("#{memberships}.custom_field_id")
+ end
+
+ def type_ids_by_project_id
+ self.class.project_type_usages
+ .where(FormConfigurationAttribute.table_name => { custom_field_id: id })
.distinct
- .count(:project_id)
+ .pluck(:project_id, :type_id)
+ .group_by(&:first)
+ .transform_values { |pairs| pairs.map(&:last) }
end
has_many :form_configuration_memberships, -> { active },
diff --git a/app/views/admin/settings/work_package_custom_fields/project_values.html.erb b/app/views/admin/settings/work_package_custom_fields/project_values.html.erb
new file mode 100644
index 000000000000..052cd0423b85
--- /dev/null
+++ b/app/views/admin/settings/work_package_custom_fields/project_values.html.erb
@@ -0,0 +1,54 @@
+<%#-- 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, see .
+
+See COPYRIGHT and LICENSE files for more details.
+
+++#%>
+
+<% html_title t(:label_administration), "#{CustomField.model_name.human} #{h @custom_field.name}", @configured_project.name %>
+
+<%=
+ render(
+ Admin::CustomFields::EditFormHeaderComponent.new(
+ custom_field: @custom_field,
+ selected: :projects,
+ section_label:,
+ page_title: custom_field_page_title,
+ project: @configured_project
+ )
+ )
+%>
+
+<%=
+ render(Primer::Alpha::Banner.new(scheme: :default, icon: :info, mb: 3)) do
+ link_translate(
+ "custom_fields.admin.projects.editing_values_notice",
+ i18n_args: { project: @configured_project.name },
+ links: { project_url: project_path(@configured_project) },
+ external: false
+ )
+ end
+%>
+
+<%= render(Admin::CustomFields::Projects::ValuesComponent.new(custom_field: @custom_field, project: @configured_project)) %>
diff --git a/app/views/admin/settings/work_package_custom_fields/projects.html.erb b/app/views/admin/settings/work_package_custom_fields/projects.html.erb
new file mode 100644
index 000000000000..98c84cdffe5f
--- /dev/null
+++ b/app/views/admin/settings/work_package_custom_fields/projects.html.erb
@@ -0,0 +1,122 @@
+<%#-- 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, see .
+
+See COPYRIGHT and LICENSE files for more details.
+
+++#%>
+
+<% html_title t(:label_administration), "#{CustomField.model_name.human} #{h @custom_field.name}", t(:label_project_plural) %>
+
+<%=
+ render(
+ Admin::CustomFields::EditFormHeaderComponent.new(
+ custom_field: @custom_field,
+ selected: :projects,
+ section_label:,
+ page_title: custom_field_page_title
+ )
+ )
+%>
+
+<% frame_id = Admin::CustomFields::Projects::TableComponent::FRAME_ID %>
+<% clear_button_id = "custom-field-projects-filter-clear" %>
+
+<%= turbo_frame_tag(frame_id, target: "_top") do %>
+ <%= render(
+ Primer::OpenProject::SubHeader.new(
+ data: {
+ controller: "filter--filters-form",
+ "filter--filters-form-output-format-value": "json",
+ "filter--filters-form-turbo-frame-request-value": frame_id,
+ "filter--filters-form-clear-button-id-value": clear_button_id,
+ "filter--filters-form-current-filters-value": OpPrimer::QuickFilter.serialize(@query.filters).to_json
+ }
+ )
+ ) do |subheader| %>
+ <% subheader.with_filter_input(
+ name: "name_and_identifier",
+ id: "custom-field-projects-filter-name",
+ label: t("custom_fields.admin.projects.filter"),
+ value: @query.find_active_filter(:name_and_identifier)&.values&.first,
+ placeholder: t("custom_fields.admin.projects.filter"),
+ leading_visual: { icon: :search, size: :small },
+ show_clear_button: true,
+ clear_button_id:,
+ data: {
+ turbo_permanent: true,
+ "filter-name": "name_and_identifier",
+ "filter-type": "string",
+ "filter-operator": "~",
+ "filter--filters-form-target": "simpleFilter filterValueContainer simpleValue"
+ }
+ ) %>
+
+ <% if @custom_field.project_specific_value_restriction.present? %>
+ <% subheader.with_quick_filter do %>
+ <%= render(
+ OpPrimer::QuickFilter::SegmentedControlComponent.new(
+ name: t(:label_filter_plural),
+ query: @query,
+ filter_key: :project_specific_values,
+ path_args: [:projects, :admin, :settings, @custom_field]
+ )
+ ) do |control| %>
+ <% control.with_item(label: t("custom_fields.admin.projects.quick_filter.all"), value: nil) %>
+ <% control.with_item(
+ label: t("custom_fields.admin.projects.quick_filter.with_project_specific_values"),
+ value: @custom_field.id.to_s
+ ) %>
+ <% end %>
+ <% end %>
+ <% end %>
+ <% end %>
+
+ <%= render(OpPrimer::InsetBoxComponent.new(mb: 3)) do |box| %>
+ <% box.with_title_icon(icon: :info) %>
+ <% box.with_title(tag: :h3) { t("custom_fields.admin.projects.changes_heading") } %>
+ <% box.with_action_button(
+ tag: :a,
+ href: OpenProject::Static::Links.url_for(:sysadmin_docs, :form_configuration),
+ target: "_blank",
+ rel: "noopener",
+ data: { allow_external_link: true }
+ ) do |button|
+ button.with_trailing_visual_icon(icon: :"link-external")
+ t(:label_learn_more)
+ end %>
+
+ <%= render(Primer::Beta::Text.new(tag: :p, mb: 0)) { t("custom_fields.admin.projects.changes_text") } %>
+ <% end %>
+
+ <%=
+ render(
+ Admin::CustomFields::Projects::TableComponent.new(
+ rows: @projects,
+ custom_field: @custom_field,
+ type_ids_by_project_id: @type_ids_by_project_id,
+ filtered: @filtered
+ )
+ )
+ %>
+<% end %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 48a625deeb55..8ccb4d11a5a8 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -307,6 +307,7 @@ en:
project_creation_wizard_status_when_submitted: "Status when submitted"
project_creation_wizard_work_package_comment: "Work package comment"
project_creation_wizard_work_package_type: "Work package type"
+ project_specific_values: "Project-specific values"
project_types: "Types"
public_value:
false: "private"
@@ -2791,6 +2792,28 @@ en:
weight: "Weight"
notice:
remember_items_and_projects: "Remember to set items for this custom field, and to make sure it is used in at least one project."
+ projects:
+ changes_heading: "Important changes"
+ changes_text: >
+ Starting version 18.0, custom fields are no longer enabled or disabled at a project level.
+ They are included in forms, which are then used in different types and variants. The
+ following list shows projects that use types or variants using forms including this custom field.
+ configure: "Configure project-specific values for %{project}"
+ disable_all: "Disable all"
+ editing_values_notice: "You are currently editing the list of values visible for this custom field in the project [%{project}](project_url)."
+ empty_state:
+ description: "Add this custom field to the form of a type that is used in a project."
+ title: "No project uses this custom field"
+ enable_all: "Enable all"
+ enabled_values: "%{count} of %{total}"
+ filter: "Filter by text"
+ filter_no_results:
+ description: "Change the filters to see more projects."
+ title: "No projects match this filter"
+ quick_filter:
+ all: "All projects"
+ with_project_specific_values: "With project-specific values"
+ types_column: "Displayed in type"
role_assignment:
description: You can automatically grant a certain project role to any user assigned to this project attribute, regardless of that user’s original role in that project.
dialog:
@@ -2814,6 +2837,11 @@ en:
calculated_field_not_editable: "Non-editable attribute. This value is calculated automatically."
confirm_destroy_option: "Deleting an option will delete all of its occurrences (e.g. in work packages). Are you sure you want to delete it?"
contained_in_type: "Contained in type"
+ disable_project_specific_values:
+ button: "Apply"
+ confirmation: "I understand that any project-specific settings will be overridden"
+ description: "All values for this custom field will be available to all projects using it."
+ title: "Make all values available to all projects?"
enabled_in_project: "Enabled in project"
instructions:
admin_only:
@@ -2844,6 +2872,8 @@ en:
multi_select:
all: "Allows the user to assign multiple values to this custom field."
project: "Allows the user to assign multiple values to this attribute."
+ project_specific_value_restriction:
+ all: "Projects using this custom field will show the values you activate for them in the Projects tab. Each project's list is empty by default."
regexp:
all: "eg. ^[A-Z0-9]+$"
project: "eg. ^[A-Z0-9]+$"
@@ -2861,6 +2891,7 @@ en:
description: "In OpenProject, you can add custom fields to work packages, user profiles and even time log entries. These settings have been moved to their respective sections:"
heading: "Settings moved"
placeholder_version_select: "Work package or project selection is required first"
+ project_specific_value_restriction_label: "Manually choose which values each project can use"
reorder_alphabetical: "Reorder values alphabetically"
reorder_confirmation: "Warning: The current order of available values as well as all unsaved values will be lost. Are you sure you want to continue?"
semantic_keys:
diff --git a/config/routes.rb b/config/routes.rb
index 9811b594d267..ff99c2ee0e4d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1055,6 +1055,11 @@
put :update_attribute_help_text
get :list_items
+ get :projects
+ get "projects/:project_id", action: :project_values, as: :project_values
+ post "projects/:project_id/values/:value_id", action: :toggle_project_value, as: :toggle_project_value
+ post "projects/:project_id/enable_all", action: :enable_all_project_values, as: :enable_all_project_values
+ post "projects/:project_id/disable_all", action: :disable_all_project_values, as: :disable_all_project_values
get :deletion_dialog
end
diff --git a/config/static_links.yml b/config/static_links.yml
index 40b469976100..f28b25bafdbb 100644
--- a/config/static_links.yml
+++ b/config/static_links.yml
@@ -196,6 +196,8 @@ storage_docs:
sysadmin_docs:
roles_and_permissions:
href: https://www.openproject.org/docs/system-admin-guide/users-permissions/roles-permissions/#roles-and-permissions
+ form_configuration:
+ href: https://www.openproject.org/docs/system-admin-guide/manage-work-packages/work-package-types/form-configuration/
ldap:
href: https://www.openproject.org/docs/system-admin-guide/authentication/ldap-connections/
ldap_group_sync:
diff --git a/frontend/src/stimulus/controllers/dynamic/checkbox-confirmation.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/checkbox-confirmation.controller.spec.ts
new file mode 100644
index 000000000000..321e948aea5e
--- /dev/null
+++ b/frontend/src/stimulus/controllers/dynamic/checkbox-confirmation.controller.spec.ts
@@ -0,0 +1,136 @@
+//-- 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, see .
+//
+// See COPYRIGHT and LICENSE files for more details.
+//++
+
+import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers';
+import type CheckboxConfirmationControllerType from './checkbox-confirmation.controller';
+
+describe('Checkbox confirmation controller', () => {
+ let ctx:StimulusTestContext;
+ let CheckboxConfirmationController:typeof CheckboxConfirmationControllerType;
+
+ beforeAll(async () => {
+ ({ default: CheckboxConfirmationController } = await import('./checkbox-confirmation.controller'));
+ });
+
+ beforeEach(async () => {
+ ctx = await setupStimulusTest({
+ controllers: { 'checkbox-confirmation': CheckboxConfirmationController },
+ });
+ });
+
+ afterEach(() => {
+ ctx.dispose();
+ });
+
+ async function render({ checked, trigger }:{ checked:boolean, trigger?:string }) {
+ const triggerAttribute = trigger ? `data-checkbox-confirmation-trigger-value="${trigger}"` : '';
+
+ await ctx.mount(`
+
+
+
+
+ `);
+
+ return {
+ checkbox: document.querySelector('input[type="checkbox"]')!,
+ dialog: document.querySelector('dialog')!,
+ };
+ }
+
+ function toggle(checkbox:HTMLInputElement) {
+ checkbox.checked = !checkbox.checked;
+ checkbox.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+
+ function closed(dialog:HTMLDialogElement) {
+ return new Promise((resolve) => {
+ dialog.addEventListener('close', resolve, { once: true });
+ });
+ }
+
+ it('asks for confirmation when unchecking', async () => {
+ const { checkbox, dialog } = await render({ checked: true });
+
+ toggle(checkbox);
+
+ expect(dialog.open).toBe(true);
+ });
+
+ it('keeps the change once confirmed', async () => {
+ const { checkbox, dialog } = await render({ checked: true });
+
+ toggle(checkbox);
+ const dialogClosed = closed(dialog);
+ document.querySelector('[data-submit-dialog-id]')!.click();
+ await dialogClosed;
+
+ expect(checkbox.checked).toBe(false);
+ });
+
+ it('reverts the change when cancelling', async () => {
+ const { checkbox, dialog } = await render({ checked: true });
+
+ toggle(checkbox);
+ const dialogClosed = closed(dialog);
+ document.querySelector('[data-close-dialog-id]')!.click();
+ dialog.close();
+ await dialogClosed;
+
+ expect(checkbox.checked).toBe(true);
+ });
+
+ it('reverts the change when the dialog is closed without confirming', async () => {
+ const { checkbox, dialog } = await render({ checked: true });
+
+ toggle(checkbox);
+ const dialogClosed = closed(dialog);
+ dialog.close();
+ await dialogClosed;
+
+ expect(checkbox.checked).toBe(true);
+ });
+
+ it('does not ask for confirmation when changing to the other state', async () => {
+ const { checkbox, dialog } = await render({ checked: false });
+
+ toggle(checkbox);
+
+ expect(dialog.open).toBe(false);
+ });
+
+ it('asks for confirmation when checking with a checked trigger', async () => {
+ const { checkbox, dialog } = await render({ checked: false, trigger: 'checked' });
+
+ toggle(checkbox);
+
+ expect(dialog.open).toBe(true);
+ });
+});
diff --git a/frontend/src/stimulus/controllers/dynamic/checkbox-confirmation.controller.ts b/frontend/src/stimulus/controllers/dynamic/checkbox-confirmation.controller.ts
new file mode 100644
index 000000000000..bbfd86f2f1ae
--- /dev/null
+++ b/frontend/src/stimulus/controllers/dynamic/checkbox-confirmation.controller.ts
@@ -0,0 +1,90 @@
+//-- 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, see .
+//
+// See COPYRIGHT and LICENSE files for more details.
+//++
+
+import { Controller } from '@hotwired/stimulus';
+
+/**
+ * Opens a confirmation dialog when the checkbox is changed to the trigger state. The checkbox is
+ * changed back unless the change is confirmed with the dialog's submit button, which is marked by
+ * `data-submit-dialog-id` as with Primer dialogs.
+ */
+export default class CheckboxConfirmationController extends Controller {
+ static targets = ['checkbox', 'dialog'];
+
+ static values = {
+ trigger: { type: String, default: 'unchecked' },
+ };
+
+ declare readonly checkboxTarget:HTMLInputElement;
+ declare readonly dialogTarget:HTMLDialogElement;
+ declare readonly triggerValue:'checked'|'unchecked';
+
+ private confirmed = false;
+
+ checkboxTargetConnected(checkbox:HTMLInputElement):void {
+ checkbox.addEventListener('change', this.onChange);
+ }
+
+ checkboxTargetDisconnected(checkbox:HTMLInputElement):void {
+ checkbox.removeEventListener('change', this.onChange);
+ }
+
+ dialogTargetConnected(dialog:HTMLDialogElement):void {
+ dialog.addEventListener('click', this.onDialogClick);
+ dialog.addEventListener('close', this.onDialogClose);
+ }
+
+ dialogTargetDisconnected(dialog:HTMLDialogElement):void {
+ dialog.removeEventListener('click', this.onDialogClick);
+ dialog.removeEventListener('close', this.onDialogClose);
+ }
+
+ private onChange = ():void => {
+ if (this.checkboxTarget.checked === (this.triggerValue === 'checked')) {
+ this.confirmed = false;
+ this.dialogTarget.showModal();
+ }
+ };
+
+ // Primer closes the dialog on its own when the submit button is clicked.
+ private onDialogClick = (event:MouseEvent):void => {
+ if (!(event.target as Element).closest('[data-submit-dialog-id]')) {
+ return;
+ }
+
+ this.confirmed = true;
+ if (this.dialogTarget.open) {
+ this.dialogTarget.close();
+ }
+ };
+
+ private onDialogClose = ():void => {
+ if (!this.confirmed) {
+ this.checkboxTarget.checked = !this.checkboxTarget.checked;
+ }
+ };
+}
diff --git a/lookbook/docs/20-patterns/02-forms/03-interactivity.md.erb b/lookbook/docs/20-patterns/02-forms/03-interactivity.md.erb
index 419e32613f7c..575b6b9d9b45 100644
--- a/lookbook/docs/20-patterns/02-forms/03-interactivity.md.erb
+++ b/lookbook/docs/20-patterns/02-forms/03-interactivity.md.erb
@@ -69,6 +69,49 @@ Important data inputs:
- data: `{ target_name: "abc"}` or `data-target-name="abc"` allows you to define multiple sets of cause/effect handlers
- If you want the target to be visibly blocked, not hidden, then use `data: { set_visibility: "true" }`. By default, the field will be hidden and removed from DOM computation.
+## Confirming checkbox changes
+
+Some checkboxes have consequences that cannot be undone once the form is saved, for example disabling a setting removes all data configured for it. To ask for confirmation right when the checkbox is changed, use the Stimulus controller `checkbox-confirmation` together with a [Danger dialog](/lookbook/pages/components/dialogs/danger_dialog).
+
+<%= embed Patterns::CheckboxConfirmationPreview, :default %>
+
+To use it, attach the controller to an element containing both the checkbox and the dialog, and mark them as targets:
+
+```erb
+
+ <%%= primer_form_with(...) do |f| %>
+ <%%= render_inline_form(f) do |form| %>
+ <%% form.check_box(
+ name: :feature_enabled,
+ label: "Enable feature",
+ data: { checkbox_confirmation_target: "checkbox" }
+ ) %>
+ <%% end %>
+ <%% end %>
+
+ <%%= render(
+ Primer::OpenProject::DangerDialog.new(
+ id: "disable-feature-dialog",
+ title: "Disable feature",
+ confirm_button_text: "Disable",
+ data: { checkbox_confirmation_target: "dialog" }
+ )
+ ) do |dialog| %>
+ <%% dialog.with_confirmation_message do |message| %>
+ <%% message.with_heading(tag: :h2) { "Disable the feature?" } %>
+ <%% message.with_description_content("All data configured for it will be removed.") %>
+ <%% end %>
+ <%% end %>
+
+```
+
+How it behaves:
+
+- The dialog opens when the checkbox is unchecked. Pass `data-checkbox-confirmation-trigger-value="checked"` to ask for confirmation when it is checked instead.
+- The change is kept when it is confirmed with the dialog's submit button, i.e. the button marked with `data-submit-dialog-id`, which the Danger dialog renders as its confirm button.
+- Closing the dialog in any other way, by cancelling, closing it or pressing Escape, changes the checkbox back.
+- Only attach the controller when confirming is necessary, for example when there actually is data that would be removed. There is no option to skip the confirmation.
+- The dialog must not have `form_arguments`, as the checkbox is saved together with its own form.
## Advanced Interactivity by previewing forms before submission
@@ -130,7 +173,6 @@ In some cases we might want to further customize the triggering behavior, for ex
**Important note:** Javascript rendered elements inside the form such as the datepicker above, could be broken after the form update. This happens, because the datepicker input get replaced without re-initializing the datepicker library. To fix the issue, we can either avoid updating the datepicker input elements using "data-turbo-permanent", or we can programmatically re-initialize them after the form update. In case of angular components, this issue is solved automatically by not updating the components. For more info see the `turbo:before-morph-element` eventlistener in the `turbo-global-listeners.ts`.
-
### How to handle the form preview actions on the backend?
The form preview mechanism shown above can be nicely tied with our existing ActiveRecord object saving services.
@@ -139,7 +181,7 @@ The form preview mechanism shown above can be nicely tied with our existing Acti
```ruby
module WorkPackages
- class PreviewAttributesService < ::BaseServices::SetAttributes
+ class PreviewAttributesService <::BaseServices::SetAttributes
def perform(*)
super.tap do |service_call|
clear_unchanged_fields(service_call)
diff --git a/lookbook/previews/patterns/checkbox_confirmation_preview.rb b/lookbook/previews/patterns/checkbox_confirmation_preview.rb
new file mode 100644
index 000000000000..0f9b88b9d78c
--- /dev/null
+++ b/lookbook/previews/patterns/checkbox_confirmation_preview.rb
@@ -0,0 +1,39 @@
+# 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 Patterns
+ # @hidden
+ class CheckboxConfirmationPreview < ViewComponent::Preview
+ # @display min_height 400px
+ def default
+ render_with_template
+ end
+ end
+end
diff --git a/lookbook/previews/patterns/checkbox_confirmation_preview/default.html.erb b/lookbook/previews/patterns/checkbox_confirmation_preview/default.html.erb
new file mode 100644
index 000000000000..9d5b59323711
--- /dev/null
+++ b/lookbook/previews/patterns/checkbox_confirmation_preview/default.html.erb
@@ -0,0 +1,36 @@
+<%
+ the_form = Class.new(ApplicationForm) do
+ form do |f|
+ f.check_box(
+ name: :project_specific_values,
+ label: "Project-specific values",
+ checked: true,
+ data: { checkbox_confirmation_target: "checkbox" }
+ )
+ end
+ end
+%>
+
+
+ <%=
+ primer_form_with(url: "/abc", method: :post) do |f|
+ render(the_form.new(f))
+ end
+ %>
+
+ <%=
+ render(
+ Primer::OpenProject::DangerDialog.new(
+ id: "checkbox-confirmation-preview-dialog",
+ title: "Disable project-specific values",
+ confirm_button_text: "Disable",
+ data: { checkbox_confirmation_target: "dialog" }
+ )
+ ) do |dialog|
+ dialog.with_confirmation_message do |message|
+ message.with_heading(tag: :h2) { "Disable project-specific values?" }
+ message.with_description_content("All values enabled per project will be removed.")
+ end
+ end
+ %>
+
diff --git a/spec/components/admin/custom_fields/projects/values_component_spec.rb b/spec/components/admin/custom_fields/projects/values_component_spec.rb
new file mode 100644
index 000000000000..13913ab52278
--- /dev/null
+++ b/spec/components/admin/custom_fields/projects/values_component_spec.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "rails_helper"
+
+RSpec.describe Admin::CustomFields::Projects::ValuesComponent, type: :component do
+ include Rails.application.routes.url_helpers
+
+ shared_let(:project) { create(:project) }
+
+ subject(:rendered) { render_inline(described_class.new(custom_field:, project:)) }
+
+ let(:row) { ->(value) { rendered.css("[data-test-selector='custom-field-project-value-#{value.id}']") } }
+
+ context "for a list custom field" do
+ let(:custom_field) { create(:list_wp_custom_field, :project_specific_allowlist, possible_values: %w[A B]) }
+ let(:option_a) { custom_field.custom_options.find_by!(value: "A") }
+ let(:option_b) { custom_field.custom_options.find_by!(value: "B") }
+
+ before do
+ create(:project_specific_value_restriction, project:, custom_option: option_a)
+ end
+
+ it "offers to enable and to disable all values" do
+ expect(rendered).to have_link(
+ I18n.t("custom_fields.admin.projects.enable_all"),
+ href: enable_all_project_values_admin_settings_work_package_custom_field_path(custom_field, project)
+ )
+ expect(rendered).to have_link(
+ I18n.t("custom_fields.admin.projects.disable_all"),
+ href: disable_all_project_values_admin_settings_work_package_custom_field_path(custom_field, project)
+ )
+ end
+
+ it "toggles each value on its own" do
+ expect(row.call(option_a).css("toggle-switch").attr("src").value)
+ .to eq(toggle_project_value_admin_settings_work_package_custom_field_path(custom_field, project, option_a.id))
+ end
+
+ it "lists the options in order with whether they are enabled in the project" do
+ expect(rendered.css("[data-test-selector^='custom-field-project-value-']").map { it.text.split.first })
+ .to eq(%w[A B])
+ expect(row.call(option_a).css("button[aria-pressed='true']")).to be_present
+ expect(row.call(option_b).css("button[aria-pressed='false']")).to be_present
+ end
+ end
+
+ context "for a weighted item list custom field", with_ee: [:weighted_item_lists] do
+ let(:custom_field) { create(:weighted_item_list_wp_custom_field, :project_specific_allowlist) }
+ let!(:parent) { create(:hierarchy_item, parent: custom_field.hierarchy_root, label: "High", weight: 10) }
+ let!(:child) { create(:hierarchy_item, parent:, label: "Urgent", weight: 15) }
+
+ it "indents sub-items and shows their weight and number of sub-items" do
+ expect(row.call(parent).text).to include("High", "10", I18n.t("custom_fields.admin.hierarchy.subitems", count: 1))
+ expect(row.call(child).text).to include("Urgent", "15")
+ expect(row.call(child).css(".pl-3")).to be_present
+ expect(row.call(parent).css(".pl-3")).to be_empty
+ end
+ end
+end
diff --git a/spec/components/custom_fields/details_component_spec.rb b/spec/components/custom_fields/details_component_spec.rb
index c4a34db862e8..e34b693eb9f0 100644
--- a/spec/components/custom_fields/details_component_spec.rb
+++ b/spec/components/custom_fields/details_component_spec.rb
@@ -63,6 +63,28 @@
end
end
+ describe "the confirmation for disabling project-specific values" do
+ let(:confirmation) { rendered.css("[data-controller~='checkbox-confirmation']") }
+
+ context "when project-specific values are enabled" do
+ let(:custom_field) { create(:list_wp_custom_field, :project_specific_allowlist) }
+
+ it "asks for confirmation before disabling them" do
+ expect(confirmation.css("input[data-checkbox-confirmation-target='checkbox']")).to be_present
+ expect(confirmation.css("dialog[data-checkbox-confirmation-target='dialog']")).to be_present
+ end
+ end
+
+ context "when project-specific values are not enabled" do
+ let(:custom_field) { create(:list_wp_custom_field) }
+
+ it "does not ask for confirmation" do
+ expect(confirmation).to be_empty
+ expect(rendered.css("dialog#disable-project-specific-values-dialog")).to be_empty
+ end
+ end
+ end
+
describe "for a project custom field" do
let(:custom_field) { create(:list_project_custom_field, possible_values: []) }
diff --git a/spec/forms/custom_fields/details_form_spec.rb b/spec/forms/custom_fields/details_form_spec.rb
index f577caace4ea..a7369d7978ab 100644
--- a/spec/forms/custom_fields/details_form_spec.rb
+++ b/spec/forms/custom_fields/details_form_spec.rb
@@ -30,7 +30,7 @@
require "rails_helper"
format_cases = {
- WorkPackageCustomField => %w[hierarchy],
+ WorkPackageCustomField => %w[hierarchy list weighted_item_list string],
TimeEntryCustomField => %w[text date float user version string bool list int],
VersionCustomField => %w[text date float int user bool string list version],
GroupCustomField => %w[text date float int bool list string],
@@ -48,6 +48,9 @@
custom_field_section_id: ->(model) { model.is_a?(ProjectCustomField) || model.is_a?(UserCustomField) },
has_comment: ->(model) { model.is_a?(ProjectCustomField) },
is_filter: ->(model) { model.is_a?(WorkPackageCustomField) },
+ project_specific_value_restriction: lambda { |model|
+ model.is_a?(WorkPackageCustomField) && %w[list hierarchy weighted_item_list].include?(model.field_format)
+ },
content_right_to_left: ->(model) { model.is_a?(WorkPackageCustomField) && model.field_format == "text" },
editable: ->(model) { model.is_a?(UserCustomField) },
visible_on_user_card: ->(model) { model.is_a?(UserCustomField) },
@@ -84,6 +87,8 @@ def field_label(field)
I18n.t("activerecord.attributes.project_custom_field.custom_field_section")
when :formula
I18n.t(:label_formula)
+ when :project_specific_value_restriction
+ I18n.t("custom_fields.project_specific_value_restriction_label")
else
I18n.t("activerecord.attributes.custom_field.#{field}")
end
@@ -136,4 +141,15 @@ def control_type(field)
end
end
end
+
+ context "with a WorkPackageCustomField with project-specific values" do
+ let(:model_class) { WorkPackageCustomField }
+ let(:field_format) { "list" }
+ let(:model) { model_class.new(field_format:, project_specific_value_restriction: "allow") }
+
+ it "renders the project-specific values checkbox checked" do
+ expect(page).to have_checked_field(I18n.t("custom_fields.project_specific_value_restriction_label"),
+ with: "allow")
+ end
+ end
end
diff --git a/spec/models/custom_field/project_specific_values_spec.rb b/spec/models/custom_field/project_specific_values_spec.rb
index 611568ab85ca..c8e9b6d6a78c 100644
--- a/spec/models/custom_field/project_specific_values_spec.rb
+++ b/spec/models/custom_field/project_specific_values_spec.rb
@@ -225,6 +225,42 @@
end
end
+ describe "#usable_value_counts_in" do
+ shared_let(:unconfigured_project) { create(:project) }
+
+ subject(:counts) { custom_field.usable_value_counts_in(Project.where(id: [project, other_project, unconfigured_project])) }
+
+ context "for a list custom field with an allowlist" do
+ let(:custom_field) { create(:list_wp_custom_field, :project_specific_allowlist, possible_values: %w[A B C]) }
+
+ before do
+ create(:project_specific_value_restriction, project:, custom_option: custom_field.custom_options.first)
+ create(:project_specific_value_restriction, project:, custom_option: custom_field.custom_options.second)
+ create(:project_specific_value_restriction, project: other_project, custom_option: custom_field.custom_options.third)
+ end
+
+ it "counts the options usable in each project" do
+ expect(counts).to eq(project.id => 2, other_project.id => 1, unconfigured_project.id => 0)
+ end
+ end
+
+ context "for a hierarchy custom field with an allowlist", with_ee: [:custom_field_hierarchies] do
+ let(:custom_field) { create(:hierarchy_wp_custom_field, :project_specific_allowlist) }
+ let!(:acme) { create(:hierarchy_item, parent: custom_field.hierarchy_root) }
+ let!(:anvils) { create(:hierarchy_item, parent: acme) }
+
+ before do
+ create(:project_specific_value_restriction, project:, hierarchy_item: acme)
+ create(:project_specific_value_restriction, project:, hierarchy_item: anvils)
+ create(:project_specific_value_restriction, project: other_project, hierarchy_item: anvils)
+ end
+
+ it "only counts the items whose ancestors are usable as well" do
+ expect(counts).to eq(project.id => 2, other_project.id => 0, unconfigured_project.id => 0)
+ end
+ end
+ end
+
describe "#visible_values_for" do
shared_let(:type) { create(:type) }
shared_let(:user) { create(:user) }
diff --git a/spec/models/queries/projects/filters/project_specific_values_filter_spec.rb b/spec/models/queries/projects/filters/project_specific_values_filter_spec.rb
new file mode 100644
index 000000000000..457b9b09f335
--- /dev/null
+++ b/spec/models/queries/projects/filters/project_specific_values_filter_spec.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+
+RSpec.describe Queries::Projects::Filters::ProjectSpecificValuesFilter do
+ it_behaves_like "basic query filter" do
+ let(:class_key) { :project_specific_values }
+ let(:type) { :list }
+ let(:human_name) { "Project-specific values" }
+ end
+
+ describe "#available?" do
+ it "is offered to administrators only" do
+ allow(User).to receive(:current).and_return(build_stubbed(:admin))
+ expect(described_class.create!(name: :project_specific_values, operator: "=")).to be_available
+
+ allow(User).to receive(:current).and_return(build_stubbed(:user))
+ expect(described_class.create!(name: :project_specific_values, operator: "=")).not_to be_available
+ end
+ end
+
+ describe "#valid?" do
+ shared_let(:custom_field) { create(:list_wp_custom_field, :project_specific_allowlist) }
+
+ subject(:filter) do
+ described_class.create!(name: :project_specific_values, operator: "=", values: [custom_field.id.to_s])
+ end
+
+ context "for an administrator" do
+ current_user { build_stubbed(:admin) }
+
+ it { is_expected.to be_valid }
+ end
+
+ context "for a non-administrator" do
+ current_user { build_stubbed(:user) }
+
+ it { is_expected.not_to be_valid }
+ end
+ end
+
+ describe "#apply_to" do
+ shared_let(:custom_field) { create(:list_wp_custom_field, :project_specific_allowlist, possible_values: %w[A]) }
+ shared_let(:other_custom_field) { create(:list_wp_custom_field, :project_specific_allowlist, possible_values: %w[A]) }
+ shared_let(:configured_project) { create(:project) }
+ shared_let(:otherwise_configured_project) { create(:project) }
+ shared_let(:unconfigured_project) { create(:project) }
+
+ subject(:filter) do
+ described_class.create!(name: :project_specific_values, operator: "=", values: [custom_field.id.to_s])
+ end
+
+ before do
+ create(:project_specific_value_restriction, project: configured_project,
+ custom_option: custom_field.custom_options.first)
+ create(:project_specific_value_restriction, project: otherwise_configured_project,
+ custom_option: other_custom_field.custom_options.first)
+ end
+
+ it "keeps the projects with project-specific values of the custom field" do
+ expect(filter.apply_to(Project)).to contain_exactly(configured_project)
+ end
+ end
+
+ describe "in a project query" do
+ context "for an administrator" do
+ current_user { build_stubbed(:admin) }
+
+ it "is available, but not offered as an advanced filter" do
+ query = ProjectQuery.new
+
+ expect(query.available_filters.map(&:class)).to include(described_class)
+ expect(query.available_advanced_filters.map(&:class)).not_to include(described_class)
+ end
+ end
+
+ context "for a non-administrator" do
+ current_user { build_stubbed(:user) }
+
+ it "makes the query invalid" do
+ custom_field = create(:list_wp_custom_field, :project_specific_allowlist)
+ query = ProjectQuery.new.where(:project_specific_values, "=", [custom_field.id.to_s])
+
+ expect(query).not_to be_valid
+ expect(query.errors.full_messages.join).to include(I18n.t(:"activerecord.errors.messages.filter_does_not_exist"))
+ end
+ end
+ end
+end
diff --git a/spec/models/work_package_custom_field_spec.rb b/spec/models/work_package_custom_field_spec.rb
index aabb94e4cefb..56c445b63d3f 100644
--- a/spec/models/work_package_custom_field_spec.rb
+++ b/spec/models/work_package_custom_field_spec.rb
@@ -48,4 +48,24 @@
end
end
end
+
+ describe "#type_ids_by_project_id" do
+ let(:feature) { create(:type) }
+ let(:epic) { create(:type) }
+ let(:bug) { create(:type) }
+ let(:custom_field) { create(:list_wp_custom_field, types: [feature, epic]) }
+
+ let!(:project) { create(:project, types: [feature, epic, bug]) }
+ let!(:other_project) { create(:project, types: [feature]) }
+
+ before do
+ create(:project, types: [bug])
+ create(:project, active: false, types: [feature])
+ end
+
+ it "maps the active projects using the field to the types showing it there" do
+ expect(custom_field.type_ids_by_project_id)
+ .to match(project.id => contain_exactly(feature.id, epic.id), other_project.id => [feature.id])
+ end
+ end
end
diff --git a/spec/requests/admin/settings/work_package_custom_fields/projects_spec.rb b/spec/requests/admin/settings/work_package_custom_fields/projects_spec.rb
new file mode 100644
index 000000000000..0e8038e81b7d
--- /dev/null
+++ b/spec/requests/admin/settings/work_package_custom_fields/projects_spec.rb
@@ -0,0 +1,217 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require "spec_helper"
+
+RSpec.describe "Admin work package custom field projects", :skip_csrf, type: :rails_request do
+ shared_let(:admin) { create(:admin) }
+ shared_let(:feature) { create(:type, name: "Feature") }
+ shared_let(:epic) { create(:type, name: "Epic") }
+ shared_let(:using_project) { create(:project, name: "Using project", types: [feature, epic]) }
+ shared_let(:other_project) { create(:project, name: "Other project", types: [create(:type)]) }
+
+ let(:custom_field) do
+ create(:list_wp_custom_field, :project_specific_allowlist, types: [feature, epic], possible_values: %w[A B])
+ end
+ let(:projects_path) { projects_admin_settings_work_package_custom_field_path(custom_field) }
+ let(:page) { Capybara.string(response.body) }
+ let(:project_specific_values_filter) do
+ { project_specific_values: { operator: "=", values: [custom_field.id.to_s] } }
+ end
+
+ it "denies non-admins" do
+ login_as create(:user)
+ get projects_path
+
+ expect(response).to have_http_status(:forbidden)
+ end
+
+ context "as an admin" do
+ before do
+ login_as(admin)
+ end
+
+ context "with project-specific values enabled" do
+ before do
+ create(:project_specific_value_restriction, project: using_project,
+ custom_option: custom_field.custom_options.first)
+ get projects_path
+ end
+
+ it "lists the projects using the custom field with their types and enabled values" do
+ expect(response).to have_http_status(:ok)
+
+ row = page.find("[data-test-selector='custom-field-projects-row-#{using_project.id}']")
+ expect(row).to have_link("Feature")
+ expect(row).to have_link("Epic")
+ expect(row).to have_text("1 of 2")
+ end
+
+ it "does not list projects not using the custom field" do
+ expect(page).to have_no_text("Other project")
+ end
+
+ it "filters the projects by name" do
+ create(:project, name: "Another using project", types: [feature])
+
+ get projects_path, params: { filters: [{ name_and_identifier: { operator: "~", values: ["another"] } }].to_json }
+
+ expect(page).to have_link("Another using project")
+ expect(page).to have_no_link(using_project.name)
+ end
+
+ it "offers to only show the projects with project-specific values" do
+ expect(page).to have_link(I18n.t("custom_fields.admin.projects.quick_filter.with_project_specific_values"))
+ end
+
+ it "filters the projects with project-specific values" do
+ create(:project, name: "Unconfigured project", types: [feature])
+
+ get projects_path, params: { filters: [project_specific_values_filter].to_json }
+
+ expect(page).to have_link(using_project.name)
+ expect(page).to have_no_link("Unconfigured project")
+ end
+
+ context "with more projects than fit a page", with_settings: { per_page_options: "1, 100" } do
+ it "keeps the filters when paginating" do
+ another_project = create(:project, name: "Another using project", types: [feature])
+ create(:project_specific_value_restriction, project: another_project,
+ custom_option: custom_field.custom_options.first)
+ filters = [{ name_and_identifier: { operator: "~", values: ["using"] } }, project_specific_values_filter]
+
+ get projects_path, params: { filters: filters.to_json }
+
+ next_page = page.first("a[href*='page=2']")[:href]
+ next_page_filters = JSON.parse(Rack::Utils.parse_query(URI(next_page).query)["filters"])
+
+ expect(next_page_filters).to include(*filters.map(&:deep_stringify_keys))
+ end
+ end
+
+ it "shows a dedicated empty state when no project matches the filters" do
+ get projects_path,
+ params: { filters: [{ name_and_identifier: { operator: "~", values: ["no such project"] } }].to_json }
+
+ expect(page).to have_text(I18n.t("custom_fields.admin.projects.filter_no_results.title"))
+ expect(page).to have_no_text(I18n.t("custom_fields.admin.projects.empty_state.title"))
+ end
+
+ it "shows the projects tab" do
+ expect(page).to have_link(I18n.t(:label_project_plural), href: projects_path)
+ end
+
+ it "links each project name to the project" do
+ expect(page).to have_link(using_project.name, href: project_path(using_project))
+ end
+
+ it "links each project to the configuration of its values" do
+ values_path = project_values_admin_settings_work_package_custom_field_path(custom_field, using_project)
+
+ expect(page).to have_link(href: values_path)
+ end
+ end
+
+ describe "the values of a project" do
+ it "lists the values of the custom field for the project" do
+ get project_values_admin_settings_work_package_custom_field_path(custom_field, using_project)
+
+ expect(response).to have_http_status(:ok)
+ expect(page).to have_text("#{Project.model_name.human}: #{using_project.name}")
+ expect(page).to have_css("[data-test-selector='custom-field-project-value-#{custom_field.custom_options.first.id}']")
+ end
+
+ it "is not found for a project not using the custom field" do
+ get project_values_admin_settings_work_package_custom_field_path(custom_field, other_project)
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ describe "changing the values of a project" do
+ let(:option) { custom_field.custom_options.first }
+ let(:enabled_values) { custom_field.usable_values_in(using_project) }
+ let(:values_path) { project_values_admin_settings_work_package_custom_field_path(custom_field, using_project) }
+
+ it "enables a single value" do
+ post toggle_project_value_admin_settings_work_package_custom_field_path(custom_field, using_project, option.id),
+ params: { value: "1" }
+
+ expect(response).to have_http_status(:ok)
+ expect(enabled_values).to contain_exactly(option)
+ end
+
+ it "does not find a value of another custom field" do
+ other_option = create(:list_wp_custom_field).custom_options.first
+ post toggle_project_value_admin_settings_work_package_custom_field_path(custom_field, using_project, other_option.id),
+ params: { value: "1" }
+
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it "enables all values" do
+ post enable_all_project_values_admin_settings_work_package_custom_field_path(custom_field, using_project)
+
+ expect(response).to redirect_to(values_path)
+ expect(enabled_values).to match_array(custom_field.custom_options)
+ end
+
+ it "disables all values" do
+ create(:project_specific_value_restriction, project: using_project, custom_option: option)
+ post disable_all_project_values_admin_settings_work_package_custom_field_path(custom_field, using_project)
+
+ expect(response).to redirect_to(values_path)
+ expect(enabled_values).to be_empty
+ end
+ end
+
+ context "without project-specific values enabled" do
+ let(:custom_field) { create(:list_wp_custom_field, types: [feature]) }
+
+ it "lists the projects without their project-specific values" do
+ get projects_path
+
+ expect(response).to have_http_status(:ok)
+ expect(page).to have_link(using_project.name, href: project_path(using_project))
+ expect(page).to have_no_text(CustomField.human_attribute_name(:project_specific_value_restriction))
+ expect(page).to have_no_link(
+ href: project_values_admin_settings_work_package_custom_field_path(custom_field, using_project)
+ )
+ expect(page).to have_no_link(I18n.t("custom_fields.admin.projects.quick_filter.with_project_specific_values"))
+ end
+
+ it "does not find the values of a project" do
+ get project_values_admin_settings_work_package_custom_field_path(custom_field, using_project)
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+end