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