From 0dfb6253b5942d3733fdec76396aedb5d7e1599f Mon Sep 17 00:00:00 2001 From: David F Date: Thu, 28 May 2026 17:14:16 +0200 Subject: [PATCH 01/20] Organize user custom fields within sections. wp/72005 --- .../custom_fields/details_component.rb | 12 +- .../custom_field_row_component.html.erb | 43 ++++ .../custom_field_row_component.rb | 111 +++++++++ .../dialog_body_form_component.html.erb | 23 ++ .../dialog_body_form_component.rb | 60 +++++ .../index_component.html.erb | 15 ++ .../index_component.rb | 76 ++++++ .../new_section_dialog_component.html.erb | 10 + .../new_section_dialog_component.rb | 40 ++++ .../show_component.html.erb | 89 +++++++ .../show_component.rb | 162 +++++++++++++ .../edit_form_header_component.html.erb | 8 + .../edit_form_header_component.rb | 82 +++++++ .../header_component.html.erb | 52 +++++ .../user_custom_fields/header_component.rb | 56 +++++ .../new_form_header_component.html.erb | 6 + .../new_form_header_component.rb | 50 ++++ .../base_contract.rb | 39 ++++ .../create_contract.rb | 34 +++ .../delete_contract.rb | 41 ++++ .../update_contract.rb | 34 +++ .../user_custom_field_sections_controller.rb | 129 ++++++++++ .../settings/user_custom_fields_controller.rb | 152 ++++++++++++ .../user_custom_fields/component_streams.rb | 73 ++++++ .../attribute_help_text_actions.rb | 2 + .../concerns/custom_fields/shared_actions.rb | 15 +- app/forms/custom_fields/details_form.rb | 10 +- .../user_custom_field_sections/name_form.rb | 41 ++++ app/models/attribute_help_text/user.rb | 51 ++++ app/models/user_custom_field.rb | 7 + app/models/user_custom_field_section.rb | 41 ++++ .../create_service.rb | 34 +++ .../delete_service.rb | 34 +++ .../set_attributes_service.rb | 34 +++ .../update_service.rb | 34 +++ .../user_custom_fields/drop_service.rb | 95 ++++++++ .../settings/user_custom_fields/edit.html.erb | 41 ++++ .../user_custom_fields/index.html.erb | 38 +++ .../user_custom_fields/list_items.html.erb | 76 ++++++ .../settings/user_custom_fields/new.html.erb | 34 +++ .../attribute_help_texts/show_user.html.erb | 54 +++++ config/initializers/menus.rb | 6 + config/locales/en.yml | 15 ++ config/routes.rb | 26 +++ ...reate_default_user_custom_field_section.rb | 35 +++ spec/factories/custom_field_factory.rb | 4 +- spec/factories/custom_field_section.rb | 1 + .../shared_custom_field_expectations.rb | 2 +- .../admin/custom_fields/users/index_spec.rb | 220 ++++++++++++++++++ .../admin/custom_fields/users/list_spec.rb | 61 ++++- .../custom_fields/users/shared_context.rb | 55 +++++ spec/support/cuprite_setup.rb | 17 ++ .../settings/user_custom_fields/index.rb | 74 ++++++ 53 files changed, 2543 insertions(+), 11 deletions(-) create mode 100644 app/components/settings/user_custom_field_sections/custom_field_row_component.html.erb create mode 100644 app/components/settings/user_custom_field_sections/custom_field_row_component.rb create mode 100644 app/components/settings/user_custom_field_sections/dialog_body_form_component.html.erb create mode 100644 app/components/settings/user_custom_field_sections/dialog_body_form_component.rb create mode 100644 app/components/settings/user_custom_field_sections/index_component.html.erb create mode 100644 app/components/settings/user_custom_field_sections/index_component.rb create mode 100644 app/components/settings/user_custom_field_sections/new_section_dialog_component.html.erb create mode 100644 app/components/settings/user_custom_field_sections/new_section_dialog_component.rb create mode 100644 app/components/settings/user_custom_field_sections/show_component.html.erb create mode 100644 app/components/settings/user_custom_field_sections/show_component.rb create mode 100644 app/components/settings/user_custom_fields/edit_form_header_component.html.erb create mode 100644 app/components/settings/user_custom_fields/edit_form_header_component.rb create mode 100644 app/components/settings/user_custom_fields/header_component.html.erb create mode 100644 app/components/settings/user_custom_fields/header_component.rb create mode 100644 app/components/settings/user_custom_fields/new_form_header_component.html.erb create mode 100644 app/components/settings/user_custom_fields/new_form_header_component.rb create mode 100644 app/contracts/user_custom_field_sections/base_contract.rb create mode 100644 app/contracts/user_custom_field_sections/create_contract.rb create mode 100644 app/contracts/user_custom_field_sections/delete_contract.rb create mode 100644 app/contracts/user_custom_field_sections/update_contract.rb create mode 100644 app/controllers/admin/settings/user_custom_field_sections_controller.rb create mode 100644 app/controllers/admin/settings/user_custom_fields_controller.rb create mode 100644 app/controllers/concerns/admin/settings/user_custom_fields/component_streams.rb create mode 100644 app/forms/user_custom_field_sections/name_form.rb create mode 100644 app/models/attribute_help_text/user.rb create mode 100644 app/models/user_custom_field_section.rb create mode 100644 app/services/user_custom_field_sections/create_service.rb create mode 100644 app/services/user_custom_field_sections/delete_service.rb create mode 100644 app/services/user_custom_field_sections/set_attributes_service.rb create mode 100644 app/services/user_custom_field_sections/update_service.rb create mode 100644 app/services/user_custom_fields/drop_service.rb create mode 100644 app/views/admin/settings/user_custom_fields/edit.html.erb create mode 100644 app/views/admin/settings/user_custom_fields/index.html.erb create mode 100644 app/views/admin/settings/user_custom_fields/list_items.html.erb create mode 100644 app/views/admin/settings/user_custom_fields/new.html.erb create mode 100644 app/views/custom_fields/attribute_help_texts/show_user.html.erb create mode 100644 db/migrate/20260528144922_create_default_user_custom_field_section.rb create mode 100644 spec/features/admin/custom_fields/users/index_spec.rb create mode 100644 spec/features/admin/custom_fields/users/shared_context.rb create mode 100644 spec/support/pages/admin/settings/user_custom_fields/index.rb diff --git a/app/components/custom_fields/details_component.rb b/app/components/custom_fields/details_component.rb index 0eb53f09011e..320c4a4a1c3c 100644 --- a/app/components/custom_fields/details_component.rb +++ b/app/components/custom_fields/details_component.rb @@ -45,9 +45,17 @@ class DetailsComponent < ApplicationComponent def form_url if model.new_record? - model.type == "ProjectCustomField" ? admin_settings_project_custom_fields_path : custom_fields_path + case model.type + when "ProjectCustomField" then admin_settings_project_custom_fields_path + when "UserCustomField" then admin_settings_user_custom_fields_path + else custom_fields_path + end else - model.type == "ProjectCustomField" ? admin_settings_project_custom_field_path(model) : custom_field_path(model) + case model.type + when "ProjectCustomField" then admin_settings_project_custom_field_path(model) + when "UserCustomField" then admin_settings_user_custom_field_path(model) + else custom_field_path(model) + end end end diff --git a/app/components/settings/user_custom_field_sections/custom_field_row_component.html.erb b/app/components/settings/user_custom_field_sections/custom_field_row_component.html.erb new file mode 100644 index 000000000000..85ea73709778 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/custom_field_row_component.html.erb @@ -0,0 +1,43 @@ +<%= + component_wrapper(class: "op-user-custom-field-container", data: { test_selector: "user-custom-field-container-#{@user_custom_field.id}" }) do + flex_layout(justify_content: :space_between, align_items: :center) do |main_container| + main_container.with_column(flex_layout: true, align_items: :center) do |content_container| + content_container.with_column(mr: 2) do + render(Primer::OpenProject::DragHandle.new(classes: "handle")) + end + content_container.with_column(mr: 2) do + render( + Primer::Beta::Link.new( + href: edit_admin_settings_user_custom_field_path(@user_custom_field), + underline: false, + font_weight: :bold, + data: { turbo: "false" } + ) + ) do + @user_custom_field.name + end + end + content_container.with_column(mr: 2) do + render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do + helpers.label_for_custom_field_format(@user_custom_field.field_format) + end + end + if @user_custom_field.required? + content_container.with_column do + render(Primer::Beta::Label.new(scheme: :attention, size: :medium)) do + UserCustomField.human_attribute_name(:required) + end + end + end + end + main_container.with_column do + render(Primer::Alpha::ActionMenu.new(data: { test_selector: "user-custom-field-action-menu" })) do |menu| + menu.with_show_button(icon: "kebab-horizontal", "aria-label": t("settings.user_attributes.label_user_custom_field_actions"), scheme: :invisible) + edit_action_item(menu) + move_actions(menu) + delete_action_item(menu) + end + end + end + end +%> diff --git a/app/components/settings/user_custom_field_sections/custom_field_row_component.rb b/app/components/settings/user_custom_field_sections/custom_field_row_component.rb new file mode 100644 index 000000000000..e9e3cbd19e15 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/custom_field_row_component.rb @@ -0,0 +1,111 @@ +# 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 Settings + module UserCustomFieldSections + class CustomFieldRowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(user_custom_field:, first_and_last:) + super + + @user_custom_field = user_custom_field + @first_and_last = first_and_last + end + + private + + def edit_action_item(menu) + menu.with_item(label: t("label_edit"), + href: edit_admin_settings_user_custom_field_path(@user_custom_field), + data: { turbo: "false", test_selector: "user-custom-field-edit" }) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + + def move_actions(menu) + unless first? + move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") + move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") + end + unless last? + move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") + move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), "move-to-bottom") + end + end + + def move_action_item(menu, move_to, label_text, icon) + menu.with_item(label: label_text, + href: move_admin_settings_user_custom_field_path(@user_custom_field, move_to:), + form_arguments: { + method: :put, data: { "turbo-stream": true, test_selector: "user-custom-field-move-#{move_to}" } + }) do |item| + item.with_leading_visual_icon(icon:) + end + end + + def delete_action_item(menu) + menu.with_item(label: t("text_destroy"), + scheme: :danger, + href: admin_settings_user_custom_field_path(@user_custom_field), + form_arguments: { + method: :delete, + data: { + turbo_confirm: t(:text_are_you_sure), + turbo_stream: true, + test_selector: "user-custom-field-delete" + } + }) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + + def first? + @first ||= + if @first_and_last.first + @first_and_last.first == @user_custom_field + else + @user_custom_field.first? + end + end + + def last? + @last ||= + if @first_and_last.last + @first_and_last.last == @user_custom_field + else + @user_custom_field.last? + end + end + end + end +end diff --git a/app/components/settings/user_custom_field_sections/dialog_body_form_component.html.erb b/app/components/settings/user_custom_field_sections/dialog_body_form_component.html.erb new file mode 100644 index 000000000000..3001cdd1b4e5 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/dialog_body_form_component.html.erb @@ -0,0 +1,23 @@ +<%= + component_wrapper do + primer_form_with(**form_config) do |f| + component_collection do |collection| + collection.with_component(Primer::Alpha::Dialog::Body.new) do + render(UserCustomFieldSections::NameForm.new(f)) + end + + collection.with_component(Primer::Alpha::Dialog::Footer.new) do + component_collection do |modal_footer| + modal_footer.with_component(Primer::Beta::Button.new(data: { "close-dialog-id": "user-custom-field-section-dialog#{@user_custom_field_section.id}" })) do + t("button_cancel") + end + + modal_footer.with_component(Primer::Beta::Button.new(scheme: :primary, type: :submit)) do + t("button_save") + end + end + end + end + end + end +%> diff --git a/app/components/settings/user_custom_field_sections/dialog_body_form_component.rb b/app/components/settings/user_custom_field_sections/dialog_body_form_component.rb new file mode 100644 index 000000000000..9a681d18a0c1 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/dialog_body_form_component.rb @@ -0,0 +1,60 @@ +# 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 Settings + module UserCustomFieldSections + class DialogBodyFormComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(user_custom_field_section: UserCustomFieldSection.new) + super + + @user_custom_field_section = user_custom_field_section + end + + private + + def wrapper_uniq_by + @user_custom_field_section.id + end + + def form_config + { + model: @user_custom_field_section, + method: @user_custom_field_section.persisted? ? :put : :post, + url: @user_custom_field_section.persisted? ? admin_settings_user_custom_field_section_path(@user_custom_field_section) : admin_settings_user_custom_field_sections_path, + data: { turbo_stream: true } + } + end + end + end +end diff --git a/app/components/settings/user_custom_field_sections/index_component.html.erb b/app/components/settings/user_custom_field_sections/index_component.html.erb new file mode 100644 index 000000000000..a496772cc3ee --- /dev/null +++ b/app/components/settings/user_custom_field_sections/index_component.html.erb @@ -0,0 +1,15 @@ +<%= + component_wrapper(data: wrapper_data_attributes) do + if @user_custom_field_sections.any? + flex_layout(classes: "dragula-container", data: { "allowed-drop-type": "section" }.merge(drop_target_config)) do |flex| + @user_custom_field_sections.each do |section| + flex.with_row( + data: draggable_item_config(section) + ) do + render(row_component_class.new(user_custom_field_section: section, first_and_last:)) + end + end + end + end + end +%> diff --git a/app/components/settings/user_custom_field_sections/index_component.rb b/app/components/settings/user_custom_field_sections/index_component.rb new file mode 100644 index 000000000000..24fddff4f476 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/index_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 Settings + module UserCustomFieldSections + class IndexComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(user_custom_field_sections:) + super + + @user_custom_field_sections = user_custom_field_sections + end + + def row_component_class + Settings::UserCustomFieldSections::ShowComponent + end + + def first_and_last + [@user_custom_field_sections.first, @user_custom_field_sections.last] + end + + private + + def wrapper_data_attributes + { + controller: "generic-drag-and-drop" + } + end + + def drop_target_config + { + generic_drag_and_drop_target: "container", + "target-allowed-drag-type": "section" + } + end + + def draggable_item_config(section) + { + "draggable-id": section.id, + "draggable-type": "section", + "drop-url": drop_admin_settings_user_custom_field_section_path(section) + } + end + end + end +end diff --git a/app/components/settings/user_custom_field_sections/new_section_dialog_component.html.erb b/app/components/settings/user_custom_field_sections/new_section_dialog_component.html.erb new file mode 100644 index 000000000000..60b62339aa1a --- /dev/null +++ b/app/components/settings/user_custom_field_sections/new_section_dialog_component.html.erb @@ -0,0 +1,10 @@ +<%= render( + Primer::Alpha::Dialog.new( + title: t("settings.user_attributes.label_new_section"), + size: :medium_portrait, + id: MODAL_ID, + data: { "keep-open-on-submit": true } + ) + ) do %> + <%= render(Settings::UserCustomFieldSections::DialogBodyFormComponent.new) %> +<% end %> diff --git a/app/components/settings/user_custom_field_sections/new_section_dialog_component.rb b/app/components/settings/user_custom_field_sections/new_section_dialog_component.rb new file mode 100644 index 000000000000..e91c9bae363b --- /dev/null +++ b/app/components/settings/user_custom_field_sections/new_section_dialog_component.rb @@ -0,0 +1,40 @@ +# 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 Settings + module UserCustomFieldSections + class NewSectionDialogComponent < ApplicationComponent + include OpTurbo::Streamable + + MODAL_ID = "user-custom-field-section-dialog" + FORM_ID = "user-custom-field-section-dialog-form" + end + end +end diff --git a/app/components/settings/user_custom_field_sections/show_component.html.erb b/app/components/settings/user_custom_field_sections/show_component.html.erb new file mode 100644 index 000000000000..49b6f3cd438e --- /dev/null +++ b/app/components/settings/user_custom_field_sections/show_component.html.erb @@ -0,0 +1,89 @@ +<%= + component_wrapper(class: "op-user-custom-field-section-container", data: { test_selector: "user-custom-field-section-container-#{@user_custom_field_section.id}" }) do + render(border_box_container(mt: 3, data: drag_and_drop_target_config)) do |component| + component.with_header(font_weight: :bold) do + flex_layout(justify_content: :space_between, align_items: :center) do |section_header_container| + section_header_container.with_column(flex_layout: true, align_items: :center) do |content_container| + content_container.with_column(mr: 2) do + render(Primer::OpenProject::DragHandle.new(classes: "handle")) + end + content_container.with_column do + render(Primer::Beta::Text.new(font_weight: :bold)) do + @user_custom_field_section.name.presence || t("settings.user_attributes.label_untitled_section") + end + end + end + + section_header_container.with_column(flex_layout: true, justify_content: :flex_end) do |actions_container| + actions_container.with_column do + render(Primer::Alpha::ActionMenu.new(data: { test_selector: "user-custom-field-section-action-menu" })) do |menu| + menu.with_show_button(icon: "kebab-horizontal", "aria-label": t("settings.user_attributes.label_section_actions"), scheme: :invisible) + edit_action_item(menu) + move_actions(menu) + if @user_custom_fields.empty? + delete_action_item(menu) + else + disabled_delete_action_item(menu) + end + end + end + + actions_container.with_column do + render( + Primer::Alpha::Dialog.new( + id: "user-custom-field-section-dialog#{@user_custom_field_section.id}", title: t("settings.user_attributes.label_new_section"), + size: :medium_portrait, + data: { "keep-open-on-submit": true } + ) + ) do |_dialog| + render(Settings::UserCustomFieldSections::DialogBodyFormComponent.new(user_custom_field_section: @user_custom_field_section)) + end + end + end + end + end + if @user_custom_fields.empty? + component.with_row(data: { "empty-list-item": true }) do + flex_layout(align_items: :center, justify_content: :space_between) do |empty_list_container| + empty_list_container.with_column(ml: 4, mr: 2) do + render(Primer::Beta::Text.new(color: :subtle)) { t("settings.user_attributes.label_no_user_custom_fields") } + end + empty_list_container.with_column do + render(Primer::Alpha::ActionMenu.new(data: { test_selector: "new-user-custom-field-in-section-button" })) do |menu| + menu.with_show_button( + scheme: :secondary, + "aria-label": t("settings.user_attributes.label_new_attribute") + ) do |btn| + btn.with_leading_visual_icon(icon: :plus) + btn.with_trailing_visual_icon(icon: :"triangle-down") + + t("settings.user_attributes.label_new_attribute") + end + + OpenProject::CustomFieldFormat.available_for_class_name("User") + .sort_by(&:name) + .map do |format| + action_menu_item_for_custom_field_format(menu, format) + end + end + end + end + end + else + first_and_last = [@user_custom_fields.first, @user_custom_fields.last] + @user_custom_fields.each do |user_custom_field| + component.with_row( + data: draggable_item_config(user_custom_field) + ) do + render( + custom_field_row_component_class.new( + user_custom_field:, + first_and_last: + ) + ) + end + end + end + end + end +%> diff --git a/app/components/settings/user_custom_field_sections/show_component.rb b/app/components/settings/user_custom_field_sections/show_component.rb new file mode 100644 index 000000000000..255d116ced28 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/show_component.rb @@ -0,0 +1,162 @@ +# 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 Settings + module UserCustomFieldSections + class ShowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(user_custom_field_section:, first_and_last: []) + super + + @user_custom_field_section = user_custom_field_section + @user_custom_fields = user_custom_field_section.custom_fields + + @first_and_last = first_and_last + end + + def custom_field_row_component_class + Settings::UserCustomFieldSections::CustomFieldRowComponent + end + + private + + def wrapper_uniq_by + @user_custom_field_section.id + end + + def drag_and_drop_target_config + { + generic_drag_and_drop_target: "container", + "target-container-accessor": ".Box > ul", + "target-id": @user_custom_field_section.id, + "target-allowed-drag-type": "custom-field" + } + end + + def draggable_item_config(user_custom_field) + { + "draggable-id": user_custom_field.id, + "draggable-type": "custom-field", + "drop-url": drop_admin_settings_user_custom_field_path(user_custom_field) + } + end + + def move_actions(menu) + unless first? + move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") + move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") + end + unless last? + move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") + move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), "move-to-bottom") + end + end + + def move_action_item(menu, move_to, label_text, icon) + menu.with_item(label: label_text, + href: move_admin_settings_user_custom_field_section_path(@user_custom_field_section, move_to:), + form_arguments: { + method: :put, data: { "turbo-stream": true, + test_selector: "user-custom-field-section-move-#{move_to}" } + }) do |item| + item.with_leading_visual_icon(icon:) + end + end + + def disabled_delete_action_item(menu) + menu.with_item(label: t("text_destroy"), disabled: true) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + + def edit_action_item(menu) + menu.with_item(label: t("settings.user_attributes.label_edit_section"), + tag: :button, + content_arguments: { + "data-show-dialog-id": "user-custom-field-section-dialog#{@user_custom_field_section.id}", + "data-test-selector": "user-custom-field-section-edit" + }, + value: "") do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + + def delete_action_item(menu) + menu.with_item(label: t("text_destroy"), + scheme: :danger, + href: admin_settings_user_custom_field_section_path(@user_custom_field_section), + form_arguments: { + method: :delete, + data: { + turbo_confirm: t(:text_are_you_sure), + turbo_stream: true, + test_selector: "user-custom-field-section-delete" + } + }) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + + def first? + @first ||= + if @first_and_last.first + @first_and_last.first == @user_custom_field_section + else + @user_custom_field_section.first? + end + end + + def last? + @last ||= + if @first_and_last.last + @first_and_last.last == @user_custom_field_section + else + @user_custom_field_section.last? + end + end + + def action_menu_item_for_custom_field_format(menu, format) + menu.with_item( + label: helpers.label_for_custom_field_format(format.name), + tag: :a, + href: new_admin_settings_user_custom_field_path( + field_format: format.name, + custom_field_section_id: @user_custom_field_section.id + ), + content_arguments: { data: { turbo: "false", + test_selector: "new-user-custom-field-in-section-button-#{format.name}" } } + ) + end + end + end +end diff --git a/app/components/settings/user_custom_fields/edit_form_header_component.html.erb b/app/components/settings/user_custom_fields/edit_form_header_component.html.erb new file mode 100644 index 000000000000..158364331b15 --- /dev/null +++ b/app/components/settings/user_custom_fields/edit_form_header_component.html.erb @@ -0,0 +1,8 @@ +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { page_title } + header.with_breadcrumbs(breadcrumbs_items, selected_item_font_weight: :normal) + + helpers.render_tab_header_nav(header, tabs, test_selector: :user_attribute_detail_header) + end +%> diff --git a/app/components/settings/user_custom_fields/edit_form_header_component.rb b/app/components/settings/user_custom_fields/edit_form_header_component.rb new file mode 100644 index 000000000000..c8ca8885de6c --- /dev/null +++ b/app/components/settings/user_custom_fields/edit_form_header_component.rb @@ -0,0 +1,82 @@ +# 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 Settings + module UserCustomFields + class EditFormHeaderComponent < ApplicationComponent + def initialize(custom_field:, selected:) + super + @custom_field = custom_field + end + + def tabs + tabs = [ + { + name: "user_custom_field_edit", + path: edit_admin_settings_user_custom_field_path(@custom_field), + label: t(:label_details) + } + ] + + if @custom_field.list? + tabs << { + name: "items", + path: list_items_admin_settings_user_custom_field_path(@custom_field), + label: t(:label_item_plural) + } + end + + tabs << + { + name: "attribute_help_text", + path: attribute_help_text_admin_settings_user_custom_field_path(@custom_field), + label: AttributeHelpText.human_attribute_name(:help_text) + } + + tabs + end + + def page_title + concat @custom_field.attribute_in_database("name") + concat render(Primer::Beta::Text.new(color: :muted)) { " (#{helpers.label_for_custom_field_format(@custom_field.field_format)})" } + end + + def breadcrumbs_items + [ + { href: admin_index_path, text: t("label_administration") }, + { href: admin_settings_user_custom_fields_path, text: t("label_user_and_permission") }, + { href: admin_settings_user_custom_fields_path, text: t("settings.user_attributes.heading") }, + helpers.nested_breadcrumb_element(helpers.label_for_custom_field_format(@custom_field.field_format), + @custom_field.attribute_in_database("name")) + ] + end + end + end +end diff --git a/app/components/settings/user_custom_fields/header_component.html.erb b/app/components/settings/user_custom_fields/header_component.html.erb new file mode 100644 index 000000000000..b5079dd3d06e --- /dev/null +++ b/app/components/settings/user_custom_fields/header_component.html.erb @@ -0,0 +1,52 @@ +<%= component_wrapper do %> + <%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title(variant: :default) { t("settings.user_attributes.heading") } + header.with_description { t("settings.user_attributes.heading_description") } + header.with_breadcrumbs(breadcrumbs_items) + end + %> + + <%= + render Primer::OpenProject::SubHeader.new do |subheader| + subheader.with_action_menu( + leading_icon: :plus, + trailing_icon: :"triangle-down", + label: I18n.t(:button_add), + anchor_align: :end, + button_arguments: { + scheme: :primary, + aria: { label: I18n.t(:button_add) }, + test_selector: "user-attributes-add-menu-button" + } + ) do |menu| + menu.with_item( + label: t("settings.user_attributes.label_new_section"), + id: "dialog-show-user-custom-field-section-dialog", + tag: :a, + href: new_link_admin_settings_user_custom_field_sections_path, + content_arguments: { data: { controller: "async-dialog", test_selector: "add-user-custom-field-section" } } + ) + + if allow_custom_field_creation? + menu.with_sub_menu_item( + label: t("settings.user_attributes.label_new_attribute"), + content_arguments: { data: { test_selector: "add-user-custom-field-attribute" } } + ) do |sub_menu| + OpenProject::CustomFieldFormat.enabled_for_class_name("User") + .sort_by(&:name) + .map do |format| + sub_menu.with_item( + label: helpers.label_for_custom_field_format(format.name), + tag: :a, + href: new_admin_settings_user_custom_field_path(field_format: format.name), + content_arguments: { data: { turbo: "false", + test_selector: "new-user-custom-field-button" } } + ) + end + end + end + end + end + %> +<% end %> diff --git a/app/components/settings/user_custom_fields/header_component.rb b/app/components/settings/user_custom_fields/header_component.rb new file mode 100644 index 000000000000..af1beed71163 --- /dev/null +++ b/app/components/settings/user_custom_fields/header_component.rb @@ -0,0 +1,56 @@ +# 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 Settings + module UserCustomFields + class HeaderComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + include CustomFieldsHelper + + def initialize(allow_custom_field_creation:) + super + + @allow_custom_field_creation = allow_custom_field_creation + end + + def breadcrumbs_items + [{ href: admin_index_path, text: t("label_administration") }, + { href: admin_settings_user_custom_fields_path, text: t("label_user_and_permission") }, + t("settings.user_attributes.heading")] + end + + def allow_custom_field_creation? + @allow_custom_field_creation + end + end + end +end diff --git a/app/components/settings/user_custom_fields/new_form_header_component.html.erb b/app/components/settings/user_custom_fields/new_form_header_component.html.erb new file mode 100644 index 000000000000..5dd415ed723c --- /dev/null +++ b/app/components/settings/user_custom_fields/new_form_header_component.html.erb @@ -0,0 +1,6 @@ +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { page_title } + header.with_breadcrumbs(breadcrumb_items, selected_item_font_weight: :normal) + end +%> diff --git a/app/components/settings/user_custom_fields/new_form_header_component.rb b/app/components/settings/user_custom_fields/new_form_header_component.rb new file mode 100644 index 000000000000..38bd818ba424 --- /dev/null +++ b/app/components/settings/user_custom_fields/new_form_header_component.rb @@ -0,0 +1,50 @@ +# 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 Settings + module UserCustomFields + class NewFormHeaderComponent < ApplicationComponent + def page_title + concat t("settings.user_attributes.new.heading") + concat render(Primer::Beta::Text.new(color: :muted)) { " (#{helpers.label_for_custom_field_format(model.field_format)})" } + end + + def breadcrumb_items + [ + { href: admin_index_path, text: t("label_administration") }, + { href: admin_settings_user_custom_fields_path, text: t("label_user_and_permission") }, + { href: admin_settings_user_custom_fields_path, text: t("settings.user_attributes.heading") }, + helpers.nested_breadcrumb_element(helpers.label_for_custom_field_format(model.field_format), + t("settings.user_attributes.new.heading")) + ] + end + end + end +end diff --git a/app/contracts/user_custom_field_sections/base_contract.rb b/app/contracts/user_custom_field_sections/base_contract.rb new file mode 100644 index 000000000000..e136f67afb28 --- /dev/null +++ b/app/contracts/user_custom_field_sections/base_contract.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 UserCustomFieldSections + class BaseContract < ::ModelContract + include RequiresAdminGuard + + attribute :name + attribute :position + attribute :type + end +end diff --git a/app/contracts/user_custom_field_sections/create_contract.rb b/app/contracts/user_custom_field_sections/create_contract.rb new file mode 100644 index 000000000000..ed0cd86c3fc6 --- /dev/null +++ b/app/contracts/user_custom_field_sections/create_contract.rb @@ -0,0 +1,34 @@ +# 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 UserCustomFieldSections + class CreateContract < BaseContract + end +end diff --git a/app/contracts/user_custom_field_sections/delete_contract.rb b/app/contracts/user_custom_field_sections/delete_contract.rb new file mode 100644 index 000000000000..4ed0b2a9b816 --- /dev/null +++ b/app/contracts/user_custom_field_sections/delete_contract.rb @@ -0,0 +1,41 @@ +# 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 UserCustomFieldSections + class DeleteContract < BaseContract + validate :section_not_in_use + + def section_not_in_use + if model.custom_fields.exists? + errors.add(:base, :in_use) + end + end + end +end diff --git a/app/contracts/user_custom_field_sections/update_contract.rb b/app/contracts/user_custom_field_sections/update_contract.rb new file mode 100644 index 000000000000..504945fc415b --- /dev/null +++ b/app/contracts/user_custom_field_sections/update_contract.rb @@ -0,0 +1,34 @@ +# 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 UserCustomFieldSections + class UpdateContract < BaseContract + end +end diff --git a/app/controllers/admin/settings/user_custom_field_sections_controller.rb b/app/controllers/admin/settings/user_custom_field_sections_controller.rb new file mode 100644 index 000000000000..45ecfd50841a --- /dev/null +++ b/app/controllers/admin/settings/user_custom_field_sections_controller.rb @@ -0,0 +1,129 @@ +# 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::Settings + class UserCustomFieldSectionsController < ::Admin::SettingsController + include OpTurbo::ComponentStream + include Admin::Settings::UserCustomFields::ComponentStreams + + before_action :set_user_custom_field_section, only: %i[update move drop destroy] + + def create + call = ::UserCustomFieldSections::CreateService.new(user: current_user).call( + user_custom_field_section_params.merge(position: 1) + ) + + if call.success? + close_dialog_via_turbo_stream("##{Settings::UserCustomFieldSections::NewSectionDialogComponent::MODAL_ID}") + update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) + update_sections_via_turbo_stream(user_custom_field_sections: UserCustomFieldSection.all) + else + update_section_dialog_body_form_via_turbo_stream(user_custom_field_section: call.result) + end + + respond_with_turbo_streams + end + + def update + call = ::UserCustomFieldSections::UpdateService.new(user: current_user, model: @user_custom_field_section).call( + user_custom_field_section_params + ) + + if call.success? + close_dialog_via_turbo_stream("#user-custom-field-section-dialog#{@user_custom_field_section.id}") + update_section_via_turbo_stream(user_custom_field_section: call.result) + else + update_section_dialog_body_form_via_turbo_stream(user_custom_field_section: call.result) + end + + respond_with_turbo_streams + end + + def destroy + call = ::UserCustomFieldSections::DeleteService.new(user: current_user, model: @user_custom_field_section).call + + if call.success? + update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) + update_sections_via_turbo_stream(user_custom_field_sections: UserCustomFieldSection.all) + else + # TODO: show error message + end + + respond_with_turbo_streams + end + + def move + call = ::UserCustomFieldSections::UpdateService.new(user: current_user, model: @user_custom_field_section).call( + move_to: params[:move_to]&.to_sym + ) + + if call.success? + update_sections_via_turbo_stream(user_custom_field_sections: UserCustomFieldSection.all) + else + # TODO: show error message + end + + respond_with_turbo_streams + end + + def drop + call = ::UserCustomFieldSections::UpdateService.new(user: current_user, model: @user_custom_field_section).call( + position: params[:position].to_i + ) + + if call.success? + update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) + update_sections_via_turbo_stream(user_custom_field_sections: UserCustomFieldSection.all) + else + # TODO: show error message + end + + respond_with_turbo_streams + end + + def new_link + respond_with_dialog Settings::UserCustomFieldSections::NewSectionDialogComponent.new + end + + private + + def set_user_custom_field_section + @user_custom_field_section = UserCustomFieldSection.find(params[:id]) + end + + def allow_custom_field_creation? + UserCustomFieldSection.any? + end + + def user_custom_field_section_params + params.expect(user_custom_field_section: [:name]) + end + end +end diff --git a/app/controllers/admin/settings/user_custom_fields_controller.rb b/app/controllers/admin/settings/user_custom_fields_controller.rb new file mode 100644 index 000000000000..3fcb8d8072b1 --- /dev/null +++ b/app/controllers/admin/settings/user_custom_fields_controller.rb @@ -0,0 +1,152 @@ +# 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::Settings + class UserCustomFieldsController < ::Admin::SettingsController + include CustomFields::SharedActions + include CustomFields::AttributeHelpTextActions + include OpTurbo::ComponentStream + include FlashMessagesOutputSafetyHelper + include Admin::Settings::UserCustomFields::ComponentStreams + + menu_item :user_custom_fields_settings + + # rubocop:disable Rails/LexicallyScopedActionFilter + before_action :set_sections, only: %i[show index edit update move drop] + before_action :find_custom_field, + only: %i(show edit update destroy delete_option reorder_alphabetical + move drop attribute_help_text update_attribute_help_text list_items) + before_action :prepare_custom_option_position, only: %i(update create) + before_action :find_custom_option, only: :delete_option + before_action :find_or_initialize_attribute_help_text, only: %i[attribute_help_text update_attribute_help_text] + # rubocop:enable Rails/LexicallyScopedActionFilter + + def index + @allow_custom_field_creation = @user_custom_field_sections.any? + + respond_to :html + end + + def show + render :edit + end + + def new + @custom_field = UserCustomField.new(custom_field_section_id: params[:custom_field_section_id], + field_format: params[:field_format]) + + respond_to :html + end + + def edit; end + + def list_items; end + + def move + result = CustomFields::UpdateService.new(user: current_user, model: @custom_field).call( + move_to: params[:move_to]&.to_sym + ) + + if result.success? + update_sections_via_turbo_stream(user_custom_field_sections: @user_custom_field_sections) + else + render_error_flash_message_via_turbo_stream( + message: join_flash_messages(result.errors) + ) + end + + respond_with_turbo_streams + end + + def drop + result = ::UserCustomFields::DropService.new(user: current_user, user_custom_field: @custom_field).call( + target_id: params[:target_id], + position: params[:position] + ) + + if result.success? + drop_success_streams(result) + else + render_error_flash_message_via_turbo_stream( + message: join_flash_messages(result.errors) + ) + end + + respond_with_turbo_streams + end + + def destroy + result = CustomFields::DeleteService.new(user: current_user, model: @custom_field).call + + if result.success? + update_section_via_turbo_stream(user_custom_field_section: @custom_field.user_custom_field_section.reload) + else + render_error_flash_message_via_turbo_stream( + message: join_flash_messages(result.errors) + ) + end + + respond_with_turbo_streams + end + + def attribute_help_text + render_attribute_help_text_form + end + + def update_attribute_help_text + update_help_text + end + + private + + def set_sections + @user_custom_field_sections = UserCustomFieldSection.includes(:custom_fields).all + end + + def find_custom_field + @custom_field = UserCustomField.find(params[:id]) + end + + def drop_success_streams(call) + update_section_via_turbo_stream(user_custom_field_section: call.result[:current_section]) + if call.result[:section_changed] + update_section_via_turbo_stream(user_custom_field_section: call.result[:old_section]) + end + end + + def show_path + attribute_help_text_admin_settings_user_custom_field_path(@custom_field) + end + + def render_attribute_help_text_form(status: :ok) + render "custom_fields/attribute_help_texts/show_user", status: + end + end +end diff --git a/app/controllers/concerns/admin/settings/user_custom_fields/component_streams.rb b/app/controllers/concerns/admin/settings/user_custom_fields/component_streams.rb new file mode 100644 index 000000000000..180d1780410a --- /dev/null +++ b/app/controllers/concerns/admin/settings/user_custom_fields/component_streams.rb @@ -0,0 +1,73 @@ +# 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 Settings + module UserCustomFields + module ComponentStreams + extend ActiveSupport::Concern + + included do + def update_header_via_turbo_stream(allow_custom_field_creation:) + update_via_turbo_stream( + component: ::Settings::UserCustomFields::HeaderComponent.new( + allow_custom_field_creation: + ) + ) + end + + def update_section_via_turbo_stream(user_custom_field_section:) + update_via_turbo_stream( + component: ::Settings::UserCustomFieldSections::ShowComponent.new( + user_custom_field_section: + ) + ) + end + + def update_section_dialog_body_form_via_turbo_stream(user_custom_field_section:) + update_via_turbo_stream( + component: ::Settings::UserCustomFieldSections::DialogBodyFormComponent.new( + user_custom_field_section: + ) + ) + end + + def update_sections_via_turbo_stream(user_custom_field_sections:) + replace_via_turbo_stream( + component: ::Settings::UserCustomFieldSections::IndexComponent.new( + user_custom_field_sections: + ) + ) + end + end + end + end + end +end diff --git a/app/controllers/concerns/custom_fields/attribute_help_text_actions.rb b/app/controllers/concerns/custom_fields/attribute_help_text_actions.rb index f5d036f86610..ce5af9f72733 100644 --- a/app/controllers/concerns/custom_fields/attribute_help_text_actions.rb +++ b/app/controllers/concerns/custom_fields/attribute_help_text_actions.rb @@ -71,6 +71,8 @@ def attribute_help_text_class_for_custom_field AttributeHelpText::Project when WorkPackageCustomField AttributeHelpText::WorkPackage + when UserCustomField + AttributeHelpText::User else raise ArgumentError, "Unsupported custom field type: #{@custom_field.class}" end diff --git a/app/controllers/concerns/custom_fields/shared_actions.rb b/app/controllers/concerns/custom_fields/shared_actions.rb index eba9a48284ec..2f15cff427e7 100644 --- a/app/controllers/concerns/custom_fields/shared_actions.rb +++ b/app/controllers/concerns/custom_fields/shared_actions.rb @@ -34,24 +34,33 @@ module SharedActions included do def index_path(custom_field, params = {}) - if custom_field.type == "ProjectCustomField" + case custom_field.type + when "ProjectCustomField" admin_settings_project_custom_fields_path(**params) + when "UserCustomField" + admin_settings_user_custom_fields_path(**params) else custom_fields_path(**params) end end def edit_path(custom_field, params = {}) - if custom_field.type == "ProjectCustomField" + case custom_field.type + when "ProjectCustomField" admin_settings_project_custom_field_path(**params) + when "UserCustomField" + edit_admin_settings_user_custom_field_path(**params) else edit_custom_field_path(**params) end end def list_item_path(custom_field, params = {}) - if custom_field.type == "ProjectCustomField" + case custom_field.type + when "ProjectCustomField" list_items_admin_settings_project_custom_field_path(**params) + when "UserCustomField" + list_items_admin_settings_user_custom_field_path(**params) else list_items_custom_field_path(**params) end diff --git a/app/forms/custom_fields/details_form.rb b/app/forms/custom_fields/details_form.rb index 58d9a81828be..68044dba950a 100644 --- a/app/forms/custom_fields/details_form.rb +++ b/app/forms/custom_fields/details_form.rb @@ -54,8 +54,8 @@ class DetailsForm < ApplicationForm label: I18n.t("activerecord.attributes.project_custom_field.custom_field_section"), required: true ) do |list| - ProjectCustomFieldSection.find_each do |cs| - list.option(value: cs.id, label: cs.name) + section_class_for_model.all.each do |cs| + list.option(value: cs.id, label: cs.name.presence || I18n.t("settings.user_attributes.label_untitled_section")) end end end @@ -217,7 +217,11 @@ def instructions(field) end def show_section_field? - model.is_a?(ProjectCustomField) + model.is_a?(ProjectCustomField) || model.is_a?(UserCustomField) + end + + def section_class_for_model + model.is_a?(UserCustomField) ? UserCustomFieldSection : ProjectCustomFieldSection end def show_default_bool_field? diff --git a/app/forms/user_custom_field_sections/name_form.rb b/app/forms/user_custom_field_sections/name_form.rb new file mode 100644 index 000000000000..b3381c123eac --- /dev/null +++ b/app/forms/user_custom_field_sections/name_form.rb @@ -0,0 +1,41 @@ +# 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 UserCustomFieldSections::NameForm < ApplicationForm + form do |user_custom_field_section_form| + user_custom_field_section_form.text_field( + name: :name, + placeholder: UserCustomFieldSection.human_attribute_name(:name), + label: UserCustomFieldSection.human_attribute_name(:name), + required: true, + autofocus: true + ) + end +end diff --git a/app/models/attribute_help_text/user.rb b/app/models/attribute_help_text/user.rb new file mode 100644 index 000000000000..208c354ad550 --- /dev/null +++ b/app/models/attribute_help_text/user.rb @@ -0,0 +1,51 @@ +# 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 AttributeHelpText::User < AttributeHelpText + def self.available_attributes + attributes = {} + + UserCustomField.find_each do |field| + attributes[field.attribute_name] = field.name + end + + attributes + end + + validates :attribute_name, inclusion: { in: ->(*) { available_attributes.keys } } + + def type_caption + ::User.model_name.human + end + + def self.visible_condition(_user) + ::AttributeHelpText.where(attribute_name: available_attributes.keys) + end +end diff --git a/app/models/user_custom_field.rb b/app/models/user_custom_field.rb index f9cc648a8eda..1c36aaa70ad5 100644 --- a/app/models/user_custom_field.rb +++ b/app/models/user_custom_field.rb @@ -29,6 +29,13 @@ #++ class UserCustomField < CustomField + belongs_to :user_custom_field_section, class_name: "UserCustomFieldSection", foreign_key: :custom_field_section_id, + inverse_of: :custom_fields + + acts_as_list column: :position_in_custom_field_section, scope: [:custom_field_section_id] + + validates :custom_field_section_id, presence: true + scopes :visible def type_name diff --git a/app/models/user_custom_field_section.rb b/app/models/user_custom_field_section.rb new file mode 100644 index 000000000000..bc482b423683 --- /dev/null +++ b/app/models/user_custom_field_section.rb @@ -0,0 +1,41 @@ +# 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 UserCustomFieldSection < CustomFieldSection + def untitled? + name.blank? + end + + has_many :custom_fields, -> { order(position_in_custom_field_section: :asc) }, + class_name: "UserCustomField", + dependent: :destroy, + foreign_key: :custom_field_section_id, + inverse_of: :user_custom_field_section +end diff --git a/app/services/user_custom_field_sections/create_service.rb b/app/services/user_custom_field_sections/create_service.rb new file mode 100644 index 000000000000..ff08f9302b43 --- /dev/null +++ b/app/services/user_custom_field_sections/create_service.rb @@ -0,0 +1,34 @@ +# 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 UserCustomFieldSections + class CreateService < ::BaseServices::Create + end +end diff --git a/app/services/user_custom_field_sections/delete_service.rb b/app/services/user_custom_field_sections/delete_service.rb new file mode 100644 index 000000000000..8a09d3b05409 --- /dev/null +++ b/app/services/user_custom_field_sections/delete_service.rb @@ -0,0 +1,34 @@ +# 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 UserCustomFieldSections + class DeleteService < ::BaseServices::Delete + end +end diff --git a/app/services/user_custom_field_sections/set_attributes_service.rb b/app/services/user_custom_field_sections/set_attributes_service.rb new file mode 100644 index 000000000000..2c5e6634d931 --- /dev/null +++ b/app/services/user_custom_field_sections/set_attributes_service.rb @@ -0,0 +1,34 @@ +# 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 UserCustomFieldSections + class SetAttributesService < ::BaseServices::SetAttributes + end +end diff --git a/app/services/user_custom_field_sections/update_service.rb b/app/services/user_custom_field_sections/update_service.rb new file mode 100644 index 000000000000..a41e70a0d9a1 --- /dev/null +++ b/app/services/user_custom_field_sections/update_service.rb @@ -0,0 +1,34 @@ +# 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 UserCustomFieldSections + class UpdateService < ::BaseServices::Update + end +end diff --git a/app/services/user_custom_fields/drop_service.rb b/app/services/user_custom_fields/drop_service.rb new file mode 100644 index 000000000000..bf20fc247b50 --- /dev/null +++ b/app/services/user_custom_fields/drop_service.rb @@ -0,0 +1,95 @@ +# 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 UserCustomFields + class DropService < ::BaseServices::BaseCallable + def initialize(user:, user_custom_field:) + super() + @user = user + @user_custom_field = user_custom_field + end + + def perform + service_call = validate_permissions + service_call = perform_drop(service_call, params) if service_call.success? + + service_call + end + + def validate_permissions + if @user.admin? + ServiceResult.success + else + ServiceResult.failure(errors: { base: :error_unauthorized }) + end + end + + def perform_drop(service_call, params) + begin + section_changed, current_section, old_section = check_and_update_section_if_changed(params) + update_position(params[:position]&.to_i) + + service_call.success = true + service_call.result = { section_changed:, current_section:, old_section: } + rescue StandardError => e + service_call.success = false + service_call.errors = e.message + end + + service_call + end + + private + + def check_and_update_section_if_changed(params) + current_section = @user_custom_field.user_custom_field_section + new_section_id = params[:target_id]&.to_i + + if current_section.id != new_section_id + old_section = current_section + current_section = update_section(new_section_id) + return [true, current_section, old_section] + end + + [false, current_section, nil] + end + + def update_section(new_section_id) + current_section = UserCustomFieldSection.find(new_section_id) + @user_custom_field.remove_from_list + @user_custom_field.update(user_custom_field_section: current_section) + current_section + end + + def update_position(new_position) + @user_custom_field.insert_at(new_position) + end + end +end diff --git a/app/views/admin/settings/user_custom_fields/edit.html.erb b/app/views/admin/settings/user_custom_fields/edit.html.erb new file mode 100644 index 000000000000..a37fef0fb325 --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/edit.html.erb @@ -0,0 +1,41 @@ +<%#-- 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. + +++#%> + +<% html_title t(:label_administration), t("settings.user_attributes.heading"), @custom_field.name %> + +<%= + render( + Settings::UserCustomFields::EditFormHeaderComponent.new( + custom_field: @custom_field, + selected: :user_custom_field_edit + ) + ) +%> + +<%= render CustomFields::DetailsComponent.new(@custom_field) %> diff --git a/app/views/admin/settings/user_custom_fields/index.html.erb b/app/views/admin/settings/user_custom_fields/index.html.erb new file mode 100644 index 000000000000..f50dbb6087dc --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/index.html.erb @@ -0,0 +1,38 @@ +<%#-- 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. + +++#%> +<% html_title t(:label_administration), t("settings.user_attributes.heading") %> + +
+ <%= render(Settings::UserCustomFields::HeaderComponent.new(allow_custom_field_creation: @allow_custom_field_creation)) %> + <%= render( + Settings::UserCustomFieldSections::IndexComponent.new( + user_custom_field_sections: @user_custom_field_sections + ) + ) %> +
diff --git a/app/views/admin/settings/user_custom_fields/list_items.html.erb b/app/views/admin/settings/user_custom_fields/list_items.html.erb new file mode 100644 index 000000000000..7e3adfb1c150 --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/list_items.html.erb @@ -0,0 +1,76 @@ +<%#-- 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. + +++#%> + +<% html_title t(:label_administration), t("settings.user_attributes.heading"), @custom_field.name %> + +<%= + render( + Settings::UserCustomFields::EditFormHeaderComponent.new( + custom_field: @custom_field, + selected: :user_custom_field_edit + ) + ) +%> + +<%= settings_primer_form_with( + model: @custom_field, + scope: :custom_field, + url: admin_settings_user_custom_field_path(@custom_field), + html: { method: :put, id: "custom_field_form" } + ) do |f| %> + <%= render partial: "custom_fields/custom_options", locals: { custom_field: @custom_field, f: f } %> + + <%= + flex_layout(mt: 4) do |flex| + flex.with_column do + render Primer::Beta::Button.new( + scheme: :secondary, + tag: :a, + href: reorder_alphabetical_admin_settings_user_custom_field_path(@custom_field), + data: { + turbo_method: :post, + turbo_confirm: t("custom_fields.reorder_confirmation") + }, + mr: 2 + ) do + t("custom_fields.reorder_alphabetical") + end + end + flex.with_column do + render Primer::Beta::Button.new( + scheme: :primary, + type: :submit + ) do |button| + button.with_leading_visual_icon(icon: :check) + t(:button_save) + end + end + end + %> +<% end %> diff --git a/app/views/admin/settings/user_custom_fields/new.html.erb b/app/views/admin/settings/user_custom_fields/new.html.erb new file mode 100644 index 000000000000..81b45f52b56e --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/new.html.erb @@ -0,0 +1,34 @@ +<%#-- 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. + +++#%> + +<% html_title t(:label_administration), t("settings.user_attributes.heading"), t("settings.user_attributes.new.heading") %> + +<%= render(Settings::UserCustomFields::NewFormHeaderComponent.new(@custom_field)) %> + +<%= render CustomFields::DetailsComponent.new(@custom_field) %> diff --git a/app/views/custom_fields/attribute_help_texts/show_user.html.erb b/app/views/custom_fields/attribute_help_texts/show_user.html.erb new file mode 100644 index 000000000000..51f3a07539fa --- /dev/null +++ b/app/views/custom_fields/attribute_help_texts/show_user.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, 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. + +++#%> + +<% html_title t(:label_administration), t("settings.user_attributes.heading"), @custom_field.name, AttributeHelpText.human_plural_model_name %> + +<%= + render( + Settings::UserCustomFields::EditFormHeaderComponent.new( + custom_field: @custom_field, + selected: :attribute_help_text + ) + ) +%> + +<%= error_messages_for "attribute_help_text" %> + +
+ <%= + primer_form_with( + model: @attribute_help_text, + scope: :attribute_help_text, + url: update_attribute_help_text_admin_settings_user_custom_field_path(@custom_field), + method: :put + ) do |f| + render AttributeHelpTexts::Form.new(f, hide_attribute_name: true) + end + %> +
diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index 162c2712345d..4da7b8c1cbb3 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -354,6 +354,12 @@ parent: :users_and_permissions, enterprise_feature: "placeholder_users" + menu.push :user_custom_fields_settings, + { controller: "/admin/settings/user_custom_fields", action: :index }, + if: ->(_) { User.current.admin? }, + caption: :label_user_attributes_plural, + parent: :users_and_permissions + menu.push :groups, { controller: "/groups" }, if: ->(_) { User.current.admin? }, diff --git a/config/locales/en.yml b/config/locales/en.yml index 9f8e0446a380..ee30c75d4474 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1954,6 +1954,8 @@ en: workspace_type: "Workspace type" project_custom_field: custom_field_section: Section + user_custom_field: + custom_field_section: Section subproject_template_assignment: workspace_type: "Workspace type" project/phase: @@ -4583,6 +4585,7 @@ en: label_user_menu: "User menu" label_user_new: "New user" label_user_plural: "Users" + label_user_attributes_plural: "User attributes" label_user_search: "Search for user" label_user_settings: "User settings" label_users_settings: "Users settings" @@ -5690,6 +5693,18 @@ en: side_panel: label: "Side panel" description: "Add all the project attributes in a section inside the right side panel in the project overview." + user_attributes: + label_untitled_section: "Untitled section" + heading: "User attributes" + heading_description: "These user attributes appear on user profiles and information cards. You can add new attributes, group them into sections and re-order them." + label_new_attribute: "User attribute" + label_new_section: "Section" + label_edit_section: "Edit title" + label_section_actions: "Section actions" + label_user_custom_field_actions: "User attribute actions" + label_no_user_custom_fields: "No user attributes defined in this section" + new: + heading: "New attribute" project_initiation_request: header_description: > OpenProject can generate a step-by-step wizard to help project managers fill out a project initiation request. diff --git a/config/routes.rb b/config/routes.rb index eb090e718d7a..4bca53525afb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -765,6 +765,32 @@ get :new_link end end + + resources :user_custom_fields, controller: "/admin/settings/user_custom_fields" do + member do + delete "options/:option_id", action: "delete_option", as: :delete_option_of + post :reorder_alphabetical + put :move + put :drop + + get :attribute_help_text + put :update_attribute_help_text + + get :list_items + end + end + + resources :user_custom_field_sections, controller: "/admin/settings/user_custom_field_sections", + only: %i[create update destroy] do + member do + put :move + put :drop + end + collection do + get :new_link + end + end + resource :working_days_and_hours, controller: "/admin/settings/working_days_and_hours_settings", only: %i[show update] do post :confirm_changes end diff --git a/db/migrate/20260528144922_create_default_user_custom_field_section.rb b/db/migrate/20260528144922_create_default_user_custom_field_section.rb new file mode 100644 index 000000000000..c38d4e86e1c9 --- /dev/null +++ b/db/migrate/20260528144922_create_default_user_custom_field_section.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class CreateDefaultUserCustomFieldSection < ActiveRecord::Migration[8.1] + def up + section_id = execute(<<~SQL).first["id"] + INSERT INTO custom_field_sections (type, name, position, created_at, updated_at) + VALUES ('UserCustomFieldSection', NULL, 1, NOW(), NOW()) + RETURNING id + SQL + + execute(<<~SQL) + UPDATE custom_fields + SET custom_field_section_id = #{section_id} + WHERE type = 'UserCustomField' + AND custom_field_section_id IS NULL + SQL + end + + def down + execute(<<~SQL) + UPDATE custom_fields + SET custom_field_section_id = NULL + WHERE type = 'UserCustomField' + AND custom_field_section_id IN ( + SELECT id FROM custom_field_sections + WHERE type = 'UserCustomFieldSection' AND name IS NULL + ) + SQL + + execute(<<~SQL) + DELETE FROM custom_field_sections + WHERE type = 'UserCustomFieldSection' AND name IS NULL + SQL + end +end diff --git a/spec/factories/custom_field_factory.rb b/spec/factories/custom_field_factory.rb index 3f2433d78e5a..ee595c6d36a0 100644 --- a/spec/factories/custom_field_factory.rb +++ b/spec/factories/custom_field_factory.rb @@ -243,7 +243,9 @@ end end - factory :user_custom_field, class: "UserCustomField" + factory :user_custom_field, class: "UserCustomField" do + user_custom_field_section + end factory :group_custom_field, class: "GroupCustomField" diff --git a/spec/factories/custom_field_section.rb b/spec/factories/custom_field_section.rb index bc71a5d1b769..7f6b47f20d08 100644 --- a/spec/factories/custom_field_section.rb +++ b/spec/factories/custom_field_section.rb @@ -36,5 +36,6 @@ updated_at { Time.zone.now } factory :project_custom_field_section, class: "ProjectCustomFieldSection" + factory :user_custom_field_section, class: "UserCustomFieldSection" end end diff --git a/spec/features/admin/custom_fields/shared_custom_field_expectations.rb b/spec/features/admin/custom_fields/shared_custom_field_expectations.rb index 385cf0ac137d..fb2623bd12d5 100644 --- a/spec/features/admin/custom_fields/shared_custom_field_expectations.rb +++ b/spec/features/admin/custom_fields/shared_custom_field_expectations.rb @@ -180,7 +180,7 @@ def expect_page_to_have(selectors) expect(page).to have_field(label_name) - if type == "Project" + if %w[Project User].include?(type) expect(page).to have_field(label_section) else expect(page).to have_no_label(label_section) diff --git a/spec/features/admin/custom_fields/users/index_spec.rb b/spec/features/admin/custom_fields/users/index_spec.rb new file mode 100644 index 000000000000..f7dc094b29e0 --- /dev/null +++ b/spec/features/admin/custom_fields/users/index_spec.rb @@ -0,0 +1,220 @@ +# 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" +require_relative "shared_context" + +RSpec.describe "List user custom fields", :js do + include_context "with seeded user custom fields" + + let(:cf_index_page) { Pages::Admin::Settings::UserCustomFields::Index.new } + + context "with insufficient permissions" do + it "is not accessible" do + login_as(non_admin) + cf_index_page.visit! + + expect(page).to have_text("You are not authorized to access this page.") + end + end + + context "with sufficient permissions" do + before do + login_as(admin) + cf_index_page.visit! + end + + it "only allows user attribute creation when there is at least one section" do + cf_index_page.expect_add_user_attribute_submenu + + boolean_user_custom_field.destroy + string_user_custom_field.destroy + list_user_custom_field.destroy + section_for_input_fields.destroy + + cf_index_page.visit! + + # The (empty) select section is still there, so we can still add user attributes + cf_index_page.expect_add_user_attribute_submenu + + within_user_custom_field_section_menu(section_for_select_fields) do + accept_confirm do + click_on("Delete") + end + end + + # Now there are no sections left + cf_index_page.expect_no_add_user_attribute_submenu(close: false) + + cf_index_page.visit! + cf_index_page.expect_no_add_user_attribute_submenu(close: false) + end + + it "shows all sections in the correct order" do + containers = page.all(".op-user-custom-field-section-container") + + expect(containers[0].text).to include(section_for_input_fields.name) + expect(containers[1].text).to include(section_for_select_fields.name) + end + + it "shows all custom fields within their section" do + within_user_custom_field_section_container(section_for_input_fields) do + containers = page.all(".op-user-custom-field-container") + + expect(containers[0].text).to include(boolean_user_custom_field.name) + expect(containers[1].text).to include(string_user_custom_field.name) + end + + within_user_custom_field_section_container(section_for_select_fields) do + expect(page).to have_text(list_user_custom_field.name) + end + end + + it "allows to delete a section only if no user custom fields are assigned to it" do + within_user_custom_field_section_menu(section_for_select_fields) do + expect(page).to have_css("button[aria-disabled='true']", text: "Delete") + end + + list_user_custom_field.destroy + + cf_index_page.visit! + + within_user_custom_field_section_menu(section_for_select_fields) do + expect(page).to have_no_css("button[aria-disabled='true']", text: "Delete") + + accept_confirm do + click_on("Delete") + end + end + + expect(page) + .to have_no_css("[data-test-selector='user-custom-field-section-container-#{section_for_select_fields.id}']") + end + + it "allows to edit a section title" do + within_user_custom_field_section_menu(section_for_input_fields) do + click_on("Edit title") + end + + fill_in("user_custom_field_section_name", with: "Updated section name") + + click_on("Save") + + expect(page).to have_no_text(section_for_input_fields.name) + expect(page).to have_text("Updated section name") + end + + it "allows to create a new section" do + within "#settings-user-custom-fields-header-component" do + page.find_test_selector("user-attributes-add-menu-button").click + click_on("dialog-show-user-custom-field-section-dialog") + end + + fill_in("user_custom_field_section_name", with: "New section name") + + click_on("Save") + + expect(page).to have_text("New section name") + + containers = page.all(".op-user-custom-field-section-container") + + expect(containers[0].text).to include("New section name") + expect(containers[1].text).to include(section_for_input_fields.name) + expect(containers[2].text).to include(section_for_select_fields.name) + end + + it "allows to delete a custom field" do + within_user_custom_field_menu(boolean_user_custom_field) do + accept_confirm do + click_on("Delete") + end + end + + expect(page).to have_no_css("[data-test-selector='user-custom-field-container-#{boolean_user_custom_field.id}']") + end + + it "redirects to the custom field edit page via menu item" do + within_user_custom_field_menu(boolean_user_custom_field) do + click_on("Edit") + end + + expect(page).to have_current_path(edit_admin_settings_user_custom_field_path(boolean_user_custom_field)) + end + + it "redirects to the custom field edit page via click on the name" do + within_user_custom_field_container(boolean_user_custom_field) do + click_on(boolean_user_custom_field.name) + end + + expect(page).to have_current_path(edit_admin_settings_user_custom_field_path(boolean_user_custom_field)) + end + + it "redirects to the new custom field page via the empty section button" do + boolean_user_custom_field.destroy + string_user_custom_field.destroy + + cf_index_page.visit! + + within_user_custom_field_section_container(section_for_input_fields) do + page.find_test_selector("new-user-custom-field-in-section-button").click + page.find_test_selector("new-user-custom-field-in-section-button-int").click + end + + expect(page).to have_current_path(new_admin_settings_user_custom_field_path( + field_format: "int", + custom_field_section_id: section_for_input_fields.id + )) + end + end + + # helper methods + + def within_user_custom_field_section_container(section, &) + within_test_selector("user-custom-field-section-container-#{section.id}", &) + end + + def within_user_custom_field_section_menu(section, &) + within_user_custom_field_section_container(section) do + page.find_test_selector("user-custom-field-section-action-menu").click + within("anchored-position", &) + end + end + + def within_user_custom_field_container(custom_field, &) + within_test_selector("user-custom-field-container-#{custom_field.id}", &) + end + + def within_user_custom_field_menu(custom_field, &) + within_user_custom_field_container(custom_field) do + page.find_test_selector("user-custom-field-action-menu").click + within("anchored-position", &) + end + end +end diff --git a/spec/features/admin/custom_fields/users/list_spec.rb b/spec/features/admin/custom_fields/users/list_spec.rb index ade785e1116c..7c36fa83b1a3 100644 --- a/spec/features/admin/custom_fields/users/list_spec.rb +++ b/spec/features/admin/custom_fields/users/list_spec.rb @@ -32,7 +32,66 @@ require_relative "../shared_custom_field_expectations" RSpec.describe "users list custom fields", :js do - it_behaves_like "list custom fields", "Users" + let(:user) { create(:admin) } + let(:section) { create(:user_custom_field_section, name: "Test section") } + let(:cf_page) { Pages::Admin::Settings::UserCustomFields::Index.new } + + current_user { user } + + before { section } + + it "has the options in the right order" do + cf_page.visit! + cf_page.click_to_create_new_custom_field "List" + + fill_in "custom_field_name", with: "Operating System" + select section.name, from: "custom_field_custom_field_section_id" + check "multi_value" + + click_on "Save" + + expect(page).to have_text("Successful creation") + expect(page).to have_field("multi_value", checked: true) + + click_link "Items" + wait_for_network_idle + + expect(page).to have_css(".custom-option-row", count: 1) + within all(".custom-option-row").last do + find(".custom-option-value input").set "Windows" + find(".custom-option-default-value input").set true + end + + retry_block do + page.find_test_selector("add-custom-option").click + expect(page).to have_css(".custom-option-row", count: 2) + end + + within all(".custom-option-row").last do + find(".custom-option-value input").set "Linux" + end + + retry_block do + page.find_test_selector("add-custom-option").click + expect(page).to have_css(".custom-option-row", count: 3) + end + + within all(".custom-option-row").last do + find(".custom-option-value input").set "Solaris" + click_on accessible_name: "Move to top" + end + + click_on "Save" + + expect(page).to have_css(".custom-option-row", count: 3) + expect(page).to have_field("custom_field_custom_options_attributes_0_value", with: "Solaris") + expect(page).to have_field("custom_field_custom_options_attributes_1_value", with: "Windows") + expect(page).to have_field("custom_field_custom_options_attributes_2_value", with: "Linux") + + expect(page).to have_field("custom_field_custom_options_attributes_0_default_value", checked: false) + expect(page).to have_field("custom_field_custom_options_attributes_1_default_value", checked: true) + expect(page).to have_field("custom_field_custom_options_attributes_2_default_value", checked: false) + end it_behaves_like "expected fields for the custom field's format", "Users", "List" end diff --git a/spec/features/admin/custom_fields/users/shared_context.rb b/spec/features/admin/custom_fields/users/shared_context.rb new file mode 100644 index 000000000000..09884f2f9722 --- /dev/null +++ b/spec/features/admin/custom_fields/users/shared_context.rb @@ -0,0 +1,55 @@ +# 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. +#++ + +RSpec.shared_context "with seeded user custom fields" do + shared_let(:admin) { create(:admin) } + shared_let(:non_admin) { create(:user) } + + shared_let(:section_for_input_fields, refind: true) do + create(:user_custom_field_section, name: "Input fields") + end + shared_let(:section_for_select_fields, refind: true) do + create(:user_custom_field_section, name: "Select fields") + end + + shared_let(:boolean_user_custom_field, refind: true) do + create(:user_custom_field, name: "Boolean field", field_format: "bool", + user_custom_field_section: section_for_input_fields) + end + shared_let(:string_user_custom_field, refind: true) do + create(:user_custom_field, name: "String field", field_format: "string", + user_custom_field_section: section_for_input_fields) + end + shared_let(:list_user_custom_field, refind: true) do + create(:user_custom_field, name: "List field", field_format: "list", + possible_values: ["Option 1", "Option 2", "Option 3"], + user_custom_field_section: section_for_select_fields) + end +end diff --git a/spec/support/cuprite_setup.rb b/spec/support/cuprite_setup.rb index 7576233d1cce..4b3bd524b5fa 100644 --- a/spec/support/cuprite_setup.rb +++ b/spec/support/cuprite_setup.rb @@ -142,6 +142,23 @@ def configure_remote_chrome(options) options end +# Ferrum v0.17.2 regression: https://github.com/rubycdp/ferrum/issues/578 +Ferrum::Contexts.prepend(Module.new do + def reset + super + @default_context = nil + end + + private + + def add_context(context_id) + return if contexts[context_id] + + context = Ferrum::Context.new(@client, self, context_id) + contexts[context_id] = context + end +end) + register_better_cuprite "en" RSpec.configure do |config| diff --git a/spec/support/pages/admin/settings/user_custom_fields/index.rb b/spec/support/pages/admin/settings/user_custom_fields/index.rb new file mode 100644 index 000000000000..987a7f1a946b --- /dev/null +++ b/spec/support/pages/admin/settings/user_custom_fields/index.rb @@ -0,0 +1,74 @@ +# 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 Pages + module Admin + module Settings + module UserCustomFields + class Index < ::Pages::Page + def path + "/admin/settings/user_custom_fields" + end + + def expect_add_user_attribute_submenu(close: true) + within_add_menu(close:) do + expect(page).to have_test_selector("add-user-custom-field-attribute") + end + end + + def expect_no_add_user_attribute_submenu(close: true) + within_add_menu(close:) do + expect(page).to have_no_test_selector("add-user-custom-field-attribute") + end + end + + def click_to_create_new_custom_field(type) + within_add_menu do + click_button "User attribute" + click_on type + end + wait_for_network_idle + end + + private + + def within_add_menu(close: false, &) + wait_for_network_idle + + button = find_button("Add") + button.click + within(button.ancestor("action-menu").find("action-list"), &) + button.click if close + end + end + end + end + end +end From b58934c086320ec88081abba1da0589284081e43 Mon Sep 17 00:00:00 2001 From: David F Date: Thu, 28 May 2026 17:20:09 +0200 Subject: [PATCH 02/20] Fix a modal dialog closing before the error could be rendered. --- .../dialog_body_form_component.rb | 3 ++- .../new_section_dialog_component.html.erb | 3 ++- .../project_custom_field_sections/show_component.html.erb | 3 ++- .../admin/settings/project_custom_field_sections_controller.rb | 2 ++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb b/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb index 4b3e3f0e6849..ef3008a6a780 100644 --- a/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb +++ b/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb @@ -51,7 +51,8 @@ def form_config { model: @project_custom_field_section, method: @project_custom_field_section.persisted? ? :put : :post, - url: @project_custom_field_section.persisted? ? admin_settings_project_custom_field_section_path(@project_custom_field_section) : admin_settings_project_custom_field_sections_path + url: @project_custom_field_section.persisted? ? admin_settings_project_custom_field_section_path(@project_custom_field_section) : admin_settings_project_custom_field_sections_path, + data: { turbo_stream: true } } end end diff --git a/app/components/settings/project_custom_field_sections/new_section_dialog_component.html.erb b/app/components/settings/project_custom_field_sections/new_section_dialog_component.html.erb index 211c3cdf346a..6a81fb54d622 100644 --- a/app/components/settings/project_custom_field_sections/new_section_dialog_component.html.erb +++ b/app/components/settings/project_custom_field_sections/new_section_dialog_component.html.erb @@ -2,7 +2,8 @@ Primer::Alpha::Dialog.new( title: t("settings.project_attributes.label_new_section"), size: :medium_portrait, - id: MODAL_ID + id: MODAL_ID, + data: { "keep-open-on-submit": true } ) ) do %> <%= render(Settings::ProjectCustomFieldSections::DialogBodyFormComponent.new) %> diff --git a/app/components/settings/project_custom_field_sections/show_component.html.erb b/app/components/settings/project_custom_field_sections/show_component.html.erb index 6ce3bf4f9699..f080aa2080ce 100644 --- a/app/components/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/settings/project_custom_field_sections/show_component.html.erb @@ -64,7 +64,8 @@ render( Primer::Alpha::Dialog.new( id: "project-custom-field-section-dialog#{@project_custom_field_section.id}", title: t("settings.project_attributes.label_new_section"), - size: :medium_portrait + size: :medium_portrait, + data: { "keep-open-on-submit": true } ) ) do |_dialog| render(Settings::ProjectCustomFieldSections::DialogBodyFormComponent.new(project_custom_field_section: @project_custom_field_section)) diff --git a/app/controllers/admin/settings/project_custom_field_sections_controller.rb b/app/controllers/admin/settings/project_custom_field_sections_controller.rb index 3fb520adfa63..54c332141924 100644 --- a/app/controllers/admin/settings/project_custom_field_sections_controller.rb +++ b/app/controllers/admin/settings/project_custom_field_sections_controller.rb @@ -42,6 +42,7 @@ def create ) if call.success? + close_dialog_via_turbo_stream("##{Settings::ProjectCustomFieldSections::NewSectionDialogComponent::MODAL_ID}") update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) else @@ -57,6 +58,7 @@ def update ) if call.success? + close_dialog_via_turbo_stream("#project-custom-field-section-dialog#{@project_custom_field_section.id}") update_section_via_turbo_stream(project_custom_field_section: call.result) else update_section_dialog_body_form_via_turbo_stream(project_custom_field_section: call.result) From e9376caad278eaa4fe41f27bd02721e7b6583770 Mon Sep 17 00:00:00 2001 From: David F Date: Fri, 29 May 2026 17:30:04 +0200 Subject: [PATCH 03/20] Display user custom fields grouped by section on profile and edit form. wp/72005 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../custom_field_field_component.html.erb | 5 + .../form/custom_field_field_component.rb | 68 +++++++++++ .../custom_field_section_component.html.erb | 7 ++ .../form/custom_field_section_component.rb | 46 ++++++++ .../custom_field_sections_component.html.erb | 3 + .../form/custom_field_sections_component.rb | 52 ++++++++ .../profile/attributes_component.html.erb | 24 ++-- .../users/profile/attributes_component.rb | 20 ++-- app/models/user_custom_field_section.rb | 7 ++ app/views/users/_form.html.erb | 2 +- app/views/users/_simple_form.html.erb | 2 +- app/views/users/form/_custom_fields.html.erb | 2 - .../custom_field_sections_component_spec.rb | 76 ++++++++++++ .../profile/attributes_component_spec.rb | 111 +++++++++++++----- spec/models/user_custom_field_section_spec.rb | 72 ++++++++++++ 15 files changed, 446 insertions(+), 51 deletions(-) create mode 100644 app/components/users/form/custom_field_field_component.html.erb create mode 100644 app/components/users/form/custom_field_field_component.rb create mode 100644 app/components/users/form/custom_field_section_component.html.erb create mode 100644 app/components/users/form/custom_field_section_component.rb create mode 100644 app/components/users/form/custom_field_sections_component.html.erb create mode 100644 app/components/users/form/custom_field_sections_component.rb delete mode 100644 app/views/users/form/_custom_fields.html.erb create mode 100644 spec/components/users/form/custom_field_sections_component_spec.rb create mode 100644 spec/models/user_custom_field_section_spec.rb diff --git a/app/components/users/form/custom_field_field_component.html.erb b/app/components/users/form/custom_field_field_component.html.erb new file mode 100644 index 000000000000..b23e7671104b --- /dev/null +++ b/app/components/users/form/custom_field_field_component.html.erb @@ -0,0 +1,5 @@ +<%= @form.fields_for_custom_fields :custom_field_values, @form.object, field_options do |cf_form| %> + <%= content_tag :div, class: css_classes do + cf_form.cf_form_field(container_class:) + end %> +<% end %> diff --git a/app/components/users/form/custom_field_field_component.rb b/app/components/users/form/custom_field_field_component.rb new file mode 100644 index 000000000000..2279a5904e4f --- /dev/null +++ b/app/components/users/form/custom_field_field_component.rb @@ -0,0 +1,68 @@ +# 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 Users + module Form + class CustomFieldFieldComponent < ApplicationComponent + include ApplicationHelper + + def initialize(custom_field:, form:) + super() + @custom_field = custom_field + @form = form + end + + def field_options + { + custom_value: @form.object.custom_value_for(@custom_field), + custom_field: @custom_field + } + end + + def css_classes + ["form--field", @custom_field.attribute_name, required_class].compact + end + + def container_class + case @custom_field.field_format + when "text" then "-xxwide" + when "date" then "-xslim" + else "-middle" + end + end + + private + + def required_class + "-required" if @custom_field.is_required? && !@custom_field.boolean? + end + end + end +end diff --git a/app/components/users/form/custom_field_section_component.html.erb b/app/components/users/form/custom_field_section_component.html.erb new file mode 100644 index 000000000000..b0e252a59006 --- /dev/null +++ b/app/components/users/form/custom_field_section_component.html.erb @@ -0,0 +1,7 @@ +
+ <%= title %> + + <% @fields.each do |custom_field| %> + <%= render Users::Form::CustomFieldFieldComponent.new(custom_field:, form: @form) %> + <% end %> +
diff --git a/app/components/users/form/custom_field_section_component.rb b/app/components/users/form/custom_field_section_component.rb new file mode 100644 index 000000000000..d809e21bb326 --- /dev/null +++ b/app/components/users/form/custom_field_section_component.rb @@ -0,0 +1,46 @@ +# 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 Users + module Form + class CustomFieldSectionComponent < ApplicationComponent + def initialize(section:, fields:, form:) + super() + @section = section + @fields = fields + @form = form + end + + def title + @section.name.presence || I18n.t("settings.user_attributes.label_untitled_section") + end + end + end +end diff --git a/app/components/users/form/custom_field_sections_component.html.erb b/app/components/users/form/custom_field_sections_component.html.erb new file mode 100644 index 000000000000..924f490130f5 --- /dev/null +++ b/app/components/users/form/custom_field_sections_component.html.erb @@ -0,0 +1,3 @@ +<% sections.each do |section| %> + <%= render section %> +<% end %> diff --git a/app/components/users/form/custom_field_sections_component.rb b/app/components/users/form/custom_field_sections_component.rb new file mode 100644 index 000000000000..229100b7ae46 --- /dev/null +++ b/app/components/users/form/custom_field_sections_component.rb @@ -0,0 +1,52 @@ +# 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 Users + module Form + class CustomFieldSectionsComponent < ApplicationComponent + def initialize(form:) + super() + @form = form + end + + def sections + @sections ||= begin + visible_cf_ids = @form.object.visible_custom_field_values.map(&:custom_field_id) + + UserCustomFieldSection + .with_custom_fields(visible_cf_ids) + .map do |section| + Users::Form::CustomFieldSectionComponent.new(section:, fields: section.custom_fields, form: @form) + end + end + end + end + end +end diff --git a/app/components/users/profile/attributes_component.html.erb b/app/components/users/profile/attributes_component.html.erb index 0b0c51447258..2aca2d14ee62 100644 --- a/app/components/users/profile/attributes_component.html.erb +++ b/app/components/users/profile/attributes_component.html.erb @@ -14,17 +14,23 @@ end end - visible_custom_fields.each do |custom_field| - details_container.with_row(mt: 2, font_weight: :bold, test_selector: "user-custom-field") do - custom_field.name + sections_with_fields.each do |custom_field_section, fields| + details_container.with_row(mt: 3, font_weight: :bold, color: :subtle) do + custom_field_section.name.presence || t("settings.user_attributes.label_untitled_section") end - details_container.with_row do - value = @user.formatted_custom_value_for(custom_field) - if value.is_a?(Array) - safe_join(value, ", ") - else - value + fields.each do |custom_field| + details_container.with_row(mt: 2, font_weight: :bold, test_selector: "user-custom-field") do + custom_field.name + end + + details_container.with_row do + value = @user.formatted_custom_value_for(custom_field) + if value.is_a?(Array) + safe_join(value, ", ") + else + value + end end end end diff --git a/app/components/users/profile/attributes_component.rb b/app/components/users/profile/attributes_component.rb index 50f7b10ed196..99352000a603 100644 --- a/app/components/users/profile/attributes_component.rb +++ b/app/components/users/profile/attributes_component.rb @@ -42,16 +42,20 @@ def initialize(user:) end def render? - user_is_allowed_to_see_email || @user.visible_custom_field_values.any? { it.value.present? } + user_is_allowed_to_see_email || sections_with_fields.any? end - def visible_custom_fields - @user - .visible_custom_field_values - .select { |cv| cv.value.present? } - .group_by(&:custom_field) - .keys - .sort_by(&:name) + def sections_with_fields + @sections_with_fields ||= begin + filled_cf_ids = @user + .visible_custom_field_values + .select { |cv| cv.value.present? } + .map(&:custom_field_id) + + UserCustomFieldSection + .with_custom_fields(filled_cf_ids) + .map { |section| [section, section.custom_fields] } + end end def user_is_allowed_to_see_email diff --git a/app/models/user_custom_field_section.rb b/app/models/user_custom_field_section.rb index bc482b423683..afb383a6f119 100644 --- a/app/models/user_custom_field_section.rb +++ b/app/models/user_custom_field_section.rb @@ -29,6 +29,13 @@ #++ class UserCustomFieldSection < CustomFieldSection + scope :with_custom_fields, ->(ids) { + joins(:custom_fields) + .where(custom_fields: { id: ids }) + .includes(:custom_fields) + .order("custom_field_sections.position", "custom_fields.position_in_custom_field_section") + } + def untitled? name.blank? end diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb index f7618bd0c9d9..0180cd39d6e2 100644 --- a/app/views/users/_form.html.erb +++ b/app/views/users/_form.html.erb @@ -36,7 +36,7 @@ See COPYRIGHT and LICENSE files for more details. <%= render partial: "users/form/admin_flag", locals: { f: f } %> <% end %> - <%= render partial: "users/form/custom_fields", locals: { f: f } %> + <%= render Users::Form::CustomFieldSectionsComponent.new(form: f) %> <%= call_hook(:view_users_form, user: @user, form: f) %> diff --git a/app/views/users/_simple_form.html.erb b/app/views/users/_simple_form.html.erb index 19e9290b0c8d..f20cfe520630 100644 --- a/app/views/users/_simple_form.html.erb +++ b/app/views/users/_simple_form.html.erb @@ -35,6 +35,6 @@ See COPYRIGHT and LICENSE files for more details. <%= render partial: "users/form/admin_flag", locals: { f: f } %> <% end %> - <%= render partial: "users/form/custom_fields", locals: { f: f } %> + <%= render Users::Form::CustomFieldSectionsComponent.new(form: f) %> <%= call_hook(:view_users_form, user: @user, form: f) %> diff --git a/app/views/users/form/_custom_fields.html.erb b/app/views/users/form/_custom_fields.html.erb deleted file mode 100644 index e829c3b6ad99..000000000000 --- a/app/views/users/form/_custom_fields.html.erb +++ /dev/null @@ -1,2 +0,0 @@ -<%= render partial: "customizable/form", - locals: { form: f, all_fields: true, only_required: false, input_size: "-middle" } %> diff --git a/spec/components/users/form/custom_field_sections_component_spec.rb b/spec/components/users/form/custom_field_sections_component_spec.rb new file mode 100644 index 000000000000..0a400d7cb396 --- /dev/null +++ b/spec/components/users/form/custom_field_sections_component_spec.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. +#++ + +require "rails_helper" + +RSpec.describe Users::Form::CustomFieldSectionsComponent, type: :component do + let(:user) { create(:user) } + let(:form) { instance_double(ActionView::Helpers::FormBuilder, object: user) } + let(:component) { described_class.new(form:) } + + describe "#sections" do + subject { component.sections } + + context "with no sections" do + it { is_expected.to be_empty } + end + + context "with a section containing visible fields" do + let!(:section) { create(:user_custom_field_section) } + let!(:field) { create(:user_custom_field, :string, user_custom_field_section: section) } + + it "returns one section component" do + expect(subject.size).to eq(1) + expect(subject.first).to be_a(Users::Form::CustomFieldSectionComponent) + end + end + + context "with a section containing only admin_only fields" do + let!(:section) { create(:user_custom_field_section) } + let!(:field) { create(:user_custom_field, :string, user_custom_field_section: section, admin_only: true) } + + it "excludes the section for a non-admin user" do + expect(subject).to be_empty + end + end + + context "with multiple sections" do + let!(:section_a) { create(:user_custom_field_section, position: 1) } + let!(:section_b) { create(:user_custom_field_section, position: 2) } + let!(:field_a) { create(:user_custom_field, :string, user_custom_field_section: section_a) } + let!(:field_b) { create(:user_custom_field, :string, user_custom_field_section: section_b) } + + it "returns sections in position order" do + names = subject.map { |s| s.instance_variable_get(:@section) } + expect(names).to eq([section_a, section_b]) + end + end + end +end diff --git a/spec/components/users/profile/attributes_component_spec.rb b/spec/components/users/profile/attributes_component_spec.rb index 9c8d48f00691..9dfaec3b0da6 100644 --- a/spec/components/users/profile/attributes_component_spec.rb +++ b/spec/components/users/profile/attributes_component_spec.rb @@ -41,9 +41,7 @@ subject { component.render? } context "when user has view_user_email permission" do - before do - create(:standard_global_role) - end + before { create(:standard_global_role) } it { is_expected.to be(true) } end @@ -80,50 +78,103 @@ end end - describe "Custom field" do - let(:custom_field) { create(:user_custom_field, :string, admin_only:) } - let(:custom_values) do - [build(:custom_value, custom_field:, value: "Hello custom field")] - end - let(:admin_only) { false } - let(:user) { build_stubbed(:user, custom_values:) } + describe "rendering custom fields" do + let(:section) { create(:user_custom_field_section, name: "Profile info") } + let(:custom_field) { create(:user_custom_field, :string, admin_only:, user_custom_field_section: section) } + let(:admin_only) { false } + let(:user) { build_stubbed(:user, custom_values: [build(:custom_value, custom_field:, value: "Hello custom field")]) } current_user { build(:admin) } - before do - render_inline(component) - end + before { render_inline(component) } - it "renders the field" do + it "renders the field value" do expect(page).to have_text("Hello custom field") end - context "when not visible" do + it "renders the section name as a heading" do + expect(page).to have_text("Profile info") + end + + context "when admin_only and current user is not admin" do let(:admin_only) { true } + current_user { logged_in_user } + it "does not render the field" do expect(page).to have_no_text("Hello custom field") end end - context "with multiple custom fields" do - let(:list_custom_field) { create(:user_custom_field, :multi_list, admin_only:, name: "Ze list") } - let(:text_custom_field) { create(:user_custom_field, :text, admin_only:, name: "A portrait") } - let(:custom_values) do - [ - build(:custom_value, custom_field: list_custom_field, value: list_custom_field.possible_values[0]), - build(:custom_value, custom_field: list_custom_field, value: list_custom_field.possible_values[1]), - build(:custom_value, custom_field: list_custom_field, value: list_custom_field.possible_values[2]), - build(:custom_value, custom_field: text_custom_field, value: "This is **formatted** text.") - ] + context "with an untitled section" do + let(:section) do + create(:user_custom_field_section).tap { |s| s.update_column(:name, nil) } end - it "renders the fields correctly and sorted" do - # correct render of formattable - expect(page).to have_css("strong", text: "formatted") - # correct render of multi select + it "renders the I18n fallback label" do + expect(page).to have_text(I18n.t("settings.user_attributes.label_untitled_section")) + end + end + + context "with fields in two sections" do + let(:section_first) { create(:user_custom_field_section, name: "First", position: 1) } + let(:section_second) { create(:user_custom_field_section, name: "Second", position: 2) } + let(:field_in_first) { create(:user_custom_field, :string, name: "In first", user_custom_field_section: section_first) } + let(:field_in_second) { create(:user_custom_field, :string, name: "In second", user_custom_field_section: section_second) } + let(:user) do + build_stubbed(:user, custom_values: [ + build(:custom_value, custom_field: field_in_first, value: "Value A"), + build(:custom_value, custom_field: field_in_second, value: "Value B") + ]) + end + + it "renders section headings in position order" do + expect(page.text).to match(/First.*Second/m) + end + + it "renders each field under its own section heading" do + expect(page.text).to match(/First.*In first.*Second.*In second/m) + end + end + + context "with multiple fields in one section" do + let(:field_1) { create(:user_custom_field, :string, name: "Field 1", user_custom_field_section: section, position_in_custom_field_section: 1) } + let(:field_2) { create(:user_custom_field, :string, name: "Field 2", user_custom_field_section: section, position_in_custom_field_section: 2) } + let(:user) do + build_stubbed(:user, custom_values: [ + build(:custom_value, custom_field: field_1, value: "First value"), + build(:custom_value, custom_field: field_2, value: "Second value") + ]) + end + + it "renders fields in position_in_custom_field_section order" do + items = page.all(:test_id, "user-custom-field") + expect(items[0]).to have_text("Field 1") + expect(items[1]).to have_text("Field 2") + end + end + + context "with multi-select and formattable fields" do + let(:list_field) { create(:user_custom_field, :multi_list, name: "Ze list", user_custom_field_section: section, position_in_custom_field_section: 2) } + let(:text_field) { create(:user_custom_field, :text, name: "A portrait", user_custom_field_section: section, position_in_custom_field_section: 1) } + let(:user) do + build_stubbed(:user, custom_values: [ + build(:custom_value, custom_field: list_field, value: list_field.possible_values[0]), + build(:custom_value, custom_field: list_field, value: list_field.possible_values[1]), + build(:custom_value, custom_field: list_field, value: list_field.possible_values[2]), + build(:custom_value, custom_field: text_field, value: "This is **formatted** text.") + ]) + end + + it "renders multi-select values as a comma-separated list" do expect(page).to have_text("A, B, C") - # alphabetical order + end + + it "renders formattable fields as HTML" do + expect(page).to have_css("strong", text: "formatted") + end + + it "orders fields by position_in_custom_field_section, not alphabetically" do items = page.all(:test_id, "user-custom-field") expect(items[0]).to have_text("A portrait") expect(items[1]).to have_text("Ze list") diff --git a/spec/models/user_custom_field_section_spec.rb b/spec/models/user_custom_field_section_spec.rb new file mode 100644 index 000000000000..6b187a9114a1 --- /dev/null +++ b/spec/models/user_custom_field_section_spec.rb @@ -0,0 +1,72 @@ +# 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 UserCustomFieldSection do + describe ".with_custom_fields" do + let!(:section_a) { create(:user_custom_field_section, name: "Section A", position: 1) } + let!(:section_b) { create(:user_custom_field_section, name: "Section B", position: 2) } + let!(:field_a1) { create(:user_custom_field, name: "A1", user_custom_field_section: section_a, position_in_custom_field_section: 1) } + let!(:field_a2) { create(:user_custom_field, name: "A2", user_custom_field_section: section_a, position_in_custom_field_section: 2) } + let!(:field_b1) { create(:user_custom_field, name: "B1", user_custom_field_section: section_b, position_in_custom_field_section: 1) } + + it "returns only sections that have at least one matching field" do + result = described_class.with_custom_fields([field_a1.id]) + expect(result).to contain_exactly(section_a) + end + + it "excludes sections with no matching fields" do + result = described_class.with_custom_fields([field_a1.id]) + expect(result).not_to include(section_b) + end + + it "orders sections by position" do + result = described_class.with_custom_fields([field_a1.id, field_b1.id]) + expect(result).to eq([section_a, section_b]) + end + + it "loads the matching custom fields on each section" do + result = described_class.with_custom_fields([field_a1.id, field_a2.id]) + expect(result.first.custom_fields).to contain_exactly(field_a1, field_a2) + end + + it "orders fields within a section by position_in_custom_field_section" do + result = described_class.with_custom_fields([field_a1.id, field_a2.id]) + expect(result.first.custom_fields).to eq([field_a1, field_a2]) + end + end + + describe "#untitled?" do + it { expect(described_class.new(name: nil)).to be_untitled } + it { expect(described_class.new(name: "")).to be_untitled } + it { expect(described_class.new(name: "My section")).not_to be_untitled } + end +end From 60032f0d6bd303cbdcde798c3d027fcff4896731 Mon Sep 17 00:00:00 2001 From: David F Date: Mon, 1 Jun 2026 13:17:29 +0200 Subject: [PATCH 04/20] Show user custom fields on the hover card when flagged. wp/72005 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../users/hover_card_component.html.erb | 11 +++ app/components/users/hover_card_component.rb | 22 +++++ app/contracts/custom_fields/base_contract.rb | 1 + app/forms/custom_fields/details_form.rb | 12 +++ app/models/permitted_params.rb | 1 + config/locales/en.yml | 6 ++ ...d_visible_on_user_card_to_custom_fields.rb | 7 ++ .../users/hover_card_component_spec.rb | 80 +++++++++++++++++++ 8 files changed, 140 insertions(+) create mode 100644 db/migrate/20260601081943_add_visible_on_user_card_to_custom_fields.rb diff --git a/app/components/users/hover_card_component.html.erb b/app/components/users/hover_card_component.html.erb index 670f889f8d48..80fac9aba006 100644 --- a/app/components/users/hover_card_component.html.erb +++ b/app/components/users/hover_card_component.html.erb @@ -57,6 +57,17 @@ See COPYRIGHT and LICENSE files for more details. end end + card_custom_field_values.group_by(&:custom_field).each do |custom_field, values| + formatted = format_multi_values(values) + + flex.with_row(data: { test_selector: "user-hover-card-custom-field" }) do + flex_layout(classes: "op-user-hover-card--custom-field") do |cf| + cf.with_column(font_weight: :bold, mr: 2) { custom_field.name } + cf.with_column { formatted } + end + end + end + flex.with_row do flex_layout(classes: "op-user-hover-card--group-list") do |f| f.with_column do diff --git a/app/components/users/hover_card_component.rb b/app/components/users/hover_card_component.rb index 014639f18510..132700eccc89 100644 --- a/app/components/users/hover_card_component.rb +++ b/app/components/users/hover_card_component.rb @@ -31,6 +31,8 @@ class Users::HoverCardComponent < ApplicationComponent include OpPrimer::ComponentHelpers + MULTI_VALUE_DISPLAY_LIMIT = 3 + def initialize(id:) super @@ -60,8 +62,28 @@ def group_membership_summary(max_length = 40) build_summary(group_links, cutoff_index) end + def card_custom_field_values + @user.custom_values + .eager_load(:custom_field) + .joins("INNER JOIN custom_field_sections ON custom_field_sections.id = custom_fields.custom_field_section_id") + .where(custom_fields: { visible_on_user_card: true }) + .where.not(value: [nil, ""]) + .then { |scope| User.current.active_admin? ? scope : scope.where(custom_fields: { admin_only: false }) } + .order("custom_field_sections.position", "custom_fields.position_in_custom_field_section") + end + private + def format_multi_values(values) + formatted = values.map(&:formatted_value) + visible = safe_join(formatted.first(MULTI_VALUE_DISPLAY_LIMIT), ", ") + remaining = formatted.size - MULTI_VALUE_DISPLAY_LIMIT + + return visible if remaining <= 0 + + safe_join([visible, t("custom_fields.multi_value_more", count: remaining)], " ") + end + def linked_group_names(groups) groups.map { |group| link_to(h(group.name), show_group_path(group)) } end diff --git a/app/contracts/custom_fields/base_contract.rb b/app/contracts/custom_fields/base_contract.rb index e8d11e9d9ed5..4e11bb2c8a5a 100644 --- a/app/contracts/custom_fields/base_contract.rb +++ b/app/contracts/custom_fields/base_contract.rb @@ -53,6 +53,7 @@ class BaseContract < ::ModelContract attribute :possible_values attribute :regexp attribute :searchable + attribute :visible_on_user_card attribute :type def validate_non_true_for_some_formats diff --git a/app/forms/custom_fields/details_form.rb b/app/forms/custom_fields/details_form.rb index 68044dba950a..fb28315957a1 100644 --- a/app/forms/custom_fields/details_form.rb +++ b/app/forms/custom_fields/details_form.rb @@ -199,6 +199,14 @@ class DetailsForm < ApplicationForm ) end + if show_on_user_card_field? + details_form.check_box( + name: :visible_on_user_card, + label: label(:visible_on_user_card), + caption: instructions(:visible_on_user_card) + ) + end + details_form.submit(name: :submit, label: I18n.t(:button_save), scheme: :default) end @@ -289,6 +297,10 @@ def show_editable_field? model.is_a?(UserCustomField) end + def show_on_user_card_field? + model.is_a?(UserCustomField) + end + def formula_suggestions operators = CustomField::CalculatedValue::MATH_OPERATORS_FOR_FORMULA # Hide % from the suggestions as it can be used as either modulo or percentage. diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb index 6934800f5192..2ea259685fb1 100644 --- a/app/models/permitted_params.rb +++ b/app/models/permitted_params.rb @@ -532,6 +532,7 @@ def self.permitted_attributes :custom_field_section_id, :allow_non_open_versions, :has_comment, + :visible_on_user_card, { custom_options_attributes: %i(id value default_value position) }, { type_ids: [] } ], diff --git a/config/locales/en.yml b/config/locales/en.yml index ee30c75d4474..1b625615d494 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -752,6 +752,11 @@ en: project: "0 means no restriction" has_comment: project: "Allows the user to add a comment related to the project attribute when selecting the value in the project overview." + visible_on_user_card: + all: "When checked, this field's value is shown in the user information card when hovering over a user across the application." + + multi_value_more: + other: "and %{count} more" tab: no_results_title_text: There are currently no custom fields. @@ -1850,6 +1855,7 @@ en: possible_values: "Possible values" regexp: "Regular expression" searchable: "Searchable" + visible_on_user_card: "Show on user card" admin_only: "Admin-only" has_comment: "Add a comment text field" custom_value: diff --git a/db/migrate/20260601081943_add_visible_on_user_card_to_custom_fields.rb b/db/migrate/20260601081943_add_visible_on_user_card_to_custom_fields.rb new file mode 100644 index 000000000000..ee5def0536cd --- /dev/null +++ b/db/migrate/20260601081943_add_visible_on_user_card_to_custom_fields.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddVisibleOnUserCardToCustomFields < ActiveRecord::Migration[8.1] + def change + add_column :custom_fields, :visible_on_user_card, :boolean, null: false, default: false + end +end diff --git a/spec/components/users/hover_card_component_spec.rb b/spec/components/users/hover_card_component_spec.rb index 3969d10c09f1..1d74be8f294b 100644 --- a/spec/components/users/hover_card_component_spec.rb +++ b/spec/components/users/hover_card_component_spec.rb @@ -164,4 +164,84 @@ end end end + + context "when displaying custom fields" do + let(:section) { create(:user_custom_field_section) } + let!(:visible_cf) do + create(:user_custom_field, :string, name: "Job title", + user_custom_field_section: section, visible_on_user_card: true) + end + let!(:hidden_cf) do + create(:user_custom_field, :string, name: "Hidden field", + user_custom_field_section: section, visible_on_user_card: false) + end + let!(:admin_only_cf) do + create(:user_custom_field, :string, name: "Secret field", + user_custom_field_section: section, visible_on_user_card: true, admin_only: true) + end + let(:user) do + create(:user, + member_with_permissions: { project => [:view_project] }, + custom_values: [ + build(:custom_value, custom_field: visible_cf, value: "Developer"), + build(:custom_value, custom_field: hidden_cf, value: "Should not appear"), + build(:custom_value, custom_field: admin_only_cf, value: "Top secret") + ]) + end + + it "shows fields flagged as visible_on_user_card" do + expect(page).to have_test_selector("user-hover-card-custom-field", text: "Developer") + end + + it "does not show fields with visible_on_user_card: false" do + expect(page).to have_no_text("Should not appear") + end + + it "does not show admin_only fields to non-admins" do + expect(page).to have_no_text("Top secret") + end + + context "when current user is admin" do + let(:current_user) { build(:admin) } + + it "shows admin_only fields flagged as visible_on_user_card" do + expect(page).to have_test_selector("user-hover-card-custom-field", text: "Top secret") + end + end + + context "with a multi-value field exceeding the display limit" do + let!(:multi_cf) do + create(:user_custom_field, :multi_list, + name: "Skills", + user_custom_field_section: section, + visible_on_user_card: true, + possible_values: %w[Ruby Rails React Vue Angular]) + end + let(:user) do + create(:user, + member_with_permissions: { project => [:view_project] }, + custom_values: multi_cf.possible_values.map { |opt| build(:custom_value, custom_field: multi_cf, value: opt) }) + end + + it "shows the first 3 values" do + expect(page).to have_test_selector("user-hover-card-custom-field", text: "Ruby, Rails, React") + end + + it "appends the ellipsis wording for the remaining values" do + expect(page).to have_test_selector("user-hover-card-custom-field", text: "and 2 more") + end + end + + context "when the value is blank" do + let(:user) do + create(:user, + member_with_permissions: { project => [:view_project] }, + custom_values: [build(:custom_value, custom_field: visible_cf, value: "")]) + end + + it "does not render the field" do + expect(page).to have_no_test_selector("user-hover-card-custom-field") + end + end + end end From e55fa08343619c953463e05064b2f66257ff8022 Mon Sep 17 00:00:00 2001 From: David F Date: Mon, 1 Jun 2026 15:40:28 +0200 Subject: [PATCH 05/20] Fix linting errors. wp/72005 --- .../dialog_body_form_component.rb | 6 ++++- .../dialog_body_form_component.rb | 6 ++++- .../user_custom_field_sections_controller.rb | 13 ++++------ .../settings/user_custom_fields_controller.rb | 4 ++-- app/forms/custom_fields/details_form.rb | 2 +- .../user_custom_fields/drop_service.rb | 2 +- ...reate_default_user_custom_field_section.rb | 8 +++---- .../users/hover_card_component_spec.rb | 6 ++--- .../profile/attributes_component_spec.rb | 24 ++++++++++++++----- spec/models/user_custom_field_section_spec.rb | 12 +++++++--- 10 files changed, 53 insertions(+), 30 deletions(-) diff --git a/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb b/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb index ef3008a6a780..f57827bca4e7 100644 --- a/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb +++ b/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb @@ -51,7 +51,11 @@ def form_config { model: @project_custom_field_section, method: @project_custom_field_section.persisted? ? :put : :post, - url: @project_custom_field_section.persisted? ? admin_settings_project_custom_field_section_path(@project_custom_field_section) : admin_settings_project_custom_field_sections_path, + url: if @project_custom_field_section.persisted? + admin_settings_project_custom_field_section_path(@project_custom_field_section) + else + admin_settings_project_custom_field_sections_path + end, data: { turbo_stream: true } } end diff --git a/app/components/settings/user_custom_field_sections/dialog_body_form_component.rb b/app/components/settings/user_custom_field_sections/dialog_body_form_component.rb index 9a681d18a0c1..78e7f62792c0 100644 --- a/app/components/settings/user_custom_field_sections/dialog_body_form_component.rb +++ b/app/components/settings/user_custom_field_sections/dialog_body_form_component.rb @@ -51,7 +51,11 @@ def form_config { model: @user_custom_field_section, method: @user_custom_field_section.persisted? ? :put : :post, - url: @user_custom_field_section.persisted? ? admin_settings_user_custom_field_section_path(@user_custom_field_section) : admin_settings_user_custom_field_sections_path, + url: if @user_custom_field_section.persisted? + admin_settings_user_custom_field_section_path(@user_custom_field_section) + else + admin_settings_user_custom_field_sections_path + end, data: { turbo_stream: true } } end diff --git a/app/controllers/admin/settings/user_custom_field_sections_controller.rb b/app/controllers/admin/settings/user_custom_field_sections_controller.rb index 45ecfd50841a..bd3168362f75 100644 --- a/app/controllers/admin/settings/user_custom_field_sections_controller.rb +++ b/app/controllers/admin/settings/user_custom_field_sections_controller.rb @@ -72,8 +72,7 @@ def destroy if call.success? update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) update_sections_via_turbo_stream(user_custom_field_sections: UserCustomFieldSection.all) - else - # TODO: show error message + # TODO: show error message on failure end respond_with_turbo_streams @@ -81,13 +80,12 @@ def destroy def move call = ::UserCustomFieldSections::UpdateService.new(user: current_user, model: @user_custom_field_section).call( - move_to: params[:move_to]&.to_sym + move_to: params.expect(:move_to)&.to_sym ) if call.success? update_sections_via_turbo_stream(user_custom_field_sections: UserCustomFieldSection.all) - else - # TODO: show error message + # TODO: show error message on failure end respond_with_turbo_streams @@ -101,8 +99,7 @@ def drop if call.success? update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) update_sections_via_turbo_stream(user_custom_field_sections: UserCustomFieldSection.all) - else - # TODO: show error message + # TODO: show error message on failure end respond_with_turbo_streams @@ -115,7 +112,7 @@ def new_link private def set_user_custom_field_section - @user_custom_field_section = UserCustomFieldSection.find(params[:id]) + @user_custom_field_section = UserCustomFieldSection.find(params.expect(:id)) end def allow_custom_field_creation? diff --git a/app/controllers/admin/settings/user_custom_fields_controller.rb b/app/controllers/admin/settings/user_custom_fields_controller.rb index 3fcb8d8072b1..4c9eed3411cb 100644 --- a/app/controllers/admin/settings/user_custom_fields_controller.rb +++ b/app/controllers/admin/settings/user_custom_fields_controller.rb @@ -71,7 +71,7 @@ def list_items; end def move result = CustomFields::UpdateService.new(user: current_user, model: @custom_field).call( - move_to: params[:move_to]&.to_sym + move_to: params.expect(:move_to)&.to_sym ) if result.success? @@ -131,7 +131,7 @@ def set_sections end def find_custom_field - @custom_field = UserCustomField.find(params[:id]) + @custom_field = UserCustomField.find(params.expect(:id)) end def drop_success_streams(call) diff --git a/app/forms/custom_fields/details_form.rb b/app/forms/custom_fields/details_form.rb index fb28315957a1..59ec5efdf089 100644 --- a/app/forms/custom_fields/details_form.rb +++ b/app/forms/custom_fields/details_form.rb @@ -54,7 +54,7 @@ class DetailsForm < ApplicationForm label: I18n.t("activerecord.attributes.project_custom_field.custom_field_section"), required: true ) do |list| - section_class_for_model.all.each do |cs| + section_class_for_model.all.each do |cs| # rubocop:disable Rails/FindEach -- ordered by default_scope; find_each would override it list.option(value: cs.id, label: cs.name.presence || I18n.t("settings.user_attributes.label_untitled_section")) end end diff --git a/app/services/user_custom_fields/drop_service.rb b/app/services/user_custom_fields/drop_service.rb index bf20fc247b50..98967700e126 100644 --- a/app/services/user_custom_fields/drop_service.rb +++ b/app/services/user_custom_fields/drop_service.rb @@ -84,7 +84,7 @@ def check_and_update_section_if_changed(params) def update_section(new_section_id) current_section = UserCustomFieldSection.find(new_section_id) @user_custom_field.remove_from_list - @user_custom_field.update(user_custom_field_section: current_section) + @user_custom_field.update!(user_custom_field_section: current_section) current_section end diff --git a/db/migrate/20260528144922_create_default_user_custom_field_section.rb b/db/migrate/20260528144922_create_default_user_custom_field_section.rb index c38d4e86e1c9..89f0e9d65199 100644 --- a/db/migrate/20260528144922_create_default_user_custom_field_section.rb +++ b/db/migrate/20260528144922_create_default_user_custom_field_section.rb @@ -2,13 +2,13 @@ class CreateDefaultUserCustomFieldSection < ActiveRecord::Migration[8.1] def up - section_id = execute(<<~SQL).first["id"] + section_id = execute(<<~SQL.squish).first["id"] INSERT INTO custom_field_sections (type, name, position, created_at, updated_at) VALUES ('UserCustomFieldSection', NULL, 1, NOW(), NOW()) RETURNING id SQL - execute(<<~SQL) + execute(<<~SQL.squish) UPDATE custom_fields SET custom_field_section_id = #{section_id} WHERE type = 'UserCustomField' @@ -17,7 +17,7 @@ def up end def down - execute(<<~SQL) + execute(<<~SQL.squish) UPDATE custom_fields SET custom_field_section_id = NULL WHERE type = 'UserCustomField' @@ -27,7 +27,7 @@ def down ) SQL - execute(<<~SQL) + execute(<<~SQL.squish) DELETE FROM custom_field_sections WHERE type = 'UserCustomFieldSection' AND name IS NULL SQL diff --git a/spec/components/users/hover_card_component_spec.rb b/spec/components/users/hover_card_component_spec.rb index 1d74be8f294b..440e7efd625e 100644 --- a/spec/components/users/hover_card_component_spec.rb +++ b/spec/components/users/hover_card_component_spec.rb @@ -169,15 +169,15 @@ let(:section) { create(:user_custom_field_section) } let!(:visible_cf) do create(:user_custom_field, :string, name: "Job title", - user_custom_field_section: section, visible_on_user_card: true) + user_custom_field_section: section, visible_on_user_card: true) end let!(:hidden_cf) do create(:user_custom_field, :string, name: "Hidden field", - user_custom_field_section: section, visible_on_user_card: false) + user_custom_field_section: section, visible_on_user_card: false) end let!(:admin_only_cf) do create(:user_custom_field, :string, name: "Secret field", - user_custom_field_section: section, visible_on_user_card: true, admin_only: true) + user_custom_field_section: section, visible_on_user_card: true, admin_only: true) end let(:user) do create(:user, diff --git a/spec/components/users/profile/attributes_component_spec.rb b/spec/components/users/profile/attributes_component_spec.rb index 9dfaec3b0da6..9bf42a3e2c3d 100644 --- a/spec/components/users/profile/attributes_component_spec.rb +++ b/spec/components/users/profile/attributes_component_spec.rb @@ -138,12 +138,18 @@ end context "with multiple fields in one section" do - let(:field_1) { create(:user_custom_field, :string, name: "Field 1", user_custom_field_section: section, position_in_custom_field_section: 1) } - let(:field_2) { create(:user_custom_field, :string, name: "Field 2", user_custom_field_section: section, position_in_custom_field_section: 2) } + let(:first_field) do + create(:user_custom_field, :string, name: "Field 1", user_custom_field_section: section, + position_in_custom_field_section: 1) + end + let(:second_field) do + create(:user_custom_field, :string, name: "Field 2", user_custom_field_section: section, + position_in_custom_field_section: 2) + end let(:user) do build_stubbed(:user, custom_values: [ - build(:custom_value, custom_field: field_1, value: "First value"), - build(:custom_value, custom_field: field_2, value: "Second value") + build(:custom_value, custom_field: first_field, value: "First value"), + build(:custom_value, custom_field: second_field, value: "Second value") ]) end @@ -155,8 +161,14 @@ end context "with multi-select and formattable fields" do - let(:list_field) { create(:user_custom_field, :multi_list, name: "Ze list", user_custom_field_section: section, position_in_custom_field_section: 2) } - let(:text_field) { create(:user_custom_field, :text, name: "A portrait", user_custom_field_section: section, position_in_custom_field_section: 1) } + let(:list_field) do + create(:user_custom_field, :multi_list, name: "Ze list", user_custom_field_section: section, + position_in_custom_field_section: 2) + end + let(:text_field) do + create(:user_custom_field, :text, name: "A portrait", user_custom_field_section: section, + position_in_custom_field_section: 1) + end let(:user) do build_stubbed(:user, custom_values: [ build(:custom_value, custom_field: list_field, value: list_field.possible_values[0]), diff --git a/spec/models/user_custom_field_section_spec.rb b/spec/models/user_custom_field_section_spec.rb index 6b187a9114a1..2530dbec3632 100644 --- a/spec/models/user_custom_field_section_spec.rb +++ b/spec/models/user_custom_field_section_spec.rb @@ -34,9 +34,15 @@ describe ".with_custom_fields" do let!(:section_a) { create(:user_custom_field_section, name: "Section A", position: 1) } let!(:section_b) { create(:user_custom_field_section, name: "Section B", position: 2) } - let!(:field_a1) { create(:user_custom_field, name: "A1", user_custom_field_section: section_a, position_in_custom_field_section: 1) } - let!(:field_a2) { create(:user_custom_field, name: "A2", user_custom_field_section: section_a, position_in_custom_field_section: 2) } - let!(:field_b1) { create(:user_custom_field, name: "B1", user_custom_field_section: section_b, position_in_custom_field_section: 1) } + let!(:field_a1) do + create(:user_custom_field, name: "A1", user_custom_field_section: section_a, position_in_custom_field_section: 1) + end + let!(:field_a2) do + create(:user_custom_field, name: "A2", user_custom_field_section: section_a, position_in_custom_field_section: 2) + end + let!(:field_b1) do + create(:user_custom_field, name: "B1", user_custom_field_section: section_b, position_in_custom_field_section: 1) + end it "returns only sections that have at least one matching field" do result = described_class.with_custom_fields([field_a1.id]) From dcad63accfc8d6d0ee81edf90b4a21ae28f7911d Mon Sep 17 00:00:00 2001 From: David F Date: Mon, 1 Jun 2026 17:53:00 +0200 Subject: [PATCH 06/20] Allow hierarchy custom fields for User. wp/72005 --- .../edit_form_header_component.rb | 8 +++- .../header_component.html.erb | 6 ++- .../hierarchy/items_controller.rb | 47 +++++++++++++++++++ .../hierarchy/items/edit.html.erb | 34 ++++++++++++++ .../hierarchy/items/index.html.erb | 34 ++++++++++++++ .../hierarchy/items/new.html.erb | 34 ++++++++++++++ config/initializers/custom_field_format.rb | 2 +- config/routes.rb | 12 +++++ lib/custom_field_form_builder.rb | 37 +++++++++++++++ .../users/format_field_expectations.rb | 41 ++++++++++++++++ .../custom_fields/users/hierarchy_spec.rb | 38 +++++++++++++++ .../settings/user_custom_fields/index.rb | 6 +++ 12 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 app/controllers/admin/settings/user_custom_fields/hierarchy/items_controller.rb create mode 100644 app/views/admin/settings/user_custom_fields/hierarchy/items/edit.html.erb create mode 100644 app/views/admin/settings/user_custom_fields/hierarchy/items/index.html.erb create mode 100644 app/views/admin/settings/user_custom_fields/hierarchy/items/new.html.erb create mode 100644 spec/features/admin/custom_fields/users/format_field_expectations.rb create mode 100644 spec/features/admin/custom_fields/users/hierarchy_spec.rb diff --git a/app/components/settings/user_custom_fields/edit_form_header_component.rb b/app/components/settings/user_custom_fields/edit_form_header_component.rb index c8ca8885de6c..ef50115303c5 100644 --- a/app/components/settings/user_custom_fields/edit_form_header_component.rb +++ b/app/components/settings/user_custom_fields/edit_form_header_component.rb @@ -45,7 +45,13 @@ def tabs } ] - if @custom_field.list? + if @custom_field.hierarchical_list? + tabs << { + name: "items", + path: admin_settings_user_custom_field_items_path(@custom_field), + label: t(:label_item_plural) + } + elsif @custom_field.list? tabs << { name: "items", path: list_items_admin_settings_user_custom_field_path(@custom_field), diff --git a/app/components/settings/user_custom_fields/header_component.html.erb b/app/components/settings/user_custom_fields/header_component.html.erb index b5079dd3d06e..329e169e6bc2 100644 --- a/app/components/settings/user_custom_fields/header_component.html.erb +++ b/app/components/settings/user_custom_fields/header_component.html.erb @@ -42,7 +42,11 @@ href: new_admin_settings_user_custom_field_path(field_format: format.name), content_arguments: { data: { turbo: "false", test_selector: "new-user-custom-field-button" } } - ) + ) do |item| + unless format.enterprise_feature_allowed? + item.with_trailing_visual_icon(icon: :"op-enterprise-addons", classes: "upsell-colored") + end + end end end end diff --git a/app/controllers/admin/settings/user_custom_fields/hierarchy/items_controller.rb b/app/controllers/admin/settings/user_custom_fields/hierarchy/items_controller.rb new file mode 100644 index 000000000000..01ba71f2608e --- /dev/null +++ b/app/controllers/admin/settings/user_custom_fields/hierarchy/items_controller.rb @@ -0,0 +1,47 @@ +# 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 Settings + module UserCustomFields + module Hierarchy + class ItemsController < Admin::CustomFields::Hierarchy::ItemsBaseController + menu_item :user_custom_fields_settings + + private + + def find_custom_field + @custom_field = CustomField.hierarchy_root_and_children.find(params[:user_custom_field_id]) + end + end + end + end + end +end diff --git a/app/views/admin/settings/user_custom_fields/hierarchy/items/edit.html.erb b/app/views/admin/settings/user_custom_fields/hierarchy/items/edit.html.erb new file mode 100644 index 000000000000..1cf89c4ec92e --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/hierarchy/items/edit.html.erb @@ -0,0 +1,34 @@ +<%#-- 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. + +++#%> + +<% html_title t(:label_administration), t("settings.user_attributes.heading"), @custom_field.name, t(:label_item_plural) %> + +<%= render(Settings::UserCustomFields::EditFormHeaderComponent.new(custom_field: @custom_field, selected: :items)) %> + +<%= render(Admin::CustomFields::Hierarchy::ItemComponent.new(item: @active_item, custom_field: @custom_field, show_edit_form: true)) %> diff --git a/app/views/admin/settings/user_custom_fields/hierarchy/items/index.html.erb b/app/views/admin/settings/user_custom_fields/hierarchy/items/index.html.erb new file mode 100644 index 000000000000..6e51fd751655 --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/hierarchy/items/index.html.erb @@ -0,0 +1,34 @@ +<%#-- 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. + +++#%> + +<% html_title t(:label_administration), t("settings.user_attributes.heading"), @custom_field.name, t(:label_item_plural) %> + +<%= render(Settings::UserCustomFields::EditFormHeaderComponent.new(custom_field: @custom_field, selected: :items)) %> + +<%= render(Admin::CustomFields::Hierarchy::ItemsComponent.new(item: @active_item)) %> diff --git a/app/views/admin/settings/user_custom_fields/hierarchy/items/new.html.erb b/app/views/admin/settings/user_custom_fields/hierarchy/items/new.html.erb new file mode 100644 index 000000000000..faa3cccd0b4c --- /dev/null +++ b/app/views/admin/settings/user_custom_fields/hierarchy/items/new.html.erb @@ -0,0 +1,34 @@ +<%#-- 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. + +++#%> + +<% html_title t(:label_administration), t("settings.user_attributes.heading"), @custom_field.name, t(:label_item_plural) %> + +<%= render(Settings::UserCustomFields::EditFormHeaderComponent.new(custom_field: @custom_field, selected: :items)) %> + +<%= render(Admin::CustomFields::Hierarchy::ItemsComponent.new(item: @active_item, new_item: @new_item)) %> diff --git a/config/initializers/custom_field_format.rb b/config/initializers/custom_field_format.rb index 07a693407a25..c58deb5e80a3 100644 --- a/config/initializers/custom_field_format.rb +++ b/config/initializers/custom_field_format.rb @@ -85,7 +85,7 @@ formats.register("hierarchy", label: :label_hierarchy, - only: %w(Project WorkPackage), + only: %w(Project User WorkPackage), order: 12, multi_value_possible: true, enterprise_feature: :custom_field_hierarchies, diff --git a/config/routes.rb b/config/routes.rb index 4bca53525afb..0f2bc08c1a15 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -778,6 +778,18 @@ get :list_items end + + resources :items, controller: "/admin/settings/user_custom_fields/hierarchy/items" do + member do + get :change_parent, action: :change_parent_dialog + post :change_parent, action: :change_parent + get :delete, action: :deletion_dialog + get :item_actions + post :move + get :new_child, action: :new + post :new_child, action: :create + end + end end resources :user_custom_field_sections, controller: "/admin/settings/user_custom_field_sections", diff --git a/lib/custom_field_form_builder.rb b/lib/custom_field_form_builder.rb index 74b455567a15..f8abda9a9a12 100644 --- a/lib/custom_field_form_builder.rb +++ b/lib/custom_field_form_builder.rb @@ -78,11 +78,48 @@ def custom_field_input(options = {}) check_box(field, input_options.merge(checked: custom_value.strategy.checked?)) when "list" custom_field_input_list(field, input_options) + when "hierarchy" + custom_field_input_hierarchy(field, input_options) else text_field(field, input_options) end end + def custom_field_input_hierarchy(field, input_options) + root = custom_field.hierarchy_root + all_items = CustomFields::Hierarchy::HierarchicalItemService.new + .get_descendants(item: root, include_self: false) + .either(->(result) { result }, ->(_) { [] }) + + by_parent = all_items.group_by(&:parent_id) + top_level = by_parent[root&.id] || [] + + grouped = top_level.map do |top_item| + [hierarchy_item_label(top_item), hierarchy_collect_options(by_parent, top_item, depth: 0)] + end + + selected = Array(custom_value).map(&:value) + input_options[:multiple] = custom_field.multi_value? + + select(field, + template.grouped_options_for_select(grouped, selected), + custom_field_select_options_for_object, + input_options) + end + + def hierarchy_collect_options(by_parent, item, depth:) + indent = "Ā Ā " * depth + children = by_parent[item.id] || [] + + [[hierarchy_item_label(item, indent:), item.id.to_s]] + + children.flat_map { |child| hierarchy_collect_options(by_parent, child, depth: depth + 1) } + end + + def hierarchy_item_label(item, indent: "") + base = item.short.present? ? "#{item.label} (#{item.short})" : item.label + "#{indent}#{base}" + end + def custom_field_input_list(field, input_options) customized = Array(custom_value).first&.customized selectable_options = custom_field_input_list_options(customized, custom_value) diff --git a/spec/features/admin/custom_fields/users/format_field_expectations.rb b/spec/features/admin/custom_fields/users/format_field_expectations.rb new file mode 100644 index 000000000000..32b22409e16d --- /dev/null +++ b/spec/features/admin/custom_fields/users/format_field_expectations.rb @@ -0,0 +1,41 @@ +# 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_relative "../shared_custom_field_expectations" + +RSpec.shared_examples_for "expected fields for the User custom field's format" do |format| + before do + create(:user_custom_field_section) + end + + include_examples "expected fields for the custom field's format", "Users", format do + let(:cf_page) { Pages::Admin::Settings::UserCustomFields::Index.new } + end +end diff --git a/spec/features/admin/custom_fields/users/hierarchy_spec.rb b/spec/features/admin/custom_fields/users/hierarchy_spec.rb new file mode 100644 index 000000000000..cf09683d3ffe --- /dev/null +++ b/spec/features/admin/custom_fields/users/hierarchy_spec.rb @@ -0,0 +1,38 @@ +# 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" +require_relative "format_field_expectations" + +RSpec.describe "User hierarchy custom fields", :js do + context "with enterprise token", with_ee: [:custom_field_hierarchies] do + it_behaves_like "expected fields for the User custom field's format", "Hierarchy" + end +end diff --git a/spec/support/pages/admin/settings/user_custom_fields/index.rb b/spec/support/pages/admin/settings/user_custom_fields/index.rb index 987a7f1a946b..038cbc6f053c 100644 --- a/spec/support/pages/admin/settings/user_custom_fields/index.rb +++ b/spec/support/pages/admin/settings/user_custom_fields/index.rb @@ -37,6 +37,12 @@ def path "/admin/settings/user_custom_fields" end + def visit_page(customizable_name) + fail "Only User type is expected" unless customizable_name == "Users" + + visit! + end + def expect_add_user_attribute_submenu(close: true) within_add_menu(close:) do expect(page).to have_test_selector("add-user-custom-field-attribute") From 55d33765c78490bec4c4e17ca747984c404c9372 Mon Sep 17 00:00:00 2001 From: David F Date: Tue, 2 Jun 2026 19:20:13 +0200 Subject: [PATCH 07/20] Reduce DB round-trips. wp/72005 --- .../custom_field_sections_component.html.erb | 3 - .../users/profile/attributes_component.rb | 8 +- .../hierarchy/items_controller.rb | 2 +- app/views/users/_form.html.erb | 4 +- app/views/users/_simple_form.html.erb | 4 +- .../custom_field_sections_component_spec.rb | 76 ----------- .../profile/attributes_component_spec.rb | 52 +++++--- spec/factories/attribute_help_text_factory.rb | 7 + .../users/create_in_section_spec.rb | 115 ++++++++++++++++ .../attribute_help_text_spec.rb | 126 ++++++++++++++++++ spec/features/users/edit_users_spec.rb | 25 +++- .../open_project/custom_field_format_spec.rb | 2 +- spec/models/user_custom_field_spec.rb | 108 +++++++++++++++ .../create_service_spec.rb | 58 +++++--- .../delete_service_spec.rb | 71 ++++++++++ .../update_service_spec.rb | 74 ++++++++++ 16 files changed, 606 insertions(+), 129 deletions(-) delete mode 100644 app/components/users/form/custom_field_sections_component.html.erb delete mode 100644 spec/components/users/form/custom_field_sections_component_spec.rb create mode 100644 spec/features/admin/custom_fields/users/create_in_section_spec.rb create mode 100644 spec/features/admin/settings/user_custom_fields/attribute_help_text_spec.rb create mode 100644 spec/models/user_custom_field_spec.rb rename app/components/users/form/custom_field_sections_component.rb => spec/services/user_custom_field_sections/create_service_spec.rb (52%) create mode 100644 spec/services/user_custom_field_sections/delete_service_spec.rb create mode 100644 spec/services/user_custom_field_sections/update_service_spec.rb diff --git a/app/components/users/form/custom_field_sections_component.html.erb b/app/components/users/form/custom_field_sections_component.html.erb deleted file mode 100644 index 924f490130f5..000000000000 --- a/app/components/users/form/custom_field_sections_component.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<% sections.each do |section| %> - <%= render section %> -<% end %> diff --git a/app/components/users/profile/attributes_component.rb b/app/components/users/profile/attributes_component.rb index 99352000a603..b52ab29b04eb 100644 --- a/app/components/users/profile/attributes_component.rb +++ b/app/components/users/profile/attributes_component.rb @@ -47,10 +47,10 @@ def render? def sections_with_fields @sections_with_fields ||= begin - filled_cf_ids = @user - .visible_custom_field_values - .select { |cv| cv.value.present? } - .map(&:custom_field_id) + filled_cf_ids = @user.custom_values + .where(custom_field: UserCustomField.visible(User.current)) + .where.not(value: [nil, ""]) + .select(:custom_field_id) UserCustomFieldSection .with_custom_fields(filled_cf_ids) diff --git a/app/controllers/admin/settings/user_custom_fields/hierarchy/items_controller.rb b/app/controllers/admin/settings/user_custom_fields/hierarchy/items_controller.rb index 01ba71f2608e..f02af815110c 100644 --- a/app/controllers/admin/settings/user_custom_fields/hierarchy/items_controller.rb +++ b/app/controllers/admin/settings/user_custom_fields/hierarchy/items_controller.rb @@ -38,7 +38,7 @@ class ItemsController < Admin::CustomFields::Hierarchy::ItemsBaseController private def find_custom_field - @custom_field = CustomField.hierarchy_root_and_children.find(params[:user_custom_field_id]) + @custom_field = CustomField.hierarchy_root_and_children.find(params.expect(:user_custom_field_id)) end end end diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb index 0180cd39d6e2..c1864a3804f6 100644 --- a/app/views/users/_form.html.erb +++ b/app/views/users/_form.html.erb @@ -36,7 +36,9 @@ See COPYRIGHT and LICENSE files for more details. <%= render partial: "users/form/admin_flag", locals: { f: f } %> <% end %> - <%= render Users::Form::CustomFieldSectionsComponent.new(form: f) %> + <% UserCustomFieldSection.with_custom_fields(UserCustomField.visible(User.current)).each do |section| %> + <%= render Users::Form::CustomFieldSectionComponent.new(section:, fields: section.custom_fields, form: f) %> + <% end %> <%= call_hook(:view_users_form, user: @user, form: f) %> diff --git a/app/views/users/_simple_form.html.erb b/app/views/users/_simple_form.html.erb index f20cfe520630..26a69d1dbd24 100644 --- a/app/views/users/_simple_form.html.erb +++ b/app/views/users/_simple_form.html.erb @@ -35,6 +35,8 @@ See COPYRIGHT and LICENSE files for more details. <%= render partial: "users/form/admin_flag", locals: { f: f } %> <% end %> - <%= render Users::Form::CustomFieldSectionsComponent.new(form: f) %> + <% UserCustomFieldSection.with_custom_fields(UserCustomField.visible(User.current)).each do |section| %> + <%= render Users::Form::CustomFieldSectionComponent.new(section:, fields: section.custom_fields, form: f) %> + <% end %> <%= call_hook(:view_users_form, user: @user, form: f) %> diff --git a/spec/components/users/form/custom_field_sections_component_spec.rb b/spec/components/users/form/custom_field_sections_component_spec.rb deleted file mode 100644 index 0a400d7cb396..000000000000 --- a/spec/components/users/form/custom_field_sections_component_spec.rb +++ /dev/null @@ -1,76 +0,0 @@ -# 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 Users::Form::CustomFieldSectionsComponent, type: :component do - let(:user) { create(:user) } - let(:form) { instance_double(ActionView::Helpers::FormBuilder, object: user) } - let(:component) { described_class.new(form:) } - - describe "#sections" do - subject { component.sections } - - context "with no sections" do - it { is_expected.to be_empty } - end - - context "with a section containing visible fields" do - let!(:section) { create(:user_custom_field_section) } - let!(:field) { create(:user_custom_field, :string, user_custom_field_section: section) } - - it "returns one section component" do - expect(subject.size).to eq(1) - expect(subject.first).to be_a(Users::Form::CustomFieldSectionComponent) - end - end - - context "with a section containing only admin_only fields" do - let!(:section) { create(:user_custom_field_section) } - let!(:field) { create(:user_custom_field, :string, user_custom_field_section: section, admin_only: true) } - - it "excludes the section for a non-admin user" do - expect(subject).to be_empty - end - end - - context "with multiple sections" do - let!(:section_a) { create(:user_custom_field_section, position: 1) } - let!(:section_b) { create(:user_custom_field_section, position: 2) } - let!(:field_a) { create(:user_custom_field, :string, user_custom_field_section: section_a) } - let!(:field_b) { create(:user_custom_field, :string, user_custom_field_section: section_b) } - - it "returns sections in position order" do - names = subject.map { |s| s.instance_variable_get(:@section) } - expect(names).to eq([section_a, section_b]) - end - end - end -end diff --git a/spec/components/users/profile/attributes_component_spec.rb b/spec/components/users/profile/attributes_component_spec.rb index 9bf42a3e2c3d..867e81742fd8 100644 --- a/spec/components/users/profile/attributes_component_spec.rb +++ b/spec/components/users/profile/attributes_component_spec.rb @@ -58,21 +58,21 @@ context "when user has a custom field with a present value" do let(:custom_field) { create(:user_custom_field, :string) } - let(:user) { build(:user, custom_values: [build(:custom_value, custom_field:, value: "Hello")]) } + let(:user) { create(:user, custom_values: [build(:custom_value, custom_field:, value: "Hello")]) } it { is_expected.to be(true) } end context "when user has a custom field with a blank value" do let(:custom_field) { create(:user_custom_field, :string) } - let(:user) { build(:user, custom_values: [build(:custom_value, custom_field:, value: " ")]) } + let(:user) { create(:user, custom_values: [build(:custom_value, custom_field:, value: "")]) } it { is_expected.to be(false) } end context "when user has a non-visible custom field with a present value" do let(:custom_field) { create(:user_custom_field, :string, admin_only: true) } - let(:user) { build(:user, custom_values: [build(:custom_value, custom_field:, value: "Hello")]) } + let(:user) { create(:user, custom_values: [build(:custom_value, custom_field:, value: "Hello")]) } it { is_expected.to be(false) } end @@ -82,7 +82,7 @@ let(:section) { create(:user_custom_field_section, name: "Profile info") } let(:custom_field) { create(:user_custom_field, :string, admin_only:, user_custom_field_section: section) } let(:admin_only) { false } - let(:user) { build_stubbed(:user, custom_values: [build(:custom_value, custom_field:, value: "Hello custom field")]) } + let(:user) { create(:user, custom_values: [build(:custom_value, custom_field:, value: "Hello custom field")]) } current_user { build(:admin) } @@ -96,13 +96,21 @@ expect(page).to have_text("Profile info") end - context "when admin_only and current user is not admin" do + context "when admin_only" do let(:admin_only) { true } - current_user { logged_in_user } + context "and current user is admin" do + it "renders the field" do + expect(page).to have_text("Hello custom field") + end + end + + context "and current user is not admin" do + current_user { logged_in_user } - it "does not render the field" do - expect(page).to have_no_text("Hello custom field") + it "does not render the field" do + expect(page).to have_no_text("Hello custom field") + end end end @@ -122,10 +130,10 @@ let(:field_in_first) { create(:user_custom_field, :string, name: "In first", user_custom_field_section: section_first) } let(:field_in_second) { create(:user_custom_field, :string, name: "In second", user_custom_field_section: section_second) } let(:user) do - build_stubbed(:user, custom_values: [ - build(:custom_value, custom_field: field_in_first, value: "Value A"), - build(:custom_value, custom_field: field_in_second, value: "Value B") - ]) + create(:user, custom_values: [ + build(:custom_value, custom_field: field_in_first, value: "Value A"), + build(:custom_value, custom_field: field_in_second, value: "Value B") + ]) end it "renders section headings in position order" do @@ -147,10 +155,10 @@ position_in_custom_field_section: 2) end let(:user) do - build_stubbed(:user, custom_values: [ - build(:custom_value, custom_field: first_field, value: "First value"), - build(:custom_value, custom_field: second_field, value: "Second value") - ]) + create(:user, custom_values: [ + build(:custom_value, custom_field: first_field, value: "First value"), + build(:custom_value, custom_field: second_field, value: "Second value") + ]) end it "renders fields in position_in_custom_field_section order" do @@ -170,12 +178,12 @@ position_in_custom_field_section: 1) end let(:user) do - build_stubbed(:user, custom_values: [ - build(:custom_value, custom_field: list_field, value: list_field.possible_values[0]), - build(:custom_value, custom_field: list_field, value: list_field.possible_values[1]), - build(:custom_value, custom_field: list_field, value: list_field.possible_values[2]), - build(:custom_value, custom_field: text_field, value: "This is **formatted** text.") - ]) + create(:user, custom_values: [ + build(:custom_value, custom_field: list_field, value: list_field.possible_values[0]), + build(:custom_value, custom_field: list_field, value: list_field.possible_values[1]), + build(:custom_value, custom_field: list_field, value: list_field.possible_values[2]), + build(:custom_value, custom_field: text_field, value: "This is **formatted** text.") + ]) end it "renders multi-select values as a comma-separated list" do diff --git a/spec/factories/attribute_help_text_factory.rb b/spec/factories/attribute_help_text_factory.rb index c1244fda86c7..0a91bc0031f1 100644 --- a/spec/factories/attribute_help_text_factory.rb +++ b/spec/factories/attribute_help_text_factory.rb @@ -14,4 +14,11 @@ caption { "Some caption" } attribute_name { "status" } end + + factory :user_help_text, class: "AttributeHelpText::User" do + type { "AttributeHelpText::User" } + help_text { "Attribute help text" } + caption { "Some caption" } + attribute_name { "custom_field_1" } + end end diff --git a/spec/features/admin/custom_fields/users/create_in_section_spec.rb b/spec/features/admin/custom_fields/users/create_in_section_spec.rb new file mode 100644 index 000000000000..109d67ddbf8f --- /dev/null +++ b/spec/features/admin/custom_fields/users/create_in_section_spec.rb @@ -0,0 +1,115 @@ +# 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" +require_relative "shared_context" + +RSpec.describe "Create user custom fields in sections", :js do + include_context "with seeded user custom fields" + + let(:cf_index_page) { Pages::Admin::Settings::UserCustomFields::Index.new } + + context "with insufficient permissions" do + it "is not accessible" do + login_as(non_admin) + cf_index_page.visit! + + expect(page).to have_text("You are not authorized to access this page.") + end + end + + context "with sufficient permissions" do + before do + login_as(admin) + cf_index_page.visit! + end + + context "when creating a Text user attribute" do + before do + cf_index_page.click_to_create_new_custom_field("Text") + end + + it "shows the correct breadcrumb" do + within ".PageHeader-breadcrumbs" do + expect(page).to have_link("Administration") + expect(page).to have_link("Users and permissions") + expect(page).to have_link("User attributes") + expect(page).to have_text("Text: New attribute") + end + end + + it "creates a user attribute assigned to a section" do + fill_in("custom_field_name", with: "Job title") + select(section_for_input_fields.name, from: "custom_field_custom_field_section_id") + check "Admin-only" + + click_on("Save") + + expect(page).to have_text("Successful creation") + + created = UserCustomField.find_by(name: "Job title") + expect(page).to have_current_path(edit_admin_settings_user_custom_field_path(created)) + expect(created.admin_only).to be(true) + expect(created.user_custom_field_section).to eq(section_for_input_fields) + end + + it "allows creating a user attribute with a prefilled section via URL param" do + visit new_admin_settings_user_custom_field_path(field_format: "text", + custom_field_section_id: section_for_select_fields.id) + + fill_in("custom_field_name", with: "Biography") + click_on("Save") + + expect(page).to have_text("Successful creation") + + created = UserCustomField.find_by(name: "Biography") + expect(created.user_custom_field_section).to eq(section_for_select_fields) + end + + it "prevents creating a user attribute with an empty name" do + click_on("Save") + + expect(page).to have_field("custom_field_name", with: "", validation_error: "Name can't be blank") + expect(page).to have_current_path(new_admin_settings_user_custom_field_path(field_format: "string")) + end + + context "without any existing sections" do + before do + UserCustomFieldSection.destroy_all + cf_index_page.visit! + end + + it "prevents creating a new user attribute" do + cf_index_page.expect_no_add_user_attribute_submenu + end + end + end + end +end diff --git a/spec/features/admin/settings/user_custom_fields/attribute_help_text_spec.rb b/spec/features/admin/settings/user_custom_fields/attribute_help_text_spec.rb new file mode 100644 index 000000000000..60fe39a7a5d7 --- /dev/null +++ b/spec/features/admin/settings/user_custom_fields/attribute_help_text_spec.rb @@ -0,0 +1,126 @@ +# 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 "User custom field attribute help text", :js do + include Flash::Expectations + + shared_let(:admin) { create(:admin) } + shared_let(:section) { create(:user_custom_field_section) } + shared_let(:user_custom_field) do + create(:user_custom_field, name: "Job title", field_format: "text", user_custom_field_section: section) + end + + let(:editor) { Components::WysiwygEditor.new } + + before { login_as(admin) } + + describe "creating attribute help text" do + it "allows creating help text from the custom field edit page" do + visit edit_admin_settings_user_custom_field_path(user_custom_field) + + click_on "Help text" + + expect(page).to have_current_path( + attribute_help_text_admin_settings_user_custom_field_path(user_custom_field) + ) + + expect(page).to have_no_css("#attribute_help_text_attribute_name") + + fill_in "Caption", with: "Job title help" + editor.set_markdown("Enter your current job title") + + click_button "Save" + + expect(page).to have_current_path( + attribute_help_text_admin_settings_user_custom_field_path(user_custom_field) + ) + expect(page).to have_text("Successful update") + + help_text = AttributeHelpText::User.find_by(attribute_name: "custom_field_#{user_custom_field.id}") + expect(help_text).to be_present + expect(help_text.caption).to eq("Job title help") + expect(help_text.help_text).to include("Enter your current job title") + end + end + + describe "editing attribute help text" do + let!(:existing_help_text) do + create(:user_help_text, + attribute_name: "custom_field_#{user_custom_field.id}", + caption: "Original caption", + help_text: "Original help text") + end + + it "allows editing existing help text" do + visit attribute_help_text_admin_settings_user_custom_field_path(user_custom_field) + + expect(page).to have_field("Caption", with: "Original caption") + + fill_in "Caption", with: "Updated caption" + editor.clear + editor.set_markdown("Updated help text") + + click_button "Save" + + expect(page).to have_text("Successful update") + + existing_help_text.reload + expect(existing_help_text.caption).to eq("Updated caption") + expect(existing_help_text.help_text).to eq("Updated help text") + end + + it "shows validation errors when clearing help text" do + visit attribute_help_text_admin_settings_user_custom_field_path(user_custom_field) + + editor.clear + editor.set_markdown(" ") + click_button "Save" + expect(page).to have_text("Help text can't be blank") + end + end + + describe "tab navigation" do + it "navigates between Details and Help text tabs" do + visit edit_admin_settings_user_custom_field_path(user_custom_field) + + click_on "Help text" + expect(page).to have_current_path( + attribute_help_text_admin_settings_user_custom_field_path(user_custom_field) + ) + + click_on "Details" + expect(page).to have_current_path( + edit_admin_settings_user_custom_field_path(user_custom_field) + ) + end + end +end diff --git a/spec/features/users/edit_users_spec.rb b/spec/features/users/edit_users_spec.rb index c139109a8234..abe283b1afec 100644 --- a/spec/features/users/edit_users_spec.rb +++ b/spec/features/users/edit_users_spec.rb @@ -49,6 +49,28 @@ def user_password find "input#user_password" end + describe "custom fields" do + let!(:section) { create(:user_custom_field_section, name: "Professional info") } + let!(:string_cf) { create(:user_custom_field, :string, name: "Job title", user_custom_field_section: section) } + let!(:admin_only_cf) do + create(:user_custom_field, :string, name: "Internal code", admin_only: true, user_custom_field_section: section) + end + + before { visit edit_user_path(user) } + + it "saves a custom field value" do + within "fieldset", text: "Professional info".upcase do + fill_in "Job title", with: "Software Engineer" + fill_in "Internal code", with: "SE" + end + click_on "Save" + + expect(page).to have_text("Successful update") + expect(user.reload.custom_value_for(string_cf)&.value).to eq("Software Engineer") + expect(user.custom_value_for(admin_only_cf)&.value).to eq("SE") + end + end + context "with internal authentication" do before do visit edit_user_path(user) @@ -145,8 +167,9 @@ def have_visible_tab(label) end context "as admin" do + let(:another_admin) { create(:admin) } + it "can edit attributes of an admin user" do - another_admin = create(:admin) visit edit_user_path(another_admin) expect(page).to have_visible_tab("General") diff --git a/spec/lib/open_project/custom_field_format_spec.rb b/spec/lib/open_project/custom_field_format_spec.rb index 074016678b10..18e4b50a631c 100644 --- a/spec/lib/open_project/custom_field_format_spec.rb +++ b/spec/lib/open_project/custom_field_format_spec.rb @@ -130,7 +130,7 @@ context "for a 'User' class" do it_behaves_like "custom field formats", "User", - %w[string text int float list date bool] + %w[string text int float list date bool hierarchy] end context "for a 'Group' class" do diff --git a/spec/models/user_custom_field_spec.rb b/spec/models/user_custom_field_spec.rb new file mode 100644 index 000000000000..167d6923ff4d --- /dev/null +++ b/spec/models/user_custom_field_spec.rb @@ -0,0 +1,108 @@ +# 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 UserCustomField do + describe "validations" do + it "is invalid without a section" do + cf = build(:user_custom_field, custom_field_section_id: nil) + expect(cf).not_to be_valid + expect(cf.errors[:custom_field_section_id]).to be_present + end + + it "is valid with a section" do + expect(create(:user_custom_field)).to be_valid + end + end + + describe "associations" do + it "belongs to a UserCustomFieldSection" do + section = create(:user_custom_field_section) + cf = create(:user_custom_field, user_custom_field_section: section) + expect(cf.user_custom_field_section).to eq(section) + end + end + + describe "#type_name" do + it "returns :label_user_plural" do + expect(described_class.new.type_name).to eq(:label_user_plural) + end + end + + describe "defaults" do + it "defaults visible_on_user_card to false" do + cf = create(:user_custom_field) + expect(cf.visible_on_user_card).to be(false) + end + end + + describe "acts_as_list within a section" do + let(:section) { create(:user_custom_field_section) } + let!(:cf_first) { create(:user_custom_field, user_custom_field_section: section, position_in_custom_field_section: 1) } + let!(:cf_second) { create(:user_custom_field, user_custom_field_section: section, position_in_custom_field_section: 2) } + + it "orders fields by position within the section" do + expect(section.custom_fields.to_a).to eq([cf_first, cf_second]) + end + + it "keeps positions scoped per section" do + other_section = create(:user_custom_field_section) + cf_other = create(:user_custom_field, user_custom_field_section: other_section) + expect(cf_other.position_in_custom_field_section).to eq(1) + end + end + + describe ".visible" do + let!(:admin_only_cf) { create(:user_custom_field, admin_only: true) } + let!(:public_cf) { create(:user_custom_field, admin_only: false) } + + context "for an admin" do + it "returns all custom fields" do + expect(described_class.visible(build(:admin))).to include(admin_only_cf, public_cf) + end + end + + context "for a non-admin" do + it "returns only non-admin_only custom fields" do + expect(described_class.visible(build(:user))).to include(public_cf) + expect(described_class.visible(build(:user))).not_to include(admin_only_cf) + end + end + end + + describe "section cascade deletion" do + it "is destroyed when its section is destroyed" do + section = create(:user_custom_field_section) + cf = create(:user_custom_field, user_custom_field_section: section) + expect { section.destroy }.to change { described_class.exists?(cf.id) }.from(true).to(false) + end + end +end diff --git a/app/components/users/form/custom_field_sections_component.rb b/spec/services/user_custom_field_sections/create_service_spec.rb similarity index 52% rename from app/components/users/form/custom_field_sections_component.rb rename to spec/services/user_custom_field_sections/create_service_spec.rb index 229100b7ae46..ad358cb5c381 100644 --- a/app/components/users/form/custom_field_sections_component.rb +++ b/spec/services/user_custom_field_sections/create_service_spec.rb @@ -28,25 +28,45 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module Users - module Form - class CustomFieldSectionsComponent < ApplicationComponent - def initialize(form:) - super() - @form = form - end - - def sections - @sections ||= begin - visible_cf_ids = @form.object.visible_custom_field_values.map(&:custom_field_id) - - UserCustomFieldSection - .with_custom_fields(visible_cf_ids) - .map do |section| - Users::Form::CustomFieldSectionComponent.new(section:, fields: section.custom_fields, form: @form) - end - end - end +require "spec_helper" + +RSpec.describe UserCustomFieldSections::CreateService do + let(:admin) { build_stubbed(:admin) } + let(:user) { build_stubbed(:user) } + + subject(:call) { described_class.new(user: current_user).call(name: "New section") } + + context "when called by an admin" do + let(:current_user) { admin } + + it "creates the section successfully" do + expect(call).to be_success + expect(call.result).to be_a(UserCustomFieldSection) + expect(call.result.name).to eq("New section") + end + + it "persists the section to the database" do + expect { call }.to change(UserCustomFieldSection, :count).by(1) + end + end + + context "when called by a non-admin" do + let(:current_user) { user } + + it "fails authorization" do + expect(call).not_to be_success + expect(call.result).not_to be_persisted + end + end + + context "with invalid attributes" do + let(:current_user) { admin } + + subject(:call) { described_class.new(user: admin).call(name: "") } + + it "fails validation" do + expect(call).not_to be_success + expect(call.result.errors[:name]).to be_present end end end diff --git a/spec/services/user_custom_field_sections/delete_service_spec.rb b/spec/services/user_custom_field_sections/delete_service_spec.rb new file mode 100644 index 000000000000..7eac6e778c31 --- /dev/null +++ b/spec/services/user_custom_field_sections/delete_service_spec.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. +#++ + +require "spec_helper" + +RSpec.describe UserCustomFieldSections::DeleteService do + let(:admin) { build_stubbed(:admin) } + let(:user) { build_stubbed(:user) } + let(:section) { create(:user_custom_field_section) } + + context "when called by an admin on an empty section" do + subject(:call) { described_class.new(user: admin, model: section).call } + + it "deletes the section" do + expect(call).to be_success + expect(UserCustomFieldSection.exists?(section.id)).to be(false) + end + end + + context "when called by a non-admin" do + subject(:call) { described_class.new(user: user, model: section).call } + + it "fails authorization" do + expect(call).not_to be_success + expect(UserCustomFieldSection.exists?(section.id)).to be(true) + end + end + + context "when the section has custom fields" do + before { create(:user_custom_field, user_custom_field_section: section) } + + subject(:call) { described_class.new(user: admin, model: section).call } + + it "fails with an in_use error" do + expect(call).not_to be_success + expect(call.result.errors).to be_of_kind(:base, :in_use) + end + + it "does not delete the section" do + call + expect(UserCustomFieldSection.exists?(section.id)).to be(true) + end + end +end diff --git a/spec/services/user_custom_field_sections/update_service_spec.rb b/spec/services/user_custom_field_sections/update_service_spec.rb new file mode 100644 index 000000000000..fd90568d3a91 --- /dev/null +++ b/spec/services/user_custom_field_sections/update_service_spec.rb @@ -0,0 +1,74 @@ +# 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 UserCustomFieldSections::UpdateService do + let(:admin) { build_stubbed(:admin) } + let(:user) { build_stubbed(:user) } + let(:section) { create(:user_custom_field_section, name: "Original name") } + + context "when called by an admin" do + subject(:call) { described_class.new(user: admin, model: section).call(name: "Updated name") } + + it "updates the section successfully" do + expect(call).to be_success + expect(section.reload.name).to eq("Updated name") + end + end + + context "when called by a non-admin" do + subject(:call) { described_class.new(user: user, model: section).call(name: "Updated name") } + + it "fails authorization" do + expect(call).not_to be_success + expect(section.reload.name).to eq("Original name") + end + end + + context "with invalid attributes" do + subject(:call) { described_class.new(user: admin, model: section).call(name: "") } + + it "fails validation" do + expect(call).not_to be_success + expect(call.result.errors[:name]).to be_present + end + end + + context "when moving position" do + let!(:section_a) { create(:user_custom_field_section, position: 1) } + let!(:section_b) { create(:user_custom_field_section, position: 2) } + + it "reorders sections when moving up" do + described_class.new(user: admin, model: section_b).call(move_to: :highest) + expect(section_b.reload.position).to eq(1) + end + end +end From 6907e1abaf19bd7fe4fc319ca6e4a8d49f54e644 Mon Sep 17 00:00:00 2001 From: David F Date: Thu, 4 Jun 2026 17:56:09 +0200 Subject: [PATCH 08/20] Include built-in fields in User attributes sections. wp/72005 --- .../built_in_attribute_row_component.html.erb | 32 +++ .../built_in_attribute_row_component.rb | 79 ++++++ .../custom_field_row_component.rb | 30 +- .../show_component.html.erb | 38 ++- .../show_component.rb | 19 +- .../custom_field_section_component.html.erb | 8 +- .../form/custom_field_section_component.rb | 20 +- .../users/hover_card_component.html.erb | 4 +- app/components/users/hover_card_component.rb | 18 +- .../users/profile/attributes_component.rb | 11 +- .../built_in_attributes_controller.rb | 80 ++++++ .../settings/user_custom_fields_controller.rb | 4 +- app/models/custom_field_section.rb | 34 +++ app/models/user_custom_field.rb | 16 +- app/models/user_custom_field_section.rb | 49 +++- .../user_custom_field_section_seeder.rb | 52 ++++ app/seeders/standard/basic_data_seeder.rb | 3 +- .../user_custom_fields/drop_service.rb | 41 +-- .../user_custom_fields/move_service.rb | 49 ++++ app/views/users/_form.html.erb | 6 +- app/views/users/_simple_form.html.erb | 6 +- .../users/form/_basic_attributes.html.erb | 39 --- .../form/built_in_fields/_firstname.html.erb | 7 + .../form/built_in_fields/_language.html.erb | 7 + .../form/built_in_fields/_lastname.html.erb | 7 + .../form/built_in_fields/_login.html.erb | 10 + .../users/form/built_in_fields/_mail.html.erb | 7 + config/locales/en.yml | 19 +- config/routes.rb | 9 + ...ttribute_order_to_custom_field_sections.rb | 59 ++++ .../users/hover_card_component_spec.rb | 51 +--- .../profile/attributes_component_spec.rb | 129 ++------- .../users/create_in_section_spec.rb | 3 +- spec/features/auth/consent_auth_stage_spec.rb | 8 + spec/features/users/create_spec.rb | 5 + spec/features/users/edit_users_spec.rb | 5 + ...ute_order_to_custom_field_sections_spec.rb | 156 +++++++++++ spec/models/user_custom_field_section_spec.rb | 257 ++++++++++++++++-- spec/models/user_custom_field_spec.rb | 49 +++- .../user_custom_fields/drop_service_spec.rb | 87 ++++++ .../user_custom_fields/move_service_spec.rb | 74 +++++ 41 files changed, 1229 insertions(+), 358 deletions(-) create mode 100644 app/components/settings/user_custom_field_sections/built_in_attribute_row_component.html.erb create mode 100644 app/components/settings/user_custom_field_sections/built_in_attribute_row_component.rb create mode 100644 app/controllers/admin/settings/user_custom_field_sections/built_in_attributes_controller.rb create mode 100644 app/seeders/basic_data/user_custom_field_section_seeder.rb create mode 100644 app/services/user_custom_fields/move_service.rb delete mode 100644 app/views/users/form/_basic_attributes.html.erb create mode 100644 app/views/users/form/built_in_fields/_firstname.html.erb create mode 100644 app/views/users/form/built_in_fields/_language.html.erb create mode 100644 app/views/users/form/built_in_fields/_lastname.html.erb create mode 100644 app/views/users/form/built_in_fields/_login.html.erb create mode 100644 app/views/users/form/built_in_fields/_mail.html.erb create mode 100644 db/migrate/20260604120001_add_attribute_order_to_custom_field_sections.rb create mode 100644 spec/migrations/add_attribute_order_to_custom_field_sections_spec.rb create mode 100644 spec/services/user_custom_fields/drop_service_spec.rb create mode 100644 spec/services/user_custom_fields/move_service_spec.rb diff --git a/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.html.erb b/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.html.erb new file mode 100644 index 000000000000..3781f23751d1 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.html.erb @@ -0,0 +1,32 @@ +<%= + component_wrapper( + class: "op-user-built-in-attribute-container", + data: { test_selector: "user-built-in-attribute-container-#{@attribute_key}" } + ) do + flex_layout(justify_content: :space_between, align_items: :center) do |main_container| + main_container.with_column(flex_layout: true, align_items: :center) do |content_container| + content_container.with_column(mr: 2) do + render(Primer::OpenProject::DragHandle.new(classes: "handle")) + end + content_container.with_column(mr: 2) do + render(Primer::Beta::Text.new(font_weight: :bold)) { attribute_label } + end + content_container.with_column do + render(Primer::Beta::Label.new(scheme: :secondary, size: :medium)) do + t("settings.user_attributes.label_built_in") + end + end + end + main_container.with_column do + render(Primer::Alpha::ActionMenu.new(data: { test_selector: "built-in-attribute-action-menu" })) do |menu| + menu.with_show_button( + icon: "kebab-horizontal", + "aria-label": t("settings.user_attributes.label_built_in_attribute_actions"), + scheme: :invisible + ) + move_actions(menu) + end + end + end + end +%> diff --git a/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.rb b/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.rb new file mode 100644 index 000000000000..3b442fc27b82 --- /dev/null +++ b/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.rb @@ -0,0 +1,79 @@ +# 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 Settings + module UserCustomFieldSections + class BuiltInAttributeRowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(section:, attribute_key:, first:, last:) + super() + @section = section + @attribute_key = attribute_key + @first = first + @last = last + end + + def attribute_label + User.human_attribute_name(@attribute_key) + end + + private + + def move_actions(menu) + unless @first + move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") + move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") + end + unless @last + move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") + move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), "move-to-bottom") + end + end + + def move_action_item(menu, move_to, label_text, icon) + menu.with_item( + label: label_text, + href: move_admin_settings_user_custom_field_section_built_in_attribute_path( + @section, @attribute_key, move_to: + ), + form_arguments: { + method: :put, + data: { "turbo-stream": true, test_selector: "built-in-attribute-move-#{move_to}" } + } + ) do |item| + item.with_leading_visual_icon(icon:) + end + end + end + end +end diff --git a/app/components/settings/user_custom_field_sections/custom_field_row_component.rb b/app/components/settings/user_custom_field_sections/custom_field_row_component.rb index e9e3cbd19e15..854ebc0b05b9 100644 --- a/app/components/settings/user_custom_field_sections/custom_field_row_component.rb +++ b/app/components/settings/user_custom_field_sections/custom_field_row_component.rb @@ -35,11 +35,11 @@ class CustomFieldRowComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize(user_custom_field:, first_and_last:) - super - + def initialize(user_custom_field:, first:, last:) + super() @user_custom_field = user_custom_field - @first_and_last = first_and_last + @first = first + @last = last end private @@ -53,11 +53,11 @@ def edit_action_item(menu) end def move_actions(menu) - unless first? + unless @first move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") end - unless last? + unless @last move_action_item(menu, :lower, t("label_agenda_item_move_down"), "chevron-down") move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), "move-to-bottom") end @@ -88,24 +88,6 @@ def delete_action_item(menu) item.with_leading_visual_icon(icon: :trash) end end - - def first? - @first ||= - if @first_and_last.first - @first_and_last.first == @user_custom_field - else - @user_custom_field.first? - end - end - - def last? - @last ||= - if @first_and_last.last - @first_and_last.last == @user_custom_field - else - @user_custom_field.last? - end - end end end end diff --git a/app/components/settings/user_custom_field_sections/show_component.html.erb b/app/components/settings/user_custom_field_sections/show_component.html.erb index 49b6f3cd438e..68ce24eaf2c3 100644 --- a/app/components/settings/user_custom_field_sections/show_component.html.erb +++ b/app/components/settings/user_custom_field_sections/show_component.html.erb @@ -20,7 +20,7 @@ menu.with_show_button(icon: "kebab-horizontal", "aria-label": t("settings.user_attributes.label_section_actions"), scheme: :invisible) edit_action_item(menu) move_actions(menu) - if @user_custom_fields.empty? + if @ordered_attributes.empty? delete_action_item(menu) else disabled_delete_action_item(menu) @@ -42,7 +42,7 @@ end end end - if @user_custom_fields.empty? + if @ordered_attributes.empty? component.with_row(data: { "empty-list-item": true }) do flex_layout(align_items: :center, justify_content: :space_between) do |empty_list_container| empty_list_container.with_column(ml: 4, mr: 2) do @@ -70,17 +70,31 @@ end end else - first_and_last = [@user_custom_fields.first, @user_custom_fields.last] - @user_custom_fields.each do |user_custom_field| - component.with_row( - data: draggable_item_config(user_custom_field) - ) do - render( - custom_field_row_component_class.new( - user_custom_field:, - first_and_last: + @ordered_attributes.each_with_index do |key, index| + first = index == 0 + last = index == @ordered_attributes.size - 1 + + if UserCustomFieldSection::BUILT_IN_ATTRIBUTES.include?(key) + component.with_row(data: draggable_item_config_for_built_in(key)) do + render( + Settings::UserCustomFieldSections::BuiltInAttributeRowComponent.new( + section: @user_custom_field_section, + attribute_key: key, + first:, + last: + ) + ) + end + elsif (cf = @custom_fields_by_key[key]) + component.with_row(data: draggable_item_config_for_custom_field(cf)) do + render( + custom_field_row_component_class.new( + user_custom_field: cf, + first:, + last: + ) ) - ) + end end end end diff --git a/app/components/settings/user_custom_field_sections/show_component.rb b/app/components/settings/user_custom_field_sections/show_component.rb index 255d116ced28..c0c431f451c1 100644 --- a/app/components/settings/user_custom_field_sections/show_component.rb +++ b/app/components/settings/user_custom_field_sections/show_component.rb @@ -39,7 +39,8 @@ def initialize(user_custom_field_section:, first_and_last: []) super @user_custom_field_section = user_custom_field_section - @user_custom_fields = user_custom_field_section.custom_fields + @ordered_attributes = user_custom_field_section.attribute_order + @custom_fields_by_key = user_custom_field_section.custom_fields_by_key @first_and_last = first_and_last end @@ -59,18 +60,28 @@ def drag_and_drop_target_config generic_drag_and_drop_target: "container", "target-container-accessor": ".Box > ul", "target-id": @user_custom_field_section.id, - "target-allowed-drag-type": "custom-field" + "target-allowed-drag-type": "user-field" } end - def draggable_item_config(user_custom_field) + def draggable_item_config_for_custom_field(user_custom_field) { "draggable-id": user_custom_field.id, - "draggable-type": "custom-field", + "draggable-type": "user-field", "drop-url": drop_admin_settings_user_custom_field_path(user_custom_field) } end + def draggable_item_config_for_built_in(key) + { + "draggable-id": key, + "draggable-type": "user-field", + "drop-url": drop_admin_settings_user_custom_field_section_built_in_attribute_path( + @user_custom_field_section, key + ) + } + end + def move_actions(menu) unless first? move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), "move-to-top") diff --git a/app/components/users/form/custom_field_section_component.html.erb b/app/components/users/form/custom_field_section_component.html.erb index b0e252a59006..241e5c33ae26 100644 --- a/app/components/users/form/custom_field_section_component.html.erb +++ b/app/components/users/form/custom_field_section_component.html.erb @@ -1,7 +1,11 @@
<%= title %> - <% @fields.each do |custom_field| %> - <%= render Users::Form::CustomFieldFieldComponent.new(custom_field:, form: @form) %> + <% @section.attribute_order.each do |key| %> + <% if built_in?(key) %> + <%= render "users/form/built_in_fields/#{key}", f: @form, contract: @contract, user: @user %> + <% elsif (cf = visible_custom_field(key)) %> + <%= render Users::Form::CustomFieldFieldComponent.new(custom_field: cf, form: @form) %> + <% end %> <% end %>
diff --git a/app/components/users/form/custom_field_section_component.rb b/app/components/users/form/custom_field_section_component.rb index d809e21bb326..d3c0de82b55d 100644 --- a/app/components/users/form/custom_field_section_component.rb +++ b/app/components/users/form/custom_field_section_component.rb @@ -31,16 +31,32 @@ module Users module Form class CustomFieldSectionComponent < ApplicationComponent - def initialize(section:, fields:, form:) + def initialize(section:, form:, contract:, user:) super() @section = section - @fields = fields @form = form + @contract = contract + @user = user + @visible_cfs_by_key = visible_cfs_by_key(section) end def title @section.name.presence || I18n.t("settings.user_attributes.label_untitled_section") end + + def built_in?(key) + UserCustomFieldSection::BUILT_IN_ATTRIBUTES.include?(key) + end + + def visible_custom_field(key) + @visible_cfs_by_key[key] + end + + private + + def visible_cfs_by_key(section) + section.custom_fields.visible(User.current).index_by(&:column_name) + end end end end diff --git a/app/components/users/hover_card_component.html.erb b/app/components/users/hover_card_component.html.erb index 80fac9aba006..911b306552bd 100644 --- a/app/components/users/hover_card_component.html.erb +++ b/app/components/users/hover_card_component.html.erb @@ -57,8 +57,8 @@ See COPYRIGHT and LICENSE files for more details. end end - card_custom_field_values.group_by(&:custom_field).each do |custom_field, values| - formatted = format_multi_values(values) + card_sections_with_fields.flat_map(&:last).each do |custom_field| + formatted = format_multi_values(@user.formatted_custom_value_for(custom_field)) flex.with_row(data: { test_selector: "user-hover-card-custom-field" }) do flex_layout(classes: "op-user-hover-card--custom-field") do |cf| diff --git a/app/components/users/hover_card_component.rb b/app/components/users/hover_card_component.rb index 132700eccc89..cc42976a2660 100644 --- a/app/components/users/hover_card_component.rb +++ b/app/components/users/hover_card_component.rb @@ -62,22 +62,16 @@ def group_membership_summary(max_length = 40) build_summary(group_links, cutoff_index) end - def card_custom_field_values - @user.custom_values - .eager_load(:custom_field) - .joins("INNER JOIN custom_field_sections ON custom_field_sections.id = custom_fields.custom_field_section_id") - .where(custom_fields: { visible_on_user_card: true }) - .where.not(value: [nil, ""]) - .then { |scope| User.current.active_admin? ? scope : scope.where(custom_fields: { admin_only: false }) } - .order("custom_field_sections.position", "custom_fields.position_in_custom_field_section") + def card_sections_with_fields + @card_sections_with_fields ||= UserCustomFieldSection.with_filled_fields_for(@user, visible_on_user_card: true) end private - def format_multi_values(values) - formatted = values.map(&:formatted_value) - visible = safe_join(formatted.first(MULTI_VALUE_DISPLAY_LIMIT), ", ") - remaining = formatted.size - MULTI_VALUE_DISPLAY_LIMIT + def format_multi_values(value) + values = Array(value) + visible = safe_join(values.first(MULTI_VALUE_DISPLAY_LIMIT), ", ") + remaining = values.size - MULTI_VALUE_DISPLAY_LIMIT return visible if remaining <= 0 diff --git a/app/components/users/profile/attributes_component.rb b/app/components/users/profile/attributes_component.rb index b52ab29b04eb..ed37f49861fb 100644 --- a/app/components/users/profile/attributes_component.rb +++ b/app/components/users/profile/attributes_component.rb @@ -46,16 +46,7 @@ def render? end def sections_with_fields - @sections_with_fields ||= begin - filled_cf_ids = @user.custom_values - .where(custom_field: UserCustomField.visible(User.current)) - .where.not(value: [nil, ""]) - .select(:custom_field_id) - - UserCustomFieldSection - .with_custom_fields(filled_cf_ids) - .map { |section| [section, section.custom_fields] } - end + @sections_with_fields ||= UserCustomFieldSection.with_filled_fields_for(@user) end def user_is_allowed_to_see_email diff --git a/app/controllers/admin/settings/user_custom_field_sections/built_in_attributes_controller.rb b/app/controllers/admin/settings/user_custom_field_sections/built_in_attributes_controller.rb new file mode 100644 index 000000000000..a2c0ed91c727 --- /dev/null +++ b/app/controllers/admin/settings/user_custom_field_sections/built_in_attributes_controller.rb @@ -0,0 +1,80 @@ +# 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 Settings + module UserCustomFieldSections + class BuiltInAttributesController < ::Admin::SettingsController + include OpTurbo::ComponentStream + include Admin::Settings::UserCustomFields::ComponentStreams + + menu_item :user_custom_fields_settings + + before_action :set_section + before_action :set_attribute_key + + def move + @section.move_in_order(@key, params.expect(:move_to)&.to_sym) + update_section_via_turbo_stream(user_custom_field_section: @section.reload) + respond_with_turbo_streams + end + + def drop # rubocop:disable Metrics/AbcSize + new_section_id = params[:target_id]&.to_i + return head :bad_request if new_section_id.nil? + + if @section.id == new_section_id + @section.add_to_order(@key, position: params[:position]&.to_i) + update_section_via_turbo_stream(user_custom_field_section: @section.reload) + else + new_section = UserCustomFieldSection.find(new_section_id) + @section.remove_from_order(@key) + new_section.add_to_order(@key, position: params[:position]&.to_i) + update_section_via_turbo_stream(user_custom_field_section: @section.reload) + update_section_via_turbo_stream(user_custom_field_section: new_section.reload) + end + + respond_with_turbo_streams + end + + private + + def set_section + @section = UserCustomFieldSection.find(params.expect(:user_custom_field_section_id)) + end + + def set_attribute_key + @key = params.expect(:key) + head :unprocessable_entity unless UserCustomFieldSection::BUILT_IN_ATTRIBUTES.include?(@key) + end + end + end + end +end diff --git a/app/controllers/admin/settings/user_custom_fields_controller.rb b/app/controllers/admin/settings/user_custom_fields_controller.rb index 4c9eed3411cb..6d538dd836e7 100644 --- a/app/controllers/admin/settings/user_custom_fields_controller.rb +++ b/app/controllers/admin/settings/user_custom_fields_controller.rb @@ -70,8 +70,8 @@ def edit; end def list_items; end def move - result = CustomFields::UpdateService.new(user: current_user, model: @custom_field).call( - move_to: params.expect(:move_to)&.to_sym + result = UserCustomFields::MoveService.new(user: current_user, user_custom_field: @custom_field).call( + move_to: params.expect(:move_to) ) if result.success? diff --git a/app/models/custom_field_section.rb b/app/models/custom_field_section.rb index 84f9db4f3d1b..bd8ddac1e85f 100644 --- a/app/models/custom_field_section.rb +++ b/app/models/custom_field_section.rb @@ -48,4 +48,38 @@ def shown_in_overview_sidebar? def shown_in_overview_main_area? overview == OVERVIEW__MAIN_AREA_KEY end + + # Append key to the end of the ordered list (or at a specific 1-indexed position). + # Idempotent: removes any existing occurrence first. + def add_to_order(key, position: nil) + keys = attribute_order.reject { |k| k == key } + if position && position > 0 + keys.insert(position - 1, key) + else + keys << key + end + update_column(:attribute_order, keys) + end + + def remove_from_order(key) + update_column(:attribute_order, attribute_order.reject { |k| k == key }) + end + + # move_to: :highest | :higher | :lower | :lowest + def move_in_order(key, move_to) + idx = attribute_order.index(key) + return unless idx + + keys = attribute_order.dup + keys.delete_at(idx) + new_idx = case move_to.to_sym + when :highest then 0 + when :lowest then keys.size + when :higher then [idx - 1, 0].max + when :lower then [idx + 1, keys.size].min + else return + end + keys.insert(new_idx, key) + update_column(:attribute_order, keys) + end end diff --git a/app/models/user_custom_field.rb b/app/models/user_custom_field.rb index 1c36aaa70ad5..aed5c29e401a 100644 --- a/app/models/user_custom_field.rb +++ b/app/models/user_custom_field.rb @@ -32,13 +32,25 @@ class UserCustomField < CustomField belongs_to :user_custom_field_section, class_name: "UserCustomFieldSection", foreign_key: :custom_field_section_id, inverse_of: :custom_fields - acts_as_list column: :position_in_custom_field_section, scope: [:custom_field_section_id] - validates :custom_field_section_id, presence: true + after_create_commit :add_to_section_order + after_destroy_commit :remove_from_section_order + scopes :visible def type_name :label_user_plural end + + private + + def add_to_section_order + user_custom_field_section.add_to_order(column_name) + end + + def remove_from_section_order + section = user_custom_field_section + section.remove_from_order(column_name) unless section.nil? || section.frozen? + end end diff --git a/app/models/user_custom_field_section.rb b/app/models/user_custom_field_section.rb index afb383a6f119..0261db07e6c2 100644 --- a/app/models/user_custom_field_section.rb +++ b/app/models/user_custom_field_section.rb @@ -29,20 +29,57 @@ #++ class UserCustomFieldSection < CustomFieldSection + BUILT_IN_ATTRIBUTES = %w[login firstname lastname mail language].freeze + + has_many :custom_fields, + class_name: "UserCustomField", + dependent: :restrict_with_exception, + foreign_key: :custom_field_section_id, + inverse_of: :user_custom_field_section + + # Returns sections that contain at least one of the given custom field IDs, + # ordered by section position. Field ordering within each section is driven + # by attribute_order in Ruby. scope :with_custom_fields, ->(ids) { joins(:custom_fields) .where(custom_fields: { id: ids }) .includes(:custom_fields) - .order("custom_field_sections.position", "custom_fields.position_in_custom_field_section") + .order("custom_field_sections.position") } + # Returns [[section, [ordered_cfs]], ...] for the given user's filled, visible + # custom fields. Pass visible_on_user_card: true to restrict to hover-card fields. + # Two SQL queries: one for sections, one for the relevant CFs (both use filled_cf_ids + # as a subquery so no values are materialised into Ruby memory). + def self.with_filled_fields_for(user, visible_on_user_card: nil) # rubocop:disable Metrics/AbcSize + cf_scope = UserCustomField.visible(User.current) + cf_scope = cf_scope.where(visible_on_user_card: true) if visible_on_user_card + + filled_cf_ids = user.custom_values + .where(custom_field: cf_scope) + .where.not(value: [nil, ""]) + .select(:custom_field_id) + + sections = joins(:custom_fields) + .where(custom_fields: { id: filled_cf_ids }) + .distinct + .order(:position) + + cfs_by_section = cf_scope.where(id: filled_cf_ids) + .group_by(&:custom_field_section_id) + + sections.filter_map do |section| + cf_by_key = (cfs_by_section[section.id] || []).index_by(&:column_name) + ordered = section.attribute_order.filter_map { |key| cf_by_key[key] } + [section, ordered] if ordered.any? + end + end + def untitled? name.blank? end - has_many :custom_fields, -> { order(position_in_custom_field_section: :asc) }, - class_name: "UserCustomField", - dependent: :destroy, - foreign_key: :custom_field_section_id, - inverse_of: :user_custom_field_section + def custom_fields_by_key + custom_fields.index_by(&:column_name) + end end diff --git a/app/seeders/basic_data/user_custom_field_section_seeder.rb b/app/seeders/basic_data/user_custom_field_section_seeder.rb new file mode 100644 index 000000000000..ad6f40df824c --- /dev/null +++ b/app/seeders/basic_data/user_custom_field_section_seeder.rb @@ -0,0 +1,52 @@ +# 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 BasicData + class UserCustomFieldSectionSeeder < Seeder + def seed_data! + # The default section is intentionally untitled — it renders the I18n fallback + # label in the UI. The name validation is bypassed to match the migration that + # creates the same section for existing installations. + section = UserCustomFieldSection.new( + position: 1, + attribute_order: UserCustomFieldSection::BUILT_IN_ATTRIBUTES + ) + section.save!(validate: false) + end + + def applicable? + UserCustomFieldSection.none? + end + + def not_applicable_message + "Skipping user custom field section as there are already some configured" + end + end +end diff --git a/app/seeders/standard/basic_data_seeder.rb b/app/seeders/standard/basic_data_seeder.rb index 4f81b3066af0..824643889986 100644 --- a/app/seeders/standard/basic_data_seeder.rb +++ b/app/seeders/standard/basic_data_seeder.rb @@ -47,7 +47,8 @@ def data_seeder_classes ::BasicData::WorkflowSeeder, ::BasicData::PrioritySeeder, ::BasicData::SettingSeeder, - ::BasicData::ProjectCustomFieldSectionSeeder + ::BasicData::ProjectCustomFieldSectionSeeder, + ::BasicData::UserCustomFieldSectionSeeder ] end end diff --git a/app/services/user_custom_fields/drop_service.rb b/app/services/user_custom_fields/drop_service.rb index 98967700e126..35e0d35c33f4 100644 --- a/app/services/user_custom_fields/drop_service.rb +++ b/app/services/user_custom_fields/drop_service.rb @@ -39,7 +39,6 @@ def initialize(user:, user_custom_field:) def perform service_call = validate_permissions service_call = perform_drop(service_call, params) if service_call.success? - service_call end @@ -52,44 +51,32 @@ def validate_permissions end def perform_drop(service_call, params) - begin - section_changed, current_section, old_section = check_and_update_section_if_changed(params) - update_position(params[:position]&.to_i) - - service_call.success = true - service_call.result = { section_changed:, current_section:, old_section: } - rescue StandardError => e - service_call.success = false - service_call.errors = e.message - end + section_changed, current_section, old_section = move_to_target_section(params) + current_section.add_to_order(@user_custom_field.column_name, position: params[:position]&.to_i) + service_call.success = true + service_call.result = { section_changed:, current_section: current_section.reload, old_section: } + service_call + rescue StandardError => e + service_call.success = false + service_call.errors = e.message service_call end private - def check_and_update_section_if_changed(params) + def move_to_target_section(params) current_section = @user_custom_field.user_custom_field_section new_section_id = params[:target_id]&.to_i - if current_section.id != new_section_id - old_section = current_section - current_section = update_section(new_section_id) - return [true, current_section, old_section] - end - - [false, current_section, nil] - end + return [false, current_section, nil] if current_section.id == new_section_id - def update_section(new_section_id) + old_section = current_section current_section = UserCustomFieldSection.find(new_section_id) - @user_custom_field.remove_from_list - @user_custom_field.update!(user_custom_field_section: current_section) - current_section - end + old_section.remove_from_order(@user_custom_field.column_name) + @user_custom_field.update!(custom_field_section_id: current_section.id) - def update_position(new_position) - @user_custom_field.insert_at(new_position) + [true, current_section, old_section.reload] end end end diff --git a/app/services/user_custom_fields/move_service.rb b/app/services/user_custom_fields/move_service.rb new file mode 100644 index 000000000000..87e7435728f5 --- /dev/null +++ b/app/services/user_custom_fields/move_service.rb @@ -0,0 +1,49 @@ +# 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 UserCustomFields + class MoveService < ::BaseServices::BaseCallable + def initialize(user:, user_custom_field:) + super() + @user = user + @user_custom_field = user_custom_field + end + + def perform + return ServiceResult.failure(errors: { base: :error_unauthorized }) unless @user.admin? + + section = @user_custom_field.user_custom_field_section + section.move_in_order(@user_custom_field.column_name, params[:move_to]&.to_sym) + ServiceResult.success(result: section) + rescue StandardError => e + ServiceResult.failure(errors: e.message) + end + end +end diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb index c1864a3804f6..7c55671ce628 100644 --- a/app/views/users/_form.html.erb +++ b/app/views/users/_form.html.erb @@ -30,14 +30,12 @@ See COPYRIGHT and LICENSE files for more details. <%= error_messages_for @user %>
- <%= render partial: "users/form/basic_attributes", locals: { f: f, contract: @contract } %> - <% if current_user.admin? %> <%= render partial: "users/form/admin_flag", locals: { f: f } %> <% end %> - <% UserCustomFieldSection.with_custom_fields(UserCustomField.visible(User.current)).each do |section| %> - <%= render Users::Form::CustomFieldSectionComponent.new(section:, fields: section.custom_fields, form: f) %> + <% UserCustomFieldSection.includes(:custom_fields).each do |section| # rubocop:disable Rails/FindEach -- to honor default scope ordering %> + <%= render Users::Form::CustomFieldSectionComponent.new(section:, form: f, contract: @contract, user: @user) %> <% end %> <%= call_hook(:view_users_form, user: @user, form: f) %>
diff --git a/app/views/users/_simple_form.html.erb b/app/views/users/_simple_form.html.erb index 26a69d1dbd24..99cf4573ffe4 100644 --- a/app/views/users/_simple_form.html.erb +++ b/app/views/users/_simple_form.html.erb @@ -28,15 +28,13 @@ See COPYRIGHT and LICENSE files for more details. ++#%>
- <%= render partial: "users/form/basic_attributes", locals: { f: f, contract: @contract } %> - <% if current_user.admin? %> <%= render partial: "users/form/authentication/auth_source", locals: { f: f } %> <%= render partial: "users/form/admin_flag", locals: { f: f } %> <% end %> - <% UserCustomFieldSection.with_custom_fields(UserCustomField.visible(User.current)).each do |section| %> - <%= render Users::Form::CustomFieldSectionComponent.new(section:, fields: section.custom_fields, form: f) %> + <% UserCustomFieldSection.all.order(:position).includes(:custom_fields).each do |section| %> + <%= render Users::Form::CustomFieldSectionComponent.new(section:, form: f, contract: @contract, user: @user) %> <% end %> <%= call_hook(:view_users_form, user: @user, form: f) %>
diff --git a/app/views/users/form/_basic_attributes.html.erb b/app/views/users/form/_basic_attributes.html.erb deleted file mode 100644 index 6abff46f0b15..000000000000 --- a/app/views/users/form/_basic_attributes.html.erb +++ /dev/null @@ -1,39 +0,0 @@ -<%# locals: (f:, contract:) -%> - -<% unless @user.new_record? %> -
- <%= f.text_field :login, - required: true, - disabled: !contract.writable?(:login), - size: 25, - container_class: "-middle" %> -
-<% end %> - -
- <%= f.text_field :firstname, - required: true, - disabled: !contract.writable?(:firstname), - container_class: "-middle" %> -
- -
- <%= f.text_field :lastname, - required: true, - disabled: !contract.writable?(:lastname), - container_class: "-middle" %> -
- -
- <%= f.text_field :mail, - required: true, - disabled: !contract.writable?(:mail), - container_class: "-middle" %> -
- -
- <%= f.select :language, - lang_options_for_select, - prompt: "--- #{t(:actionview_instancetag_blank_option)} ---", - container_class: "-middle" %> -
diff --git a/app/views/users/form/built_in_fields/_firstname.html.erb b/app/views/users/form/built_in_fields/_firstname.html.erb new file mode 100644 index 000000000000..623344bcd621 --- /dev/null +++ b/app/views/users/form/built_in_fields/_firstname.html.erb @@ -0,0 +1,7 @@ +<%# locals: (f:, contract:, user:) -%> +
+ <%= f.text_field :firstname, + required: true, + disabled: !contract.writable?(:firstname), + container_class: "-middle" %> +
diff --git a/app/views/users/form/built_in_fields/_language.html.erb b/app/views/users/form/built_in_fields/_language.html.erb new file mode 100644 index 000000000000..ed8562925f05 --- /dev/null +++ b/app/views/users/form/built_in_fields/_language.html.erb @@ -0,0 +1,7 @@ +<%# locals: (f:, contract:, user:) -%> +
+ <%= f.select :language, + lang_options_for_select, + prompt: "--- #{t(:actionview_instancetag_blank_option)} ---", + container_class: "-middle" %> +
diff --git a/app/views/users/form/built_in_fields/_lastname.html.erb b/app/views/users/form/built_in_fields/_lastname.html.erb new file mode 100644 index 000000000000..e28ad9940a26 --- /dev/null +++ b/app/views/users/form/built_in_fields/_lastname.html.erb @@ -0,0 +1,7 @@ +<%# locals: (f:, contract:, user:) -%> +
+ <%= f.text_field :lastname, + required: true, + disabled: !contract.writable?(:lastname), + container_class: "-middle" %> +
diff --git a/app/views/users/form/built_in_fields/_login.html.erb b/app/views/users/form/built_in_fields/_login.html.erb new file mode 100644 index 000000000000..9630997392b0 --- /dev/null +++ b/app/views/users/form/built_in_fields/_login.html.erb @@ -0,0 +1,10 @@ +<%# locals: (f:, contract:, user:) -%> +<% unless user.new_record? %> +
+ <%= f.text_field :login, + required: true, + disabled: !contract.writable?(:login), + size: 25, + container_class: "-middle" %> +
+<% end %> diff --git a/app/views/users/form/built_in_fields/_mail.html.erb b/app/views/users/form/built_in_fields/_mail.html.erb new file mode 100644 index 000000000000..1d0245ce5226 --- /dev/null +++ b/app/views/users/form/built_in_fields/_mail.html.erb @@ -0,0 +1,7 @@ +<%# locals: (f:, contract:, user:) -%> +
+ <%= f.text_field :mail, + required: true, + disabled: !contract.writable?(:mail), + container_class: "-middle" %> +
diff --git a/config/locales/en.yml b/config/locales/en.yml index 1b625615d494..c68308a8c79c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -719,6 +719,8 @@ en: 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?" placeholder_version_select: "Work package or project selection is required first" calculated_field_not_editable: "Non-editable attribute. This value is calculated automatically." + multi_value_more: + other: "and %{count} more" no_role_assigment: "No role assignment" instructions: is_required: @@ -755,9 +757,6 @@ en: visible_on_user_card: all: "When checked, this field's value is shown in the user information card when hovering over a user across the application." - multi_value_more: - other: "and %{count} more" - tab: no_results_title_text: There are currently no custom fields. no_results_content_text: Create a new custom field @@ -1960,8 +1959,6 @@ en: workspace_type: "Workspace type" project_custom_field: custom_field_section: Section - user_custom_field: - custom_field_section: Section subproject_template_assignment: workspace_type: "Workspace type" project/phase: @@ -2074,6 +2071,8 @@ en: group_detail: parent: "Parent group" organizational_unit: "Organizational unit" + user_custom_field: + custom_field_section: Section user_preference: header_look_and_feel: "Look and feel" header_alerts: "Alerts" @@ -4588,10 +4587,10 @@ en: label_user_named: "User %{name}" label_user_activity_html: "%{value}'s activity" label_user_anonymous: "Anonymous" + label_user_attributes_plural: "User attributes" label_user_menu: "User menu" label_user_new: "New user" label_user_plural: "Users" - label_user_attributes_plural: "User attributes" label_user_search: "Search for user" label_user_settings: "User settings" label_users_settings: "Users settings" @@ -5700,15 +5699,17 @@ en: label: "Side panel" description: "Add all the project attributes in a section inside the right side panel in the project overview." user_attributes: - label_untitled_section: "Untitled section" heading: "User attributes" heading_description: "These user attributes appear on user profiles and information cards. You can add new attributes, group them into sections and re-order them." + label_built_in: "Built-in" + label_built_in_attribute_actions: "Built-in attribute actions" + label_edit_section: "Edit title" label_new_attribute: "User attribute" label_new_section: "Section" - label_edit_section: "Edit title" + label_no_user_custom_fields: "No user attributes defined in this section" label_section_actions: "Section actions" + label_untitled_section: "Untitled section" label_user_custom_field_actions: "User attribute actions" - label_no_user_custom_fields: "No user attributes defined in this section" new: heading: "New attribute" project_initiation_request: diff --git a/config/routes.rb b/config/routes.rb index 0f2bc08c1a15..e726e92cb2e0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -801,6 +801,15 @@ collection do get :new_link end + resources :built_in_attributes, + controller: "/admin/settings/user_custom_field_sections/built_in_attributes", + param: :key, + only: [] do + member do + put :move + put :drop + end + end end resource :working_days_and_hours, controller: "/admin/settings/working_days_and_hours_settings", only: %i[show update] do diff --git a/db/migrate/20260604120001_add_attribute_order_to_custom_field_sections.rb b/db/migrate/20260604120001_add_attribute_order_to_custom_field_sections.rb new file mode 100644 index 000000000000..09ce5f2d2392 --- /dev/null +++ b/db/migrate/20260604120001_add_attribute_order_to_custom_field_sections.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class AddAttributeOrderToCustomFieldSections < ActiveRecord::Migration[8.0] + def up + add_column :custom_field_sections, :attribute_order, :string, array: true, null: false, default: [] + # Populate attribute_order for every section from the existing position ordering. + # COALESCE handles sections that currently have no custom fields (ARRAY_AGG returns NULL). + execute(<<~SQL.squish) + UPDATE custom_field_sections cs + SET attribute_order = COALESCE( + ( + SELECT ARRAY_AGG('cf_' || cf.id::text ORDER BY cf.position_in_custom_field_section) + FROM custom_fields cf + WHERE cf.custom_field_section_id = cs.id + ), + '{}' + ) + WHERE cs.type = 'UserCustomFieldSection' + SQL + + # Prepend the five built-in user attributes to the first UserCustomFieldSection + # (the one with the lowest position). This is idempotent if run more than once. + execute(<<~SQL.squish) + UPDATE custom_field_sections + SET attribute_order = + ARRAY['login', 'firstname', 'lastname', 'mail', 'language'] || attribute_order + WHERE id = ( + SELECT id FROM custom_field_sections + WHERE type = 'UserCustomFieldSection' + ORDER BY position + LIMIT 1 + ) + SQL + end + + def down + # Restore per-section positions from attribute_order in case they have been + # changed already. + # UNNEST … WITH ORDINALITY yields each key with its 1-based rank. + # Built-in keys (login, firstname, …) don't follow the cf_ pattern + # and are filtered out by the regex so the cast to integer is safe. + execute(<<~SQL.squish) + UPDATE custom_fields cf + SET position_in_custom_field_section = positions.ordinal + FROM ( + SELECT + SUBSTRING(key FROM 4)::integer AS cf_id, + ordinal::integer + FROM custom_field_sections cs, + LATERAL UNNEST(cs.attribute_order) WITH ORDINALITY AS unnested(key, ordinal) + WHERE cs.type = 'UserCustomFieldSection' + AND key ~ '^cf_[0-9]+$' + ) positions + WHERE cf.id = positions.cf_id + SQL + + remove_column :custom_field_sections, :attribute_order + end +end diff --git a/spec/components/users/hover_card_component_spec.rb b/spec/components/users/hover_card_component_spec.rb index 440e7efd625e..db9d7b47f0cb 100644 --- a/spec/components/users/hover_card_component_spec.rb +++ b/spec/components/users/hover_card_component_spec.rb @@ -167,48 +167,20 @@ context "when displaying custom fields" do let(:section) { create(:user_custom_field_section) } - let!(:visible_cf) do + let!(:cf) do create(:user_custom_field, :string, name: "Job title", user_custom_field_section: section, visible_on_user_card: true) end - let!(:hidden_cf) do - create(:user_custom_field, :string, name: "Hidden field", - user_custom_field_section: section, visible_on_user_card: false) - end - let!(:admin_only_cf) do - create(:user_custom_field, :string, name: "Secret field", - user_custom_field_section: section, visible_on_user_card: true, admin_only: true) - end let(:user) do create(:user, member_with_permissions: { project => [:view_project] }, - custom_values: [ - build(:custom_value, custom_field: visible_cf, value: "Developer"), - build(:custom_value, custom_field: hidden_cf, value: "Should not appear"), - build(:custom_value, custom_field: admin_only_cf, value: "Top secret") - ]) + custom_values: [build(:custom_value, custom_field: cf, value: "Developer")]) end - it "shows fields flagged as visible_on_user_card" do + it "renders card-visible fields with their value" do expect(page).to have_test_selector("user-hover-card-custom-field", text: "Developer") end - it "does not show fields with visible_on_user_card: false" do - expect(page).to have_no_text("Should not appear") - end - - it "does not show admin_only fields to non-admins" do - expect(page).to have_no_text("Top secret") - end - - context "when current user is admin" do - let(:current_user) { build(:admin) } - - it "shows admin_only fields flagged as visible_on_user_card" do - expect(page).to have_test_selector("user-hover-card-custom-field", text: "Top secret") - end - end - context "with a multi-value field exceeding the display limit" do let!(:multi_cf) do create(:user_custom_field, :multi_list, @@ -223,25 +195,10 @@ custom_values: multi_cf.possible_values.map { |opt| build(:custom_value, custom_field: multi_cf, value: opt) }) end - it "shows the first 3 values" do + it "shows the first 3 values and a count of the rest" do expect(page).to have_test_selector("user-hover-card-custom-field", text: "Ruby, Rails, React") - end - - it "appends the ellipsis wording for the remaining values" do expect(page).to have_test_selector("user-hover-card-custom-field", text: "and 2 more") end end - - context "when the value is blank" do - let(:user) do - create(:user, - member_with_permissions: { project => [:view_project] }, - custom_values: [build(:custom_value, custom_field: visible_cf, value: "")]) - end - - it "does not render the field" do - expect(page).to have_no_test_selector("user-hover-card-custom-field") - end - end end end diff --git a/spec/components/users/profile/attributes_component_spec.rb b/spec/components/users/profile/attributes_component_spec.rb index 867e81742fd8..7c9fae872050 100644 --- a/spec/components/users/profile/attributes_component_spec.rb +++ b/spec/components/users/profile/attributes_component_spec.rb @@ -52,37 +52,15 @@ it { is_expected.to be(true) } end - context "when user has no view_user_email permission" do - it { is_expected.to be(false) } - end - - context "when user has a custom field with a present value" do - let(:custom_field) { create(:user_custom_field, :string) } - let(:user) { create(:user, custom_values: [build(:custom_value, custom_field:, value: "Hello")]) } - - it { is_expected.to be(true) } - end - - context "when user has a custom field with a blank value" do - let(:custom_field) { create(:user_custom_field, :string) } - let(:user) { create(:user, custom_values: [build(:custom_value, custom_field:, value: "")]) } - - it { is_expected.to be(false) } - end - - context "when user has a non-visible custom field with a present value" do - let(:custom_field) { create(:user_custom_field, :string, admin_only: true) } - let(:user) { create(:user, custom_values: [build(:custom_value, custom_field:, value: "Hello")]) } - + context "when user has no view_user_email permission and no filled custom fields" do it { is_expected.to be(false) } end end describe "rendering custom fields" do - let(:section) { create(:user_custom_field_section, name: "Profile info") } - let(:custom_field) { create(:user_custom_field, :string, admin_only:, user_custom_field_section: section) } - let(:admin_only) { false } - let(:user) { create(:user, custom_values: [build(:custom_value, custom_field:, value: "Hello custom field")]) } + let(:section) { create(:user_custom_field_section, name: "Profile info") } + let(:custom_field) { create(:user_custom_field, :string, user_custom_field_section: section) } + let(:user) { create(:user, custom_values: [build(:custom_value, custom_field:, value: "Hello custom field")]) } current_user { build(:admin) } @@ -96,109 +74,34 @@ expect(page).to have_text("Profile info") end - context "when admin_only" do - let(:admin_only) { true } - - context "and current user is admin" do - it "renders the field" do - expect(page).to have_text("Hello custom field") - end - end - - context "and current user is not admin" do - current_user { logged_in_user } - - it "does not render the field" do - expect(page).to have_no_text("Hello custom field") - end - end - end - context "with an untitled section" do - let(:section) do - create(:user_custom_field_section).tap { |s| s.update_column(:name, nil) } - end + let(:section) { create(:user_custom_field_section).tap { |s| s.update_column(:name, nil) } } it "renders the I18n fallback label" do expect(page).to have_text(I18n.t("settings.user_attributes.label_untitled_section")) end end - context "with fields in two sections" do - let(:section_first) { create(:user_custom_field_section, name: "First", position: 1) } - let(:section_second) { create(:user_custom_field_section, name: "Second", position: 2) } - let(:field_in_first) { create(:user_custom_field, :string, name: "In first", user_custom_field_section: section_first) } - let(:field_in_second) { create(:user_custom_field, :string, name: "In second", user_custom_field_section: section_second) } + context "with a multi-select field" do + let(:custom_field) { create(:user_custom_field, :multi_list, user_custom_field_section: section) } let(:user) do - create(:user, custom_values: [ - build(:custom_value, custom_field: field_in_first, value: "Value A"), - build(:custom_value, custom_field: field_in_second, value: "Value B") - ]) + create(:user, custom_values: custom_field.possible_values.first(3).map do |v| + build(:custom_value, custom_field:, value: v) + end) end - it "renders section headings in position order" do - expect(page.text).to match(/First.*Second/m) - end - - it "renders each field under its own section heading" do - expect(page.text).to match(/First.*In first.*Second.*In second/m) - end - end - - context "with multiple fields in one section" do - let(:first_field) do - create(:user_custom_field, :string, name: "Field 1", user_custom_field_section: section, - position_in_custom_field_section: 1) - end - let(:second_field) do - create(:user_custom_field, :string, name: "Field 2", user_custom_field_section: section, - position_in_custom_field_section: 2) - end - let(:user) do - create(:user, custom_values: [ - build(:custom_value, custom_field: first_field, value: "First value"), - build(:custom_value, custom_field: second_field, value: "Second value") - ]) - end - - it "renders fields in position_in_custom_field_section order" do - items = page.all(:test_id, "user-custom-field") - expect(items[0]).to have_text("Field 1") - expect(items[1]).to have_text("Field 2") + it "renders values as a comma-separated list" do + expect(page).to have_text("A, B, C") end end - context "with multi-select and formattable fields" do - let(:list_field) do - create(:user_custom_field, :multi_list, name: "Ze list", user_custom_field_section: section, - position_in_custom_field_section: 2) - end - let(:text_field) do - create(:user_custom_field, :text, name: "A portrait", user_custom_field_section: section, - position_in_custom_field_section: 1) - end - let(:user) do - create(:user, custom_values: [ - build(:custom_value, custom_field: list_field, value: list_field.possible_values[0]), - build(:custom_value, custom_field: list_field, value: list_field.possible_values[1]), - build(:custom_value, custom_field: list_field, value: list_field.possible_values[2]), - build(:custom_value, custom_field: text_field, value: "This is **formatted** text.") - ]) - end - - it "renders multi-select values as a comma-separated list" do - expect(page).to have_text("A, B, C") - end + context "with a formattable text field" do + let(:custom_field) { create(:user_custom_field, :text, user_custom_field_section: section) } + let(:user) { create(:user, custom_values: [build(:custom_value, custom_field:, value: "This is **formatted** text.")]) } - it "renders formattable fields as HTML" do + it "renders the value as HTML" do expect(page).to have_css("strong", text: "formatted") end - - it "orders fields by position_in_custom_field_section, not alphabetically" do - items = page.all(:test_id, "user-custom-field") - expect(items[0]).to have_text("A portrait") - expect(items[1]).to have_text("Ze list") - end end end end diff --git a/spec/features/admin/custom_fields/users/create_in_section_spec.rb b/spec/features/admin/custom_fields/users/create_in_section_spec.rb index 109d67ddbf8f..9c6501ba91ec 100644 --- a/spec/features/admin/custom_fields/users/create_in_section_spec.rb +++ b/spec/features/admin/custom_fields/users/create_in_section_spec.rb @@ -102,7 +102,8 @@ context "without any existing sections" do before do - UserCustomFieldSection.destroy_all + UserCustomField.delete_all + UserCustomFieldSection.delete_all cf_index_page.visit! end diff --git a/spec/features/auth/consent_auth_stage_spec.rb b/spec/features/auth/consent_auth_stage_spec.rb index c1ba31b9f0a6..8bf22545708c 100644 --- a/spec/features/auth/consent_auth_stage_spec.rb +++ b/spec/features/auth/consent_auth_stage_spec.rb @@ -31,6 +31,14 @@ require "spec_helper" RSpec.describe "Authentication Stages" do + # The new-user form renders the built-in attributes (login, mail, …) through + # the user attribute sections, so at least one section must list them. + shared_let(:default_section) do + create(:user_custom_field_section).tap do |s| + s.update_column(:attribute_order, UserCustomFieldSection::BUILT_IN_ATTRIBUTES) + end + end + let(:language) { "en" } let(:user_password) { "bob" * 4 } let(:user) do diff --git a/spec/features/users/create_spec.rb b/spec/features/users/create_spec.rb index 7a546531d57e..0a53164a33bf 100644 --- a/spec/features/users/create_spec.rb +++ b/spec/features/users/create_spec.rb @@ -32,6 +32,11 @@ RSpec.describe "create users" do shared_let(:admin) { create(:admin) } + shared_let(:default_section) do + create(:user_custom_field_section).tap do |s| + s.update_column(:attribute_order, UserCustomFieldSection::BUILT_IN_ATTRIBUTES) + end + end let(:current_user) { admin } let!(:auth_source) { create(:ldap_auth_source) } let(:new_user_page) { Pages::NewUser.new } diff --git a/spec/features/users/edit_users_spec.rb b/spec/features/users/edit_users_spec.rb index abe283b1afec..23c65f6b8784 100644 --- a/spec/features/users/edit_users_spec.rb +++ b/spec/features/users/edit_users_spec.rb @@ -179,6 +179,11 @@ def have_visible_tab(label) context "as global user" do shared_let(:global_manage_user) { create(:user, global_permissions: %i[view_all_principals manage_user create_user]) } let(:current_user) { global_manage_user } + let!(:default_section) do + create(:user_custom_field_section).tap do |s| + s.update_column(:attribute_order, UserCustomFieldSection::BUILT_IN_ATTRIBUTES) + end + end it "can too edit the user" do visit edit_user_path(user) diff --git a/spec/migrations/add_attribute_order_to_custom_field_sections_spec.rb b/spec/migrations/add_attribute_order_to_custom_field_sections_spec.rb new file mode 100644 index 000000000000..dfde5de4e1e7 --- /dev/null +++ b/spec/migrations/add_attribute_order_to_custom_field_sections_spec.rb @@ -0,0 +1,156 @@ +# 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" +require Rails.root.join("db/migrate/20260604120001_add_attribute_order_to_custom_field_sections") + +RSpec.describe AddAttributeOrderToCustomFieldSections, type: :model do + let(:conn) { ActiveRecord::Base.connection } + + # All setup uses raw SQL because attribute_order does not exist during up tests. + + def insert_section(type:, position:) + conn.select_value(<<~SQL.squish) + INSERT INTO custom_field_sections (type, name, position, display_representation, created_at, updated_at) + VALUES ('#{type}', '#{type} #{position}', #{position}, '{}', NOW(), NOW()) + RETURNING id + SQL + end + + def insert_cf(section_id:, position:) + conn.select_value(<<~SQL.squish) + INSERT INTO custom_fields + (type, name, field_format, custom_field_section_id, position_in_custom_field_section, + is_required, is_for_all, searchable, editable, admin_only, multi_value, + created_at, updated_at) + VALUES + ('UserCustomField', 'CF #{position}', 'string', #{section_id}, #{position}, + false, false, false, true, false, false, + NOW(), NOW()) + RETURNING id + SQL + end + + def attribute_order_of(section_id) + conn.select_value("SELECT attribute_order FROM custom_field_sections WHERE id = #{section_id}") + end + + # Before each example the schema is put in the pre-migration state: + # attribute_order removed, position_in_custom_field_section added. + before do + ActiveRecord::Migration.suppress_messages do + conn.add_column :custom_fields, :position_in_custom_field_section, :integer unless + conn.column_exists?(:custom_fields, :position_in_custom_field_section) + conn.remove_column :custom_field_sections, :attribute_order if + conn.column_exists?(:custom_field_sections, :attribute_order) + end + end + + after do + ActiveRecord::Migration.suppress_messages do + unless conn.column_exists?(:custom_field_sections, :attribute_order) + conn.add_column :custom_field_sections, :attribute_order, :string, array: true, null: false, default: [] + end + conn.remove_column :custom_fields, :position_in_custom_field_section if + conn.column_exists?(:custom_fields, :position_in_custom_field_section) + end + end + + describe "up migration" do + it "seeds attribute_order for UserCustomFieldSection from position_in_custom_field_section" do + section_id = insert_section(type: "UserCustomFieldSection", position: 1) + cf1_id = insert_cf(section_id:, position: 1) + cf2_id = insert_cf(section_id:, position: 2) + + ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } + + order = conn.select_value("SELECT attribute_order::text FROM custom_field_sections WHERE id = #{section_id}") + expect(order).to include("cf_#{cf1_id}", "cf_#{cf2_id}") + expect(order.index("cf_#{cf1_id}")).to be < order.index("cf_#{cf2_id}") + end + + it "prepends built-in attributes to the first UserCustomFieldSection only" do + first_id = insert_section(type: "UserCustomFieldSection", position: 1) + second_id = insert_section(type: "UserCustomFieldSection", position: 2) + + ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } + + first_order = conn.select_value("SELECT attribute_order::text FROM custom_field_sections WHERE id = #{first_id}") + second_order = conn.select_value("SELECT attribute_order::text FROM custom_field_sections WHERE id = #{second_id}") + + expect(first_order).to include("login", "firstname", "lastname", "mail", "language") + expect(second_order).not_to include("login") + end + + it "does not seed attribute_order for ProjectCustomFieldSection" do + section_id = insert_section(type: "ProjectCustomFieldSection", position: 1) + insert_cf(section_id:, position: 1) + + ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } + + order = conn.select_value("SELECT attribute_order::text FROM custom_field_sections WHERE id = #{section_id}") + expect(order).to eq("{}") + end + + it "sets attribute_order to built-ins only for a UserCustomFieldSection with no custom fields" do + section_id = insert_section(type: "UserCustomFieldSection", position: 1) + + ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } + + order = conn.select_value("SELECT attribute_order::text FROM custom_field_sections WHERE id = #{section_id}") + expect(order).to include("login") + end + end + + describe "down migration" do + before do + # Put schema back to post-migration-1 state for the down test. + ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } + end + + it "restores position_in_custom_field_section for user CFs from attribute_order" do + section = UserCustomFieldSection.create!(name: "S") + cf1 = create(:user_custom_field, user_custom_field_section: section) + cf2 = create(:user_custom_field, user_custom_field_section: section) + section.update_column(:attribute_order, [cf2.column_name, cf1.column_name]) + + ActiveRecord::Migration.suppress_messages { described_class.migrate(:down) } + + expect(conn.select_value("SELECT position_in_custom_field_section FROM custom_fields WHERE id = #{cf2.id}").to_i).to eq(1) + expect(conn.select_value("SELECT position_in_custom_field_section FROM custom_fields WHERE id = #{cf1.id}").to_i).to eq(2) + end + + it "removes the attribute_order column" do + ActiveRecord::Migration.suppress_messages { described_class.migrate(:down) } + + expect(conn.column_exists?(:custom_field_sections, :attribute_order)).to be false + end + end +end diff --git a/spec/models/user_custom_field_section_spec.rb b/spec/models/user_custom_field_section_spec.rb index 2530dbec3632..f1edab2356fb 100644 --- a/spec/models/user_custom_field_section_spec.rb +++ b/spec/models/user_custom_field_section_spec.rb @@ -28,23 +28,113 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require "spec_helper" +require "rails_helper" RSpec.describe UserCustomFieldSection do - describe ".with_custom_fields" do - let!(:section_a) { create(:user_custom_field_section, name: "Section A", position: 1) } - let!(:section_b) { create(:user_custom_field_section, name: "Section B", position: 2) } - let!(:field_a1) do - create(:user_custom_field, name: "A1", user_custom_field_section: section_a, position_in_custom_field_section: 1) + subject(:section) { create(:user_custom_field_section) } + + describe "constants" do + it "defines the five built-in attribute keys" do + expect(described_class::BUILT_IN_ATTRIBUTES).to eq(%w[login firstname lastname mail language]) + end + end + + describe "#untitled?" do + it { expect(described_class.new(name: nil)).to be_untitled } + it { expect(described_class.new(name: "")).to be_untitled } + it { expect(described_class.new(name: "My section")).not_to be_untitled } + end + + describe "#add_to_order" do + it "appends to the end when no position given" do + section.add_to_order("cf_1") + section.add_to_order("cf_2") + expect(section.reload.attribute_order).to eq(%w[cf_1 cf_2]) + end + + it "inserts at the given 1-indexed position" do + section.update_column(:attribute_order, %w[cf_1 cf_3]) + section.add_to_order("cf_2", position: 2) + expect(section.reload.attribute_order).to eq(%w[cf_1 cf_2 cf_3]) + end + + it "is idempotent — removes prior occurrence before inserting" do + section.update_column(:attribute_order, %w[cf_1 cf_2 cf_3]) + section.add_to_order("cf_2", position: 1) + expect(section.reload.attribute_order).to eq(%w[cf_2 cf_1 cf_3]) + end + end + + describe "#remove_from_order" do + it "removes the key" do + section.update_column(:attribute_order, %w[cf_1 cf_2 cf_3]) + section.remove_from_order("cf_2") + expect(section.reload.attribute_order).to eq(%w[cf_1 cf_3]) end - let!(:field_a2) do - create(:user_custom_field, name: "A2", user_custom_field_section: section_a, position_in_custom_field_section: 2) + + it "is a no-op when key is absent" do + section.update_column(:attribute_order, %w[cf_1]) + expect { section.remove_from_order("cf_99") }.not_to change { section.reload.attribute_order } + end + end + + describe "#move_in_order" do + before { section.update_column(:attribute_order, %w[a b c d]) } + + it "moves to highest (index 0)" do + section.move_in_order("c", :highest) + expect(section.reload.attribute_order).to eq(%w[c a b d]) + end + + it "moves to lowest (last index)" do + section.move_in_order("b", :lowest) + expect(section.reload.attribute_order).to eq(%w[a c d b]) + end + + it "moves higher (one step earlier)" do + section.move_in_order("c", :higher) + expect(section.reload.attribute_order).to eq(%w[a c b d]) + end + + it "moves lower (one step later)" do + section.move_in_order("b", :lower) + expect(section.reload.attribute_order).to eq(%w[a c b d]) + end + + it "clamps: moving highest item higher stays at index 0" do + section.move_in_order("a", :higher) + expect(section.reload.attribute_order).to eq(%w[a b c d]) + end + + it "clamps: moving lowest item lower stays at the end" do + section.move_in_order("d", :lower) + expect(section.reload.attribute_order).to eq(%w[a b c d]) end - let!(:field_b1) do - create(:user_custom_field, name: "B1", user_custom_field_section: section_b, position_in_custom_field_section: 1) + + it "is a no-op when key is absent" do + expect { section.move_in_order("z", :higher) }.not_to change { section.reload.attribute_order } end + end + + describe "#custom_fields_by_key" do + let!(:cf1) { create(:user_custom_field, user_custom_field_section: section) } + let!(:cf2) { create(:user_custom_field, user_custom_field_section: section) } - it "returns only sections that have at least one matching field" do + it "returns a hash keyed by cf_" do + result = section.reload.custom_fields_by_key + expect(result[cf1.column_name]).to eq(cf1) + expect(result[cf2.column_name]).to eq(cf2) + end + end + + describe ".with_custom_fields" do + let!(:section_a) { create(:user_custom_field_section, name: "Section A", position: 1) } + let!(:section_b) { create(:user_custom_field_section, name: "Section B", position: 2) } + let!(:field_a1) { create(:user_custom_field, user_custom_field_section: section_a) } + let!(:field_a2) { create(:user_custom_field, user_custom_field_section: section_a) } + let!(:field_b1) { create(:user_custom_field, user_custom_field_section: section_b) } + + it "returns only sections containing at least one matching field" do result = described_class.with_custom_fields([field_a1.id]) expect(result).to contain_exactly(section_a) end @@ -59,20 +149,147 @@ expect(result).to eq([section_a, section_b]) end - it "loads the matching custom fields on each section" do + it "preloads the matching custom fields on each section" do result = described_class.with_custom_fields([field_a1.id, field_a2.id]) expect(result.first.custom_fields).to contain_exactly(field_a1, field_a2) end + end - it "orders fields within a section by position_in_custom_field_section" do - result = described_class.with_custom_fields([field_a1.id, field_a2.id]) - expect(result.first.custom_fields).to eq([field_a1, field_a2]) + describe ".with_filled_fields_for" do + let(:viewer) { create(:user) } + let!(:section_a) { create(:user_custom_field_section, name: "Section A", position: 1) } + let!(:section_b) { create(:user_custom_field_section, name: "Section B", position: 2) } + # Creation order determines initial attribute_order within each section. + let!(:cf_a1) { create(:user_custom_field, :string, name: "A1", user_custom_field_section: section_a) } + let!(:cf_a2) { create(:user_custom_field, :string, name: "A2", user_custom_field_section: section_a) } + let!(:cf_b1) { create(:user_custom_field, :string, name: "B1", user_custom_field_section: section_b) } + + before { allow(User).to receive(:current).and_return(viewer) } + + context "when user has no custom field values" do + let(:profile) { create(:user) } + + it "returns an empty array" do + expect(described_class.with_filled_fields_for(profile)).to eq([]) + end end - end - describe "#untitled?" do - it { expect(described_class.new(name: nil)).to be_untitled } - it { expect(described_class.new(name: "")).to be_untitled } - it { expect(described_class.new(name: "My section")).not_to be_untitled } + context "when all values are blank" do + let(:profile) { create(:user, custom_values: [build(:custom_value, custom_field: cf_a1, value: "")]) } + + it "returns an empty array" do + expect(described_class.with_filled_fields_for(profile)).to eq([]) + end + end + + context "when user has filled values in multiple sections" do + let(:profile) do + create(:user, custom_values: [ + build(:custom_value, custom_field: cf_a1, value: "hello"), + build(:custom_value, custom_field: cf_b1, value: "world") + ]) + end + + it "returns one [section, cfs] pair per matching section" do + result = described_class.with_filled_fields_for(profile) + expect(result.size).to eq(2) + end + + it "orders pairs by section position" do + result = described_class.with_filled_fields_for(profile) + expect(result.map(&:first)).to eq([section_a, section_b]) + end + + it "includes only the CFs that have a filled value, not all CFs in the section" do + result = described_class.with_filled_fields_for(profile) + section_a_cfs = result.find { |s, _| s == section_a }.last + expect(section_a_cfs).to eq([cf_a1]) + expect(section_a_cfs).not_to include(cf_a2) + end + end + + context "when a section has multiple filled CFs" do + let(:profile) do + create(:user, custom_values: [ + build(:custom_value, custom_field: cf_a1, value: "first"), + build(:custom_value, custom_field: cf_a2, value: "second") + ]) + end + + it "orders CFs within a section by attribute_order" do + result = described_class.with_filled_fields_for(profile) + expect(result.find { |s, _| s == section_a }.last).to eq([cf_a1, cf_a2]) + end + + it "respects a manually reordered attribute_order" do + section_a.update_column(:attribute_order, [cf_a2.column_name, cf_a1.column_name]) + result = described_class.with_filled_fields_for(profile) + expect(result.find { |s, _| s == section_a }.last).to eq([cf_a2, cf_a1]) + end + end + + context "with admin_only CFs" do + let!(:admin_cf) do + create(:user_custom_field, :string, name: "Secret", user_custom_field_section: section_a, admin_only: true) + end + let(:profile) { create(:user, custom_values: [build(:custom_value, custom_field: admin_cf, value: "secret")]) } + + context "when viewer is not an admin" do + it "excludes admin_only CFs from results" do + result = described_class.with_filled_fields_for(profile) + expect(result.flat_map(&:last)).not_to include(admin_cf) + end + + it "excludes the section entirely when all its filled CFs are admin_only" do + expect(described_class.with_filled_fields_for(profile)).to eq([]) + end + end + + context "when viewer is an admin" do + let(:viewer) { create(:admin) } + + it "includes admin_only CFs" do + result = described_class.with_filled_fields_for(profile) + expect(result.flat_map(&:last)).to include(admin_cf) + end + end + end + + context "with visible_on_user_card" do + let!(:card_cf) do + create(:user_custom_field, :string, name: "On card", + user_custom_field_section: section_a, visible_on_user_card: true) + end + let!(:no_card_cf) do + create(:user_custom_field, :string, name: "Off card", + user_custom_field_section: section_a, visible_on_user_card: false) + end + let(:profile) do + create(:user, custom_values: [ + build(:custom_value, custom_field: card_cf, value: "card value"), + build(:custom_value, custom_field: no_card_cf, value: "off-card value") + ]) + end + + it "returns all visible CFs when visible_on_user_card is not specified" do + result = described_class.with_filled_fields_for(profile) + all_cfs = result.flat_map(&:last) + expect(all_cfs).to include(card_cf, no_card_cf) + end + + it "restricts to card-flagged CFs when visible_on_user_card: true" do + result = described_class.with_filled_fields_for(profile, visible_on_user_card: true) + all_cfs = result.flat_map(&:last) + expect(all_cfs).to include(card_cf) + expect(all_cfs).not_to include(no_card_cf) + end + + it "excludes the section when its only filled card-visible CF is filtered out" do + profile_no_card = create(:user, custom_values: [ + build(:custom_value, custom_field: no_card_cf, value: "only off-card") + ]) + expect(described_class.with_filled_fields_for(profile_no_card, visible_on_user_card: true)).to eq([]) + end + end end end diff --git a/spec/models/user_custom_field_spec.rb b/spec/models/user_custom_field_spec.rb index 167d6923ff4d..b5ab70501f0a 100644 --- a/spec/models/user_custom_field_spec.rb +++ b/spec/models/user_custom_field_spec.rb @@ -28,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require "spec_helper" +require "rails_helper" RSpec.describe UserCustomField do describe "validations" do @@ -64,25 +64,38 @@ end end - describe "acts_as_list within a section" do + describe "attribute_order integration" do let(:section) { create(:user_custom_field_section) } - let!(:cf_first) { create(:user_custom_field, user_custom_field_section: section, position_in_custom_field_section: 1) } - let!(:cf_second) { create(:user_custom_field, user_custom_field_section: section, position_in_custom_field_section: 2) } - it "orders fields by position within the section" do - expect(section.custom_fields.to_a).to eq([cf_first, cf_second]) + it "appends itself to the section's attribute_order on creation" do + cf = create(:user_custom_field, user_custom_field_section: section) + expect(section.reload.attribute_order).to include(cf.column_name) + end + + it "orders fields by creation order when multiple are created" do + cf1 = create(:user_custom_field, user_custom_field_section: section) + cf2 = create(:user_custom_field, user_custom_field_section: section) + expect(section.reload.attribute_order).to eq([cf1.column_name, cf2.column_name]) + end + + it "removes itself from the section's attribute_order on destruction" do + cf = create(:user_custom_field, user_custom_field_section: section) + cf.destroy + expect(section.reload.attribute_order).not_to include(cf.column_name) end - it "keeps positions scoped per section" do + it "scopes positions per section (each section is independent)" do other_section = create(:user_custom_field_section) - cf_other = create(:user_custom_field, user_custom_field_section: other_section) - expect(cf_other.position_in_custom_field_section).to eq(1) + cf1 = create(:user_custom_field, user_custom_field_section: section) + cf2 = create(:user_custom_field, user_custom_field_section: other_section) + expect(section.reload.attribute_order).to eq([cf1.column_name]) + expect(other_section.reload.attribute_order).to eq([cf2.column_name]) end end describe ".visible" do let!(:admin_only_cf) { create(:user_custom_field, admin_only: true) } - let!(:public_cf) { create(:user_custom_field, admin_only: false) } + let!(:public_cf) { create(:user_custom_field, admin_only: false) } context "for an admin" do it "returns all custom fields" do @@ -98,11 +111,17 @@ end end - describe "section cascade deletion" do - it "is destroyed when its section is destroyed" do - section = create(:user_custom_field_section) - cf = create(:user_custom_field, user_custom_field_section: section) - expect { section.destroy }.to change { described_class.exists?(cf.id) }.from(true).to(false) + describe "section deletion restriction" do + let(:section) { create(:user_custom_field_section) } + let!(:cf) { create(:user_custom_field, user_custom_field_section: section) } + + it "raises when attempting to destroy a section that still holds custom fields" do + expect { section.destroy! }.to raise_error(ActiveRecord::DeleteRestrictionError) + end + + it "removes itself from the section's attribute_order when destroyed individually" do + cf.destroy! + expect(section.reload.attribute_order).not_to include(cf.column_name) end end end diff --git a/spec/services/user_custom_fields/drop_service_spec.rb b/spec/services/user_custom_fields/drop_service_spec.rb new file mode 100644 index 000000000000..d2d2e9f7afc3 --- /dev/null +++ b/spec/services/user_custom_fields/drop_service_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 UserCustomFields::DropService do + let(:admin) { build(:admin) } + let(:section_a) { create(:user_custom_field_section, position: 1) } + let(:section_b) { create(:user_custom_field_section, position: 2) } + let!(:cf1) { create(:user_custom_field, user_custom_field_section: section_a) } + let!(:cf2) { create(:user_custom_field, user_custom_field_section: section_a) } + + subject(:service) { described_class.new(user: admin, user_custom_field: cf2) } + + describe "reordering within the same section" do + it "moves the field to the target position" do + result = service.call(target_id: section_a.id, position: 1) + expect(result).to be_success + expect(section_a.reload.attribute_order).to eq([cf2.column_name, cf1.column_name]) + end + + it "reports section_changed as false" do + result = service.call(target_id: section_a.id, position: 1) + expect(result.result[:section_changed]).to be(false) + end + end + + describe "moving to a different section" do + it "removes the field from the source section" do + service.call(target_id: section_b.id, position: 1) + expect(section_a.reload.attribute_order).not_to include(cf2.column_name) + end + + it "inserts the field at the target position in the new section" do + service.call(target_id: section_b.id, position: 1) + expect(section_b.reload.attribute_order.first).to eq(cf2.column_name) + end + + it "updates the custom field's section" do + service.call(target_id: section_b.id, position: 1) + expect(cf2.reload.custom_field_section_id).to eq(section_b.id) + end + + it "reports section_changed as true with both sections" do + result = service.call(target_id: section_b.id, position: 1) + expect(result.result[:section_changed]).to be(true) + expect(result.result[:current_section]).to eq(section_b) + expect(result.result[:old_section]).to eq(section_a) + end + end + + context "when user is not admin" do + let(:admin) { build(:user) } + + it "returns a failure" do + result = service.call(target_id: section_a.id, position: 1) + expect(result).not_to be_success + end + end +end diff --git a/spec/services/user_custom_fields/move_service_spec.rb b/spec/services/user_custom_fields/move_service_spec.rb new file mode 100644 index 000000000000..731587e45aa6 --- /dev/null +++ b/spec/services/user_custom_fields/move_service_spec.rb @@ -0,0 +1,74 @@ +# 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 UserCustomFields::MoveService do + let(:admin) { build(:admin) } + let(:section) { create(:user_custom_field_section) } + let!(:cf1) { create(:user_custom_field, user_custom_field_section: section) } + let!(:cf2) { create(:user_custom_field, user_custom_field_section: section) } + let!(:cf3) { create(:user_custom_field, user_custom_field_section: section) } + + subject(:service) { described_class.new(user: admin, user_custom_field: cf2) } + + it "moves the field higher in attribute_order" do + result = service.call(move_to: "higher") + expect(result).to be_success + expect(section.reload.attribute_order.index(cf2.column_name)) + .to be < section.attribute_order.index(cf1.column_name) + end + + it "moves the field lower in attribute_order" do + result = service.call(move_to: "lower") + expect(result).to be_success + expect(section.reload.attribute_order.index(cf2.column_name)) + .to be > section.attribute_order.index(cf3.column_name) + end + + it "moves the field to the top" do + service.call(move_to: "highest") + expect(section.reload.attribute_order.first).to eq(cf2.column_name) + end + + it "moves the field to the bottom" do + service.call(move_to: "lowest") + expect(section.reload.attribute_order.last).to eq(cf2.column_name) + end + + context "when user is not admin" do + let(:admin) { build(:user) } + + it "returns a failure" do + result = service.call(move_to: "higher") + expect(result).not_to be_success + end + end +end From f7eb1044422768409ae0aaae129b0c4254a6a51d Mon Sep 17 00:00:00 2001 From: David F Date: Fri, 5 Jun 2026 13:55:37 +0200 Subject: [PATCH 09/20] Refactor Projects custom field ordering to match Users one. wp/72005 --- .../show_component.rb | 5 +- .../show_component.rb | 2 +- .../custom_field_row_component.rb | 21 +-- .../show_component.html.erb | 16 +- .../show_component.rb | 34 ++-- .../project_custom_fields_controller.rb | 6 +- .../projects/creation_wizard_controller.rb | 6 +- .../project_custom_fields_controller.rb | 2 +- app/models/custom_field_section.rb | 9 + .../project/pdf_export/project_initiation.rb | 3 +- app/models/project_custom_field.rb | 15 +- app/models/project_custom_field_section.rb | 26 ++- app/models/projects/custom_fields.rb | 2 +- app/models/user_custom_field_section.rb | 4 - .../project_custom_fields/drop_service.rb | 41 ++--- .../project_custom_fields/move_service.rb | 49 ++++++ ...custom_field_section_from_custom_fields.rb | 48 ++++++ ...roject_custom_field_section_representer.rb | 2 +- .../schemas/project_schema_representer.rb | 2 +- .../grids/project_attribute_widgets.rb | 6 +- .../side_panel_component.rb | 6 +- .../overview_page/sidebar_spec.rb | 2 +- .../settings/mapping_spec.rb | 2 +- ...m_field_section_from_custom_fields_spec.rb | 128 ++++++++++++++ .../pdf_export/project_initiation_spec.rb | 18 +- .../project_custom_field_section_spec.rb | 161 ++++++++++++++++++ spec/models/project_custom_field_spec.rb | 38 +++++ .../drop_service_spec.rb | 87 ++++++++++ .../move_service_spec.rb | 74 ++++++++ 29 files changed, 708 insertions(+), 107 deletions(-) create mode 100644 app/services/project_custom_fields/move_service.rb create mode 100644 db/migrate/20260605094204_remove_position_in_custom_field_section_from_custom_fields.rb create mode 100644 spec/migrations/remove_position_in_custom_field_section_from_custom_fields_spec.rb create mode 100644 spec/models/project_custom_field_section_spec.rb create mode 100644 spec/services/project_custom_fields/drop_service_spec.rb create mode 100644 spec/services/project_custom_fields/move_service_spec.rb diff --git a/app/components/projects/settings/creation_wizard/project_custom_field_sections/show_component.rb b/app/components/projects/settings/creation_wizard/project_custom_field_sections/show_component.rb index 075ce7dbd376..48fc90689f3e 100644 --- a/app/components/projects/settings/creation_wizard/project_custom_field_sections/show_component.rb +++ b/app/components/projects/settings/creation_wizard/project_custom_field_sections/show_component.rb @@ -42,8 +42,9 @@ def initialize(project:, project_custom_field_section:) @project = project @project_custom_field_section = project_custom_field_section - enabled_custom_field_ids = project.enabled_custom_field_ids - @project_custom_fields = project_custom_field_section.custom_fields.where(id: enabled_custom_field_ids) + enabled_ids = project.enabled_custom_field_ids.to_set + @project_custom_fields = project_custom_field_section.custom_fields_in_order + .select { |cf| enabled_ids.include?(cf.id) } end private diff --git a/app/components/projects/settings/project_custom_field_sections/show_component.rb b/app/components/projects/settings/project_custom_field_sections/show_component.rb index 1a5428694eb8..a5de0894e196 100644 --- a/app/components/projects/settings/project_custom_field_sections/show_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/show_component.rb @@ -41,7 +41,7 @@ def initialize(project:, project_custom_field_section:) @project = project @project_custom_field_section = project_custom_field_section - @project_custom_fields = project_custom_field_section.custom_fields + @project_custom_fields = project_custom_field_section.custom_fields_in_order end private diff --git a/app/components/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb index a0167b860435..c5cb8f75ce93 100644 --- a/app/components/settings/project_custom_field_sections/custom_field_row_component.rb +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb @@ -35,11 +35,12 @@ class CustomFieldRowComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize(project_custom_field:, first_and_last:) - super + def initialize(project_custom_field:, first:, last:) + super() @project_custom_field = project_custom_field - @first_and_last = first_and_last + @first = first + @last = last end private @@ -101,21 +102,11 @@ def delete_action_item(menu) end def first? - @first ||= - if @first_and_last.first - @first_and_last.first == @project_custom_field - else - @project_custom_field.first? - end + @first end def last? - @last ||= - if @first_and_last.last - @first_and_last.last == @project_custom_field - else - @project_custom_field.last? - end + @last end end end diff --git a/app/components/settings/project_custom_field_sections/show_component.html.erb b/app/components/settings/project_custom_field_sections/show_component.html.erb index f080aa2080ce..c499ae729e93 100644 --- a/app/components/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/settings/project_custom_field_sections/show_component.html.erb @@ -52,7 +52,7 @@ menu.with_show_button(icon: "kebab-horizontal", "aria-label": t("settings.project_attributes.label_section_actions"), scheme: :invisible) edit_action_item(menu) move_actions(menu) - if @project_custom_fields.empty? + if @ordered_cfs.empty? delete_action_item(menu) else disabled_delete_action_item(menu) @@ -74,7 +74,7 @@ end end end - if @project_custom_fields.empty? + if @ordered_cfs.empty? component.with_row(data: { "empty-list-item": true }) do flex_layout(align_items: :center, justify_content: :space_between) do |empty_list_container| empty_list_container.with_column(ml: 4, mr: 2) do @@ -102,15 +102,13 @@ end end else - first_and_last = [@project_custom_fields.first, @project_custom_fields.last] - @project_custom_fields.each do |project_custom_field| - component.with_row( - data: draggable_item_config(project_custom_field) - ) do + @ordered_cfs.each_with_index do |cf, index| + component.with_row(data: draggable_item_config(cf)) do render( custom_field_row_component_class.new( - project_custom_field:, - first_and_last: + project_custom_field: cf, + first: index == 0, + last: index == @ordered_cfs.size - 1 ) ) end diff --git a/app/components/settings/project_custom_field_sections/show_component.rb b/app/components/settings/project_custom_field_sections/show_component.rb index d6c5203de2f9..be36c7fecfc7 100644 --- a/app/components/settings/project_custom_field_sections/show_component.rb +++ b/app/components/settings/project_custom_field_sections/show_component.rb @@ -39,7 +39,7 @@ def initialize(project_custom_field_section:, first_and_last: []) super @project_custom_field_section = project_custom_field_section - @project_custom_fields = project_custom_field_section.custom_fields + @ordered_cfs = project_custom_field_section.custom_fields_in_order @first_and_last = first_and_last end @@ -57,9 +57,9 @@ def wrapper_uniq_by def drag_and_drop_target_config { generic_drag_and_drop_target: "container", - "target-container-accessor": ".Box > ul", # the accessor of the container that contains the drag and drop items - "target-id": @project_custom_field_section.id, # the id of the target - "target-allowed-drag-type": "custom-field" # the type of dragged items which are allowed to be dropped in this target + "target-container-accessor": ".Box > ul", + "target-id": @project_custom_field_section.id, + "target-allowed-drag-type": "custom-field" } end @@ -132,21 +132,23 @@ def delete_action_item(menu) end def first? - @first ||= - if @first_and_last.first - @first_and_last.first == @project_custom_field_section - else - @project_custom_field_section.first? - end + return @first unless @first.nil? + + @first = if @first_and_last.first + @first_and_last.first == @project_custom_field_section + else + @project_custom_field_section.first? + end end def last? - @last ||= - if @first_and_last.last - @first_and_last.last == @project_custom_field_section - else - @project_custom_field_section.last? - end + return @last unless @last.nil? + + @last = if @first_and_last.last + @first_and_last.last == @project_custom_field_section + else + @project_custom_field_section.last? + end end def action_menu_item_for_custom_field_format(menu, format) diff --git a/app/controllers/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb index 8b88b00eb250..665dd3ae5192 100644 --- a/app/controllers/admin/settings/project_custom_fields_controller.rb +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -143,8 +143,8 @@ def unlink end def move - result = CustomFields::UpdateService.new(user: current_user, model: @custom_field).call( - move_to: params[:move_to]&.to_sym + result = ProjectCustomFields::MoveService.new(user: current_user, project_custom_field: @custom_field).call( + move_to: params.expect(:move_to) ) if result.success? @@ -262,7 +262,7 @@ def find_custom_field_projects_to_link end def find_custom_field - @custom_field = ProjectCustomField.find(params[:id]) + @custom_field = ProjectCustomField.find(params.expect(:id)) end def drop_success_streams(call) diff --git a/app/controllers/projects/creation_wizard_controller.rb b/app/controllers/projects/creation_wizard_controller.rb index f444065596b4..fb76b36dc36d 100644 --- a/app/controllers/projects/creation_wizard_controller.rb +++ b/app/controllers/projects/creation_wizard_controller.rb @@ -101,10 +101,8 @@ def load_sections_and_fields .where(creation_wizard: true) .select(:custom_field_id) - @custom_fields_by_section = @project - .available_custom_fields - .where(id: enabled_in_wizard_ids) - .group_by(&:project_custom_field_section) + scoped_fields = @project.available_custom_fields.where(id: enabled_in_wizard_ids) + @custom_fields_by_section = ProjectCustomFieldSection.grouped_in_order(scoped_fields).to_h end def find_current_section diff --git a/app/controllers/projects/settings/project_custom_fields_controller.rb b/app/controllers/projects/settings/project_custom_fields_controller.rb index 4150b2da595d..162553b4cb1b 100644 --- a/app/controllers/projects/settings/project_custom_fields_controller.rb +++ b/app/controllers/projects/settings/project_custom_fields_controller.rb @@ -91,7 +91,7 @@ def eager_load_project_custom_field_data .includes(:custom_fields) .merge(ProjectCustomField.visible) .group(:id, "custom_fields.id") - .order(:position, :position_in_custom_field_section) + .order(:position) end def set_project_custom_field_section diff --git a/app/models/custom_field_section.rb b/app/models/custom_field_section.rb index bd8ddac1e85f..de00ff2c6e1b 100644 --- a/app/models/custom_field_section.rb +++ b/app/models/custom_field_section.rb @@ -66,6 +66,15 @@ def remove_from_order(key) end # move_to: :highest | :higher | :lower | :lowest + def custom_fields_by_key + custom_fields.index_by(&:column_name) + end + + def custom_fields_in_order + cf_by_key = custom_fields_by_key + attribute_order.filter_map { |key| cf_by_key[key] } + end + def move_in_order(key, move_to) idx = attribute_order.index(key) return unless idx diff --git a/app/models/project/pdf_export/project_initiation.rb b/app/models/project/pdf_export/project_initiation.rb index cf39ae9ad79b..76cbc488945f 100644 --- a/app/models/project/pdf_export/project_initiation.rb +++ b/app/models/project/pdf_export/project_initiation.rb @@ -183,9 +183,10 @@ def enabled_in_wizard_ids .select(:custom_field_id) end - def collect_custom_fields_data + def collect_custom_fields_data # rubocop:disable Metrics/AbcSize project.available_custom_fields .where(id: enabled_in_wizard_ids) + .order("custom_fields.id") .group_by(&:project_custom_field_section) .map do |section, custom_fields| { diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index ab4797589d29..9accd0b56aa0 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -35,12 +35,12 @@ class ProjectCustomField < CustomField dependent: :destroy, inverse_of: :project_custom_field has_many :projects, through: :project_custom_field_project_mappings - acts_as_list column: :position_in_custom_field_section, scope: [:custom_field_section_id] + validates :custom_field_section_id, presence: true + after_create_commit :add_to_section_order + after_destroy_commit :remove_from_section_order after_save :activate_required_field_in_all_projects, if: :is_for_all? - validates :custom_field_section_id, presence: true - # Relevant for user fields to allow membership assignment has_one :custom_fields_role, foreign_key: :custom_field_id, dependent: :destroy, inverse_of: :custom_field has_one :role, through: :custom_fields_role @@ -157,4 +157,13 @@ def activate_required_field_in_all_projects unique_by: %i[custom_field_id project_id] ) end + + def add_to_section_order + project_custom_field_section.add_to_order(column_name) + end + + def remove_from_section_order + section = project_custom_field_section + section.remove_from_order(column_name) unless section.nil? || section.frozen? + end end diff --git a/app/models/project_custom_field_section.rb b/app/models/project_custom_field_section.rb index a0f019139557..e2f6c9a2f879 100644 --- a/app/models/project_custom_field_section.rb +++ b/app/models/project_custom_field_section.rb @@ -29,9 +29,33 @@ #++ class ProjectCustomFieldSection < CustomFieldSection - has_many :custom_fields, -> { order(position_in_custom_field_section: :asc) }, + has_many :custom_fields, class_name: "ProjectCustomField", dependent: :destroy, foreign_key: :custom_field_section_id, inverse_of: :project_custom_field_section + + # Returns [[section, [ordered_cfs]], ...] for the given project's available, + # visible custom fields. Sections are ordered by position, fields within each + # section by attribute_order. Two SQL queries: one for the CFs, one for the sections. + def self.with_available_fields_for(project) + grouped_in_order(project.available_custom_fields) + end + + # Groups the given custom fields by their section, with sections ordered by + # position and the fields within each section ordered by attribute_order. + # Sections that end up with no ordered field are dropped. + # Returns [[section, [ordered_cfs]], ...]. Two SQL queries: the given relation + # is materialised once, then the relevant sections are loaded. + def self.grouped_in_order(custom_fields) + cfs_by_section_id = custom_fields.group_by(&:custom_field_section_id) + + where(id: cfs_by_section_id.keys) + .order(:position) + .filter_map do |section| + cf_by_key = cfs_by_section_id[section.id].index_by(&:column_name) + ordered = section.attribute_order.filter_map { |key| cf_by_key[key] } + [section, ordered] if ordered.any? + end + end end diff --git a/app/models/projects/custom_fields.rb b/app/models/projects/custom_fields.rb index 6716e5651b6e..c22b8b731d98 100644 --- a/app/models/projects/custom_fields.rb +++ b/app/models/projects/custom_fields.rb @@ -62,7 +62,7 @@ def available_custom_fields def all_available_custom_fields ProjectCustomField .includes(:project_custom_field_section) - .order("custom_field_sections.position", :position_in_custom_field_section) + .order("custom_field_sections.position") end def all_visible_custom_fields diff --git a/app/models/user_custom_field_section.rb b/app/models/user_custom_field_section.rb index 0261db07e6c2..3227c021968f 100644 --- a/app/models/user_custom_field_section.rb +++ b/app/models/user_custom_field_section.rb @@ -78,8 +78,4 @@ def self.with_filled_fields_for(user, visible_on_user_card: nil) # rubocop:disab def untitled? name.blank? end - - def custom_fields_by_key - custom_fields.index_by(&:column_name) - end end diff --git a/app/services/project_custom_fields/drop_service.rb b/app/services/project_custom_fields/drop_service.rb index c3501e7539af..1faecb20fbc7 100644 --- a/app/services/project_custom_fields/drop_service.rb +++ b/app/services/project_custom_fields/drop_service.rb @@ -39,7 +39,6 @@ def initialize(user:, project_custom_field:) def perform service_call = validate_permissions service_call = perform_drop(service_call, params) if service_call.success? - service_call end @@ -52,44 +51,32 @@ def validate_permissions end def perform_drop(service_call, params) - begin - section_changed, current_section, old_section = check_and_update_section_if_changed(params) - update_position(params[:position]&.to_i) - - service_call.success = true - service_call.result = { section_changed:, current_section:, old_section: } - rescue StandardError => e - service_call.success = false - service_call.errors = e.message - end + section_changed, current_section, old_section = move_to_target_section(params) + current_section.add_to_order(@project_custom_field.column_name, position: params[:position]&.to_i) + service_call.success = true + service_call.result = { section_changed:, current_section: current_section.reload, old_section: } + service_call + rescue StandardError => e + service_call.success = false + service_call.errors = e.message service_call end private - def check_and_update_section_if_changed(params) + def move_to_target_section(params) current_section = @project_custom_field.project_custom_field_section new_section_id = params[:target_id]&.to_i - if current_section.id != new_section_id - old_section = current_section - current_section = update_section(new_section_id) - return [true, current_section, old_section] - end - - [false, current_section, nil] - end + return [false, current_section, nil] if current_section.id == new_section_id - def update_section(new_section_id) + old_section = current_section current_section = ProjectCustomFieldSection.find(new_section_id) - @project_custom_field.remove_from_list - @project_custom_field.update(project_custom_field_section: current_section) - current_section - end + old_section.remove_from_order(@project_custom_field.column_name) + @project_custom_field.update!(custom_field_section_id: current_section.id) - def update_position(new_position) - @project_custom_field.insert_at(new_position) + [true, current_section, old_section.reload] end end end diff --git a/app/services/project_custom_fields/move_service.rb b/app/services/project_custom_fields/move_service.rb new file mode 100644 index 000000000000..aa4d63f1d571 --- /dev/null +++ b/app/services/project_custom_fields/move_service.rb @@ -0,0 +1,49 @@ +# 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 ProjectCustomFields + class MoveService < ::BaseServices::BaseCallable + def initialize(user:, project_custom_field:) + super() + @user = user + @project_custom_field = project_custom_field + end + + def perform + return ServiceResult.failure(errors: { base: :error_unauthorized }) unless @user.admin? + + section = @project_custom_field.project_custom_field_section + section.move_in_order(@project_custom_field.column_name, params[:move_to]&.to_sym) + ServiceResult.success(result: section) + rescue StandardError => e + ServiceResult.failure(errors: e.message) + end + end +end diff --git a/db/migrate/20260605094204_remove_position_in_custom_field_section_from_custom_fields.rb b/db/migrate/20260605094204_remove_position_in_custom_field_section_from_custom_fields.rb new file mode 100644 index 000000000000..04d1d461b2f7 --- /dev/null +++ b/db/migrate/20260605094204_remove_position_in_custom_field_section_from_custom_fields.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class RemovePositionInCustomFieldSectionFromCustomFields < ActiveRecord::Migration[8.0] + def up + return unless column_exists?(:custom_fields, :position_in_custom_field_section) + + # Populate attribute_order for every section from the existing position ordering. + # COALESCE handles sections that currently have no custom fields (ARRAY_AGG returns NULL). + execute(<<~SQL.squish) + UPDATE custom_field_sections cs + SET attribute_order = COALESCE( + ( + SELECT ARRAY_AGG('cf_' || cf.id::text ORDER BY cf.position_in_custom_field_section) + FROM custom_fields cf + WHERE cf.custom_field_section_id = cs.id + ), + '{}' + ) + WHERE cs.type = 'ProjectCustomFieldSection' + SQL + + remove_column :custom_fields, :position_in_custom_field_section + end + + def down + add_column :custom_fields, :position_in_custom_field_section, :integer + + # Restore per-section positions from attribute_order in case they have been + # changed already. + # UNNEST … WITH ORDINALITY yields each key with its 1-based rank. + # Built-in keys (login, firstname, …) don't follow the cf_ pattern + # and are filtered out by the regex so the cast to integer is safe. + execute(<<~SQL.squish) + UPDATE custom_fields cf + SET position_in_custom_field_section = positions.ordinal + FROM ( + SELECT + SUBSTRING(key FROM 4)::integer AS cf_id, + ordinal::integer + FROM custom_field_sections cs, + LATERAL UNNEST(cs.attribute_order) WITH ORDINALITY AS unnested(key, ordinal) + WHERE cs.type = 'ProjectCustomFieldSection' + AND key ~ '^cf_[0-9]+$' + ) positions + WHERE cf.id = positions.cf_id + SQL + end +end diff --git a/lib/api/v3/projects/schemas/project_custom_field_section_representer.rb b/lib/api/v3/projects/schemas/project_custom_field_section_representer.rb index 0f86302c85bf..7b6223d83520 100644 --- a/lib/api/v3/projects/schemas/project_custom_field_section_representer.rb +++ b/lib/api/v3/projects/schemas/project_custom_field_section_representer.rb @@ -49,7 +49,7 @@ def _type delegate :id, :name, to: :represented def attributes - represented.custom_fields.map do |cf| + represented.custom_fields_in_order.map do |cf| convert_property(cf.attribute_name) end end diff --git a/lib/api/v3/projects/schemas/project_schema_representer.rb b/lib/api/v3/projects/schemas/project_schema_representer.rb index dd9e35ece611..13a2f45efa86 100644 --- a/lib/api/v3/projects/schemas/project_schema_representer.rb +++ b/lib/api/v3/projects/schemas/project_schema_representer.rb @@ -138,7 +138,7 @@ def project_custom_field_sections .includes(:custom_fields) .merge(ProjectCustomField.visible(current_user)) .group(:id, "custom_fields.id") - .order(:position, :position_in_custom_field_section) + .order(:position) end def section_representation(section) diff --git a/modules/grids/app/components/grids/project_attribute_widgets.rb b/modules/grids/app/components/grids/project_attribute_widgets.rb index 33f5f22cc039..f7f054ca58e9 100644 --- a/modules/grids/app/components/grids/project_attribute_widgets.rb +++ b/modules/grids/app/components/grids/project_attribute_widgets.rb @@ -55,9 +55,9 @@ def before_render def available_project_attributes_grouped_by_section @available_project_attributes_grouped_by_section ||= - @project.available_custom_fields - .group_by(&:project_custom_field_section) - .select { |section, _| section.shown_in_overview_main_area? } + ProjectCustomFieldSection + .with_available_fields_for(@project) + .select { |section, _| section.shown_in_overview_main_area? } end end end diff --git a/modules/overviews/app/components/overviews/project_custom_fields/side_panel_component.rb b/modules/overviews/app/components/overviews/project_custom_fields/side_panel_component.rb index 7311f2431462..b87087a781ca 100644 --- a/modules/overviews/app/components/overviews/project_custom_fields/side_panel_component.rb +++ b/modules/overviews/app/components/overviews/project_custom_fields/side_panel_component.rb @@ -50,9 +50,9 @@ def render? def available_project_custom_fields_grouped_by_section @available_project_custom_fields_grouped_by_section ||= - @project.available_custom_fields - .group_by(&:project_custom_field_section) - .select { |section, _| section.shown_in_overview_sidebar? } + ProjectCustomFieldSection + .with_available_fields_for(@project) + .select { |section, _| section.shown_in_overview_sidebar? } end end end diff --git a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb index 93e74f103500..d3cd3812b9b1 100644 --- a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb @@ -116,7 +116,7 @@ end end - string_project_custom_field.move_to_bottom + section_for_input_fields.reload.move_in_order(string_project_custom_field.column_name, :lowest) overview_page.visit_page diff --git a/spec/features/projects/project_custom_fields/settings/mapping_spec.rb b/spec/features/projects/project_custom_fields/settings/mapping_spec.rb index 6042ee244056..420aa87c3d4c 100644 --- a/spec/features/projects/project_custom_fields/settings/mapping_spec.rb +++ b/spec/features/projects/project_custom_fields/settings/mapping_spec.rb @@ -401,7 +401,7 @@ expect(custom_fields[2].text).to include("Int field") end - boolean_project_custom_field.move_to_bottom + section_for_input_fields.reload.move_in_order(boolean_project_custom_field.column_name, :lowest) visit project_settings_project_custom_fields_path(project) diff --git a/spec/migrations/remove_position_in_custom_field_section_from_custom_fields_spec.rb b/spec/migrations/remove_position_in_custom_field_section_from_custom_fields_spec.rb new file mode 100644 index 000000000000..75a72adda538 --- /dev/null +++ b/spec/migrations/remove_position_in_custom_field_section_from_custom_fields_spec.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. +#++ + +require "spec_helper" +require Rails.root.join("db/migrate/20260605094204_remove_position_in_custom_field_section_from_custom_fields") + +RSpec.describe RemovePositionInCustomFieldSectionFromCustomFields, type: :model do + let(:conn) { ActiveRecord::Base.connection } + + def position_of(cf) + conn.select_value("SELECT position_in_custom_field_section FROM custom_fields WHERE id = #{cf.id}")&.to_i + end + + describe "up migration" do + let(:section) { create(:project_custom_field_section) } + let!(:cf1) { create(:project_custom_field, project_custom_field_section: section) } + let!(:cf2) { create(:project_custom_field, project_custom_field_section: section) } + let!(:cf3) { create(:project_custom_field, project_custom_field_section: section) } + + before do + # Put schema in pre-migration state: add the column and set position values. + ActiveRecord::Migration.suppress_messages do + conn.add_column :custom_fields, :position_in_custom_field_section, :integer + end + conn.execute("UPDATE custom_fields SET position_in_custom_field_section = 1 WHERE id = #{cf1.id}") + conn.execute("UPDATE custom_fields SET position_in_custom_field_section = 2 WHERE id = #{cf2.id}") + conn.execute("UPDATE custom_fields SET position_in_custom_field_section = 3 WHERE id = #{cf3.id}") + section.update_column(:attribute_order, []) + end + + after do + ActiveRecord::Migration.suppress_messages do + conn.remove_column :custom_fields, :position_in_custom_field_section if + conn.column_exists?(:custom_fields, :position_in_custom_field_section) + end + end + + it "seeds attribute_order from position_in_custom_field_section" do + ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } + + expect(section.reload.attribute_order).to eq([cf1.column_name, cf2.column_name, cf3.column_name]) + end + + it "removes the position_in_custom_field_section column" do + ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } + + expect(conn.column_exists?(:custom_fields, :position_in_custom_field_section)).to be false + end + + it "does not seed attribute_order for UserCustomFieldSection" do + user_section = create(:user_custom_field_section) + cf_user = create(:user_custom_field, user_custom_field_section: user_section) + conn.execute("UPDATE custom_fields SET position_in_custom_field_section = 1 WHERE id = #{cf_user.id}") + user_section.update_column(:attribute_order, []) + + ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } + + expect(user_section.reload.attribute_order).to be_empty + end + end + + describe "down migration" do + let(:section) { create(:project_custom_field_section) } + let!(:cf1) { create(:project_custom_field, project_custom_field_section: section) } + let!(:cf2) { create(:project_custom_field, project_custom_field_section: section) } + + before do + section.update_column(:attribute_order, [cf2.column_name, cf1.column_name]) + end + + after do + ActiveRecord::Migration.suppress_messages do + conn.remove_column :custom_fields, :position_in_custom_field_section if + conn.column_exists?(:custom_fields, :position_in_custom_field_section) + end + end + + it "adds position_in_custom_field_section back" do + ActiveRecord::Migration.suppress_messages { described_class.migrate(:down) } + + expect(conn.column_exists?(:custom_fields, :position_in_custom_field_section)).to be true + end + + it "restores positions from attribute_order" do + ActiveRecord::Migration.suppress_messages { described_class.migrate(:down) } + + expect(position_of(cf2)).to eq(1) + expect(position_of(cf1)).to eq(2) + end + + it "does not touch user section custom fields" do + user_section = create(:user_custom_field_section) + cf_user = create(:user_custom_field, user_custom_field_section: user_section) + user_section.update_column(:attribute_order, [cf_user.column_name]) + + ActiveRecord::Migration.suppress_messages { described_class.migrate(:down) } + + expect(position_of(cf_user)).to be_nil + end + end +end diff --git a/spec/models/project/pdf_export/project_initiation_spec.rb b/spec/models/project/pdf_export/project_initiation_spec.rb index 24175cb77a1b..391a269b57ef 100644 --- a/spec/models/project/pdf_export/project_initiation_spec.rb +++ b/spec/models/project/pdf_export/project_initiation_spec.rb @@ -127,19 +127,19 @@ "The description of the project", "Section A", - link_cf.name, "https://www.example.com", + bool_cf.name, "Yes", text_cf.name, "Some ", "long", " text", string_cf.name, "Some small text", - bool_cf.name, "Yes", + link_cf.name, "https://www.example.com", unset_string_cf.name, "–", "Section B", version_cf.name, system_version, "#{version_cf.name} comment", "Comment visible to members", user_cf.name, "Other User", - date_cf.name, format_date(Time.zone.today), - float_cf.name, "4.5", int_cf.name, "5", + float_cf.name, "4.5", + date_cf.name, format_date(Time.zone.today), "1/1", heading, project.name ].join(" ") @@ -160,21 +160,21 @@ "The description of the project", "Section A", + bool_cf.name, "Yes", + text_cf.name, "Some ", "long", " text", + string_cf.name, "Some small text", hidden_cf.name, "hidden", "#{hidden_cf.name} comment", "Comment visible to admins", link_cf.name, "https://www.example.com", - text_cf.name, "Some ", "long", " text", - string_cf.name, "Some small text", - bool_cf.name, "Yes", unset_string_cf.name, "–", "Section B", version_cf.name, system_version, "#{version_cf.name} comment", "Comment visible to members", user_cf.name, "Other User", - date_cf.name, format_date(Time.zone.today), - float_cf.name, "4.5", int_cf.name, "5", + float_cf.name, "4.5", + date_cf.name, format_date(Time.zone.today), "1/1", heading, project.name ].join(" ") diff --git a/spec/models/project_custom_field_section_spec.rb b/spec/models/project_custom_field_section_spec.rb new file mode 100644 index 000000000000..f7a79390a942 --- /dev/null +++ b/spec/models/project_custom_field_section_spec.rb @@ -0,0 +1,161 @@ +# 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 ProjectCustomFieldSection do + describe ".with_available_fields_for" do + let(:project) { create(:project) } + let(:other_project) { create(:project) } + # Admin sees all non-admin-only CFs regardless of project membership. + let(:admin) { create(:admin) } + + let!(:section_a) { create(:project_custom_field_section, name: "Section A", position: 1) } + let!(:section_b) { create(:project_custom_field_section, name: "Section B", position: 2) } + # Creation order determines initial attribute_order within each section. + let!(:cf_a1) do + create(:string_project_custom_field, name: "A1", project_custom_field_section: section_a, projects: [project]) + end + let!(:cf_a2) do + create(:string_project_custom_field, name: "A2", project_custom_field_section: section_a, projects: [project]) + end + let!(:cf_b1) do + create(:string_project_custom_field, name: "B1", project_custom_field_section: section_b, projects: [project]) + end + + before { allow(User).to receive(:current).and_return(admin) } + + it "returns one [section, cfs] pair per section that has available CFs" do + result = described_class.with_available_fields_for(project) + expect(result.size).to eq(2) + end + + it "orders pairs by section position" do + result = described_class.with_available_fields_for(project) + expect(result.map(&:first)).to eq([section_a, section_b]) + end + + it "orders CFs within a section by attribute_order" do + result = described_class.with_available_fields_for(project) + expect(result.find { |s, _| s == section_a }.last).to eq([cf_a1, cf_a2]) + end + + it "respects a manually reordered attribute_order" do + section_a.update_column(:attribute_order, [cf_a2.column_name, cf_a1.column_name]) + result = described_class.with_available_fields_for(project) + expect(result.find { |s, _| s == section_a }.last).to eq([cf_a2, cf_a1]) + end + + it "excludes CFs not mapped to the project" do + unmapped = create(:string_project_custom_field, name: "Unmapped", + project_custom_field_section: section_a, + projects: [other_project]) + result = described_class.with_available_fields_for(project) + expect(result.flat_map(&:last)).not_to include(unmapped) + end + + it "excludes sections that have no CFs available for the project" do + empty_section = create(:project_custom_field_section, name: "Empty", position: 3) + create(:string_project_custom_field, name: "Other", + project_custom_field_section: empty_section, + projects: [other_project]) + result = described_class.with_available_fields_for(project) + expect(result.map(&:first)).not_to include(empty_section) + end + + it "returns an empty array when the project has no available CFs" do + expect(described_class.with_available_fields_for(other_project)).to eq([]) + end + + context "with admin_only CFs" do + let!(:admin_cf) do + create(:string_project_custom_field, name: "Secret", + project_custom_field_section: section_a, + projects: [project], + admin_only: true) + end + + context "when viewer is not an admin" do + let(:non_admin) do + create(:user, member_with_permissions: { project => [:select_project_custom_fields] }) + end + + before { allow(User).to receive(:current).and_return(non_admin) } + + it "excludes admin_only CFs" do + result = described_class.with_available_fields_for(project) + expect(result.flat_map(&:last)).not_to include(admin_cf) + end + + it "still returns non-admin CFs in the same section" do + result = described_class.with_available_fields_for(project) + expect(result.flat_map(&:last)).to include(cf_a1, cf_a2) + end + end + + context "when viewer is an admin" do + it "includes admin_only CFs" do + result = described_class.with_available_fields_for(project) + expect(result.flat_map(&:last)).to include(admin_cf) + end + end + end + end + + describe ".grouped_in_order" do + let!(:section_a) { create(:project_custom_field_section, name: "Section A", position: 1) } + let!(:section_b) { create(:project_custom_field_section, name: "Section B", position: 2) } + let!(:cf_a1) { create(:string_project_custom_field, name: "A1", project_custom_field_section: section_a) } + let!(:cf_a2) { create(:string_project_custom_field, name: "A2", project_custom_field_section: section_a) } + let!(:cf_b1) { create(:string_project_custom_field, name: "B1", project_custom_field_section: section_b) } + + it "orders sections by position and fields by attribute_order" do + result = described_class.grouped_in_order(ProjectCustomField.where(id: [cf_a1, cf_a2, cf_b1])) + expect(result.map(&:first)).to eq([section_a, section_b]) + expect(result.find { |s, _| s == section_a }.last).to eq([cf_a1, cf_a2]) + end + + it "respects a manually reordered attribute_order" do + section_a.update_column(:attribute_order, [cf_a2.column_name, cf_a1.column_name]) + result = described_class.grouped_in_order(ProjectCustomField.where(id: [cf_a1, cf_a2])) + expect(result.find { |s, _| s == section_a }.last).to eq([cf_a2, cf_a1]) + end + + it "restricts to the given custom fields" do + result = described_class.grouped_in_order(ProjectCustomField.where(id: [cf_a1])) + expect(result.map(&:first)).to eq([section_a]) + expect(result.flat_map(&:last)).to eq([cf_a1]) + end + + it "returns an empty array for an empty relation" do + expect(described_class.grouped_in_order(ProjectCustomField.none)).to eq([]) + end + end +end diff --git a/spec/models/project_custom_field_spec.rb b/spec/models/project_custom_field_spec.rb index 314d25e6add8..5b08e8bb27c3 100644 --- a/spec/models/project_custom_field_spec.rb +++ b/spec/models/project_custom_field_spec.rb @@ -104,6 +104,15 @@ end end + context "when deleting a project custom field via section cascade" do + let(:section) { create(:project_custom_field_section) } + let!(:cf) { create(:project_custom_field, project_custom_field_section: section) } + + it "does not raise an error when the section is destroyed with its fields" do + expect { section.destroy! }.not_to raise_error + end + end + context "when deleting a project custom field" do let!(:project_custom_field) { create(:string_project_custom_field) } let!(:project) do @@ -122,6 +131,35 @@ end end + describe "attribute_order integration" do + let(:section) { create(:project_custom_field_section) } + + it "appends itself to the section's attribute_order on creation" do + cf = create(:project_custom_field, project_custom_field_section: section) + expect(section.reload.attribute_order).to include(cf.column_name) + end + + it "orders fields by creation order when multiple are created" do + cf1 = create(:project_custom_field, project_custom_field_section: section) + cf2 = create(:project_custom_field, project_custom_field_section: section) + expect(section.reload.attribute_order).to eq([cf1.column_name, cf2.column_name]) + end + + it "removes itself from the section's attribute_order on destruction" do + cf = create(:project_custom_field, project_custom_field_section: section) + cf.destroy + expect(section.reload.attribute_order).not_to include(cf.column_name) + end + + it "scopes positions per section (each section is independent)" do + other_section = create(:project_custom_field_section) + cf1 = create(:project_custom_field, project_custom_field_section: section) + cf2 = create(:project_custom_field, project_custom_field_section: other_section) + expect(section.reload.attribute_order).to eq([cf1.column_name]) + expect(other_section.reload.attribute_order).to eq([cf2.column_name]) + end + end + describe ".visible" do shared_let(:invisible_cf) { create(:string_project_custom_field, admin_only: true) } shared_let(:other_cf) { create(:string_project_custom_field) } diff --git a/spec/services/project_custom_fields/drop_service_spec.rb b/spec/services/project_custom_fields/drop_service_spec.rb new file mode 100644 index 000000000000..4e6eebc389bf --- /dev/null +++ b/spec/services/project_custom_fields/drop_service_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 ProjectCustomFields::DropService do + let(:admin) { build(:admin) } + let(:section_a) { create(:project_custom_field_section, position: 1) } + let(:section_b) { create(:project_custom_field_section, position: 2) } + let!(:cf1) { create(:project_custom_field, project_custom_field_section: section_a) } + let!(:cf2) { create(:project_custom_field, project_custom_field_section: section_a) } + + subject(:service) { described_class.new(user: admin, project_custom_field: cf2) } + + describe "reordering within the same section" do + it "moves the field to the target position" do + result = service.call(target_id: section_a.id, position: 1) + expect(result).to be_success + expect(section_a.reload.attribute_order).to eq([cf2.column_name, cf1.column_name]) + end + + it "reports section_changed as false" do + result = service.call(target_id: section_a.id, position: 1) + expect(result.result[:section_changed]).to be(false) + end + end + + describe "moving to a different section" do + it "removes the field from the source section" do + service.call(target_id: section_b.id, position: 1) + expect(section_a.reload.attribute_order).not_to include(cf2.column_name) + end + + it "inserts the field at the target position in the new section" do + service.call(target_id: section_b.id, position: 1) + expect(section_b.reload.attribute_order.first).to eq(cf2.column_name) + end + + it "updates the custom field's section" do + service.call(target_id: section_b.id, position: 1) + expect(cf2.reload.custom_field_section_id).to eq(section_b.id) + end + + it "reports section_changed as true with both sections" do + result = service.call(target_id: section_b.id, position: 1) + expect(result.result[:section_changed]).to be(true) + expect(result.result[:current_section]).to eq(section_b) + expect(result.result[:old_section]).to eq(section_a) + end + end + + context "when user is not admin" do + let(:admin) { build(:user) } + + it "returns a failure" do + result = service.call(target_id: section_a.id, position: 1) + expect(result).not_to be_success + end + end +end diff --git a/spec/services/project_custom_fields/move_service_spec.rb b/spec/services/project_custom_fields/move_service_spec.rb new file mode 100644 index 000000000000..79cfc5daeaf7 --- /dev/null +++ b/spec/services/project_custom_fields/move_service_spec.rb @@ -0,0 +1,74 @@ +# 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 ProjectCustomFields::MoveService do + let(:admin) { build(:admin) } + let(:section) { create(:project_custom_field_section) } + let!(:cf1) { create(:project_custom_field, project_custom_field_section: section) } + let!(:cf2) { create(:project_custom_field, project_custom_field_section: section) } + let!(:cf3) { create(:project_custom_field, project_custom_field_section: section) } + + subject(:service) { described_class.new(user: admin, project_custom_field: cf2) } + + it "moves the field higher in attribute_order" do + result = service.call(move_to: "higher") + expect(result).to be_success + expect(section.reload.attribute_order.index(cf2.column_name)) + .to be < section.attribute_order.index(cf1.column_name) + end + + it "moves the field lower in attribute_order" do + result = service.call(move_to: "lower") + expect(result).to be_success + expect(section.reload.attribute_order.index(cf2.column_name)) + .to be > section.attribute_order.index(cf3.column_name) + end + + it "moves the field to the top" do + service.call(move_to: "highest") + expect(section.reload.attribute_order.first).to eq(cf2.column_name) + end + + it "moves the field to the bottom" do + service.call(move_to: "lowest") + expect(section.reload.attribute_order.last).to eq(cf2.column_name) + end + + context "when user is not admin" do + let(:admin) { build(:user) } + + it "returns a failure" do + result = service.call(move_to: "higher") + expect(result).not_to be_success + end + end +end From f9ed9a447327a55a7eb49b7a8543da1a2fd90b79 Mon Sep 17 00:00:00 2001 From: David F Date: Fri, 5 Jun 2026 14:43:45 +0200 Subject: [PATCH 10/20] DRY up code between Users and Projects section management. wp/72005 --- .../project_custom_fields_controller.rb | 4 +- .../settings/user_custom_fields_controller.rb | 4 +- .../custom_field/sectionable.rb} | 36 +++--- app/models/custom_field_section.rb | 2 +- app/models/project_custom_field.rb | 15 +-- app/models/user_custom_field.rb | 18 +-- .../drop_service.rb | 16 +-- .../move_service.rb | 10 +- .../project_custom_fields/drop_service.rb | 82 -------------- app/views/users/_simple_form.html.erb | 2 +- .../custom_fields/drop_service_spec.rb | 103 ++++++++++++++++++ .../custom_fields/move_service_spec.rb | 90 +++++++++++++++ .../drop_service_spec.rb | 87 --------------- .../move_service_spec.rb | 74 ------------- .../user_custom_fields/drop_service_spec.rb | 87 --------------- .../user_custom_fields/move_service_spec.rb | 74 ------------- 16 files changed, 237 insertions(+), 467 deletions(-) rename app/{services/project_custom_fields/move_service.rb => models/custom_field/sectionable.rb} (60%) rename app/services/{user_custom_fields => custom_fields}/drop_service.rb (82%) rename app/services/{user_custom_fields => custom_fields}/move_service.rb (85%) delete mode 100644 app/services/project_custom_fields/drop_service.rb create mode 100644 spec/services/custom_fields/drop_service_spec.rb create mode 100644 spec/services/custom_fields/move_service_spec.rb delete mode 100644 spec/services/project_custom_fields/drop_service_spec.rb delete mode 100644 spec/services/project_custom_fields/move_service_spec.rb delete mode 100644 spec/services/user_custom_fields/drop_service_spec.rb delete mode 100644 spec/services/user_custom_fields/move_service_spec.rb diff --git a/app/controllers/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb index 665dd3ae5192..48657fc76997 100644 --- a/app/controllers/admin/settings/project_custom_fields_controller.rb +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -143,7 +143,7 @@ def unlink end def move - result = ProjectCustomFields::MoveService.new(user: current_user, project_custom_field: @custom_field).call( + result = CustomFields::MoveService.new(user: current_user, custom_field: @custom_field).call( move_to: params.expect(:move_to) ) @@ -159,7 +159,7 @@ def move end def drop - result = ::ProjectCustomFields::DropService.new(user: current_user, project_custom_field: @custom_field).call( + result = CustomFields::DropService.new(user: current_user, custom_field: @custom_field).call( target_id: params[:target_id], position: params[:position] ) diff --git a/app/controllers/admin/settings/user_custom_fields_controller.rb b/app/controllers/admin/settings/user_custom_fields_controller.rb index 6d538dd836e7..580cfe6a2af6 100644 --- a/app/controllers/admin/settings/user_custom_fields_controller.rb +++ b/app/controllers/admin/settings/user_custom_fields_controller.rb @@ -70,7 +70,7 @@ def edit; end def list_items; end def move - result = UserCustomFields::MoveService.new(user: current_user, user_custom_field: @custom_field).call( + result = CustomFields::MoveService.new(user: current_user, custom_field: @custom_field).call( move_to: params.expect(:move_to) ) @@ -86,7 +86,7 @@ def move end def drop - result = ::UserCustomFields::DropService.new(user: current_user, user_custom_field: @custom_field).call( + result = CustomFields::DropService.new(user: current_user, custom_field: @custom_field).call( target_id: params[:target_id], position: params[:position] ) diff --git a/app/services/project_custom_fields/move_service.rb b/app/models/custom_field/sectionable.rb similarity index 60% rename from app/services/project_custom_fields/move_service.rb rename to app/models/custom_field/sectionable.rb index aa4d63f1d571..7f30e143d1c2 100644 --- a/app/services/project_custom_fields/move_service.rb +++ b/app/models/custom_field/sectionable.rb @@ -28,22 +28,28 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module ProjectCustomFields - class MoveService < ::BaseServices::BaseCallable - def initialize(user:, project_custom_field:) - super() - @user = user - @project_custom_field = project_custom_field - end +# Shared behaviour for custom fields that belong to a CustomFieldSection and +# maintain their position within that section via attribute_order. +# Include in UserCustomField and ProjectCustomField. +module CustomField::Sectionable + extend ActiveSupport::Concern - def perform - return ServiceResult.failure(errors: { base: :error_unauthorized }) unless @user.admin? + included do + belongs_to :custom_field_section, class_name: "CustomFieldSection", + inverse_of: false - section = @project_custom_field.project_custom_field_section - section.move_in_order(@project_custom_field.column_name, params[:move_to]&.to_sym) - ServiceResult.success(result: section) - rescue StandardError => e - ServiceResult.failure(errors: e.message) - end + validates :custom_field_section_id, presence: true + + after_create_commit :add_to_section_order + after_destroy_commit :remove_from_section_order + end + + def add_to_section_order + custom_field_section.add_to_order(column_name) + end + + def remove_from_section_order + section = custom_field_section + section.remove_from_order(column_name) unless section.nil? || section.frozen? end end diff --git a/app/models/custom_field_section.rb b/app/models/custom_field_section.rb index de00ff2c6e1b..854981534cca 100644 --- a/app/models/custom_field_section.rb +++ b/app/models/custom_field_section.rb @@ -65,7 +65,6 @@ def remove_from_order(key) update_column(:attribute_order, attribute_order.reject { |k| k == key }) end - # move_to: :highest | :higher | :lower | :lowest def custom_fields_by_key custom_fields.index_by(&:column_name) end @@ -75,6 +74,7 @@ def custom_fields_in_order attribute_order.filter_map { |key| cf_by_key[key] } end + # move_to: :highest | :higher | :lower | :lowest def move_in_order(key, move_to) idx = attribute_order.index(key) return unless idx diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index 9accd0b56aa0..9d03848b50d4 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -29,16 +29,14 @@ #++ class ProjectCustomField < CustomField + include CustomField::Sectionable + belongs_to :project_custom_field_section, class_name: "ProjectCustomFieldSection", foreign_key: :custom_field_section_id, inverse_of: :custom_fields has_many :project_custom_field_project_mappings, class_name: "ProjectCustomFieldProjectMapping", foreign_key: :custom_field_id, dependent: :destroy, inverse_of: :project_custom_field has_many :projects, through: :project_custom_field_project_mappings - validates :custom_field_section_id, presence: true - - after_create_commit :add_to_section_order - after_destroy_commit :remove_from_section_order after_save :activate_required_field_in_all_projects, if: :is_for_all? # Relevant for user fields to allow membership assignment @@ -157,13 +155,4 @@ def activate_required_field_in_all_projects unique_by: %i[custom_field_id project_id] ) end - - def add_to_section_order - project_custom_field_section.add_to_order(column_name) - end - - def remove_from_section_order - section = project_custom_field_section - section.remove_from_order(column_name) unless section.nil? || section.frozen? - end end diff --git a/app/models/user_custom_field.rb b/app/models/user_custom_field.rb index aed5c29e401a..df296a39a792 100644 --- a/app/models/user_custom_field.rb +++ b/app/models/user_custom_field.rb @@ -29,28 +29,14 @@ #++ class UserCustomField < CustomField + include CustomField::Sectionable + belongs_to :user_custom_field_section, class_name: "UserCustomFieldSection", foreign_key: :custom_field_section_id, inverse_of: :custom_fields - validates :custom_field_section_id, presence: true - - after_create_commit :add_to_section_order - after_destroy_commit :remove_from_section_order - scopes :visible def type_name :label_user_plural end - - private - - def add_to_section_order - user_custom_field_section.add_to_order(column_name) - end - - def remove_from_section_order - section = user_custom_field_section - section.remove_from_order(column_name) unless section.nil? || section.frozen? - end end diff --git a/app/services/user_custom_fields/drop_service.rb b/app/services/custom_fields/drop_service.rb similarity index 82% rename from app/services/user_custom_fields/drop_service.rb rename to app/services/custom_fields/drop_service.rb index 35e0d35c33f4..ddba3925c492 100644 --- a/app/services/user_custom_fields/drop_service.rb +++ b/app/services/custom_fields/drop_service.rb @@ -28,12 +28,12 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module UserCustomFields +module CustomFields class DropService < ::BaseServices::BaseCallable - def initialize(user:, user_custom_field:) + def initialize(user:, custom_field:) super() @user = user - @user_custom_field = user_custom_field + @custom_field = custom_field end def perform @@ -52,7 +52,7 @@ def validate_permissions def perform_drop(service_call, params) section_changed, current_section, old_section = move_to_target_section(params) - current_section.add_to_order(@user_custom_field.column_name, position: params[:position]&.to_i) + current_section.add_to_order(@custom_field.column_name, position: params[:position]&.to_i) service_call.success = true service_call.result = { section_changed:, current_section: current_section.reload, old_section: } @@ -66,15 +66,15 @@ def perform_drop(service_call, params) private def move_to_target_section(params) - current_section = @user_custom_field.user_custom_field_section + current_section = @custom_field.custom_field_section new_section_id = params[:target_id]&.to_i return [false, current_section, nil] if current_section.id == new_section_id old_section = current_section - current_section = UserCustomFieldSection.find(new_section_id) - old_section.remove_from_order(@user_custom_field.column_name) - @user_custom_field.update!(custom_field_section_id: current_section.id) + current_section = CustomFieldSection.find(new_section_id) + old_section.remove_from_order(@custom_field.column_name) + @custom_field.update!(custom_field_section_id: current_section.id) [true, current_section, old_section.reload] end diff --git a/app/services/user_custom_fields/move_service.rb b/app/services/custom_fields/move_service.rb similarity index 85% rename from app/services/user_custom_fields/move_service.rb rename to app/services/custom_fields/move_service.rb index 87e7435728f5..510fc5d736d4 100644 --- a/app/services/user_custom_fields/move_service.rb +++ b/app/services/custom_fields/move_service.rb @@ -28,19 +28,19 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module UserCustomFields +module CustomFields class MoveService < ::BaseServices::BaseCallable - def initialize(user:, user_custom_field:) + def initialize(user:, custom_field:) super() @user = user - @user_custom_field = user_custom_field + @custom_field = custom_field end def perform return ServiceResult.failure(errors: { base: :error_unauthorized }) unless @user.admin? - section = @user_custom_field.user_custom_field_section - section.move_in_order(@user_custom_field.column_name, params[:move_to]&.to_sym) + section = @custom_field.custom_field_section + section.move_in_order(@custom_field.column_name, params[:move_to]&.to_sym) ServiceResult.success(result: section) rescue StandardError => e ServiceResult.failure(errors: e.message) diff --git a/app/services/project_custom_fields/drop_service.rb b/app/services/project_custom_fields/drop_service.rb deleted file mode 100644 index 1faecb20fbc7..000000000000 --- a/app/services/project_custom_fields/drop_service.rb +++ /dev/null @@ -1,82 +0,0 @@ -# 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 ProjectCustomFields - class DropService < ::BaseServices::BaseCallable - def initialize(user:, project_custom_field:) - super() - @user = user - @project_custom_field = project_custom_field - end - - def perform - service_call = validate_permissions - service_call = perform_drop(service_call, params) if service_call.success? - service_call - end - - def validate_permissions - if @user.admin? - ServiceResult.success - else - ServiceResult.failure(errors: { base: :error_unauthorized }) - end - end - - def perform_drop(service_call, params) - section_changed, current_section, old_section = move_to_target_section(params) - current_section.add_to_order(@project_custom_field.column_name, position: params[:position]&.to_i) - - service_call.success = true - service_call.result = { section_changed:, current_section: current_section.reload, old_section: } - service_call - rescue StandardError => e - service_call.success = false - service_call.errors = e.message - service_call - end - - private - - def move_to_target_section(params) - current_section = @project_custom_field.project_custom_field_section - new_section_id = params[:target_id]&.to_i - - return [false, current_section, nil] if current_section.id == new_section_id - - old_section = current_section - current_section = ProjectCustomFieldSection.find(new_section_id) - old_section.remove_from_order(@project_custom_field.column_name) - @project_custom_field.update!(custom_field_section_id: current_section.id) - - [true, current_section, old_section.reload] - end - end -end diff --git a/app/views/users/_simple_form.html.erb b/app/views/users/_simple_form.html.erb index 99cf4573ffe4..3a46855ecac4 100644 --- a/app/views/users/_simple_form.html.erb +++ b/app/views/users/_simple_form.html.erb @@ -33,7 +33,7 @@ See COPYRIGHT and LICENSE files for more details. <%= render partial: "users/form/admin_flag", locals: { f: f } %> <% end %> - <% UserCustomFieldSection.all.order(:position).includes(:custom_fields).each do |section| %> + <% UserCustomFieldSection.includes(:custom_fields).each do |section| # rubocop:disable Rails/FindEach -- to honor default scope ordering %> <%= render Users::Form::CustomFieldSectionComponent.new(section:, form: f, contract: @contract, user: @user) %> <% end %> <%= call_hook(:view_users_form, user: @user, form: f) %> diff --git a/spec/services/custom_fields/drop_service_spec.rb b/spec/services/custom_fields/drop_service_spec.rb new file mode 100644 index 000000000000..d9f74e9e64d9 --- /dev/null +++ b/spec/services/custom_fields/drop_service_spec.rb @@ -0,0 +1,103 @@ +# 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 CustomFields::DropService do + let(:admin) { build(:admin) } + + shared_examples "drops fields within and across sections" do |cf_factory:, section_factory:, section_assoc:| + let(:section_a) { create(section_factory, position: 1) } + let(:section_b) { create(section_factory, position: 2) } + let!(:cf1) { create(cf_factory, section_assoc => section_a) } + let!(:cf2) { create(cf_factory, section_assoc => section_a) } + + subject(:service) { described_class.new(user: admin, custom_field: cf2) } + + describe "reordering within the same section" do + it "moves to the target position" do + result = service.call(target_id: section_a.id, position: 1) + expect(result).to be_success + expect(section_a.reload.attribute_order).to eq([cf2.column_name, cf1.column_name]) + end + + it "reports section_changed as false" do + result = service.call(target_id: section_a.id, position: 1) + expect(result.result[:section_changed]).to be(false) + end + end + + describe "moving to a different section" do + it "removes the field from the source section" do + service.call(target_id: section_b.id, position: 1) + expect(section_a.reload.attribute_order).not_to include(cf2.column_name) + end + + it "inserts the field at the target position in the new section" do + service.call(target_id: section_b.id, position: 1) + expect(section_b.reload.attribute_order.first).to eq(cf2.column_name) + end + + it "updates the custom field's section" do + service.call(target_id: section_b.id, position: 1) + expect(cf2.reload.custom_field_section_id).to eq(section_b.id) + end + + it "reports section_changed as true with both sections" do + result = service.call(target_id: section_b.id, position: 1) + expect(result.result[:section_changed]).to be(true) + expect(result.result[:current_section]).to eq(section_b) + expect(result.result[:old_section]).to eq(section_a) + end + end + + context "when user is not admin" do + let(:admin) { build(:user) } + + it "returns a failure" do + expect(service.call(target_id: section_a.id, position: 1)).not_to be_success + end + end + end + + describe "with UserCustomField" do + include_examples "drops fields within and across sections", + cf_factory: :user_custom_field, + section_factory: :user_custom_field_section, + section_assoc: :user_custom_field_section + end + + describe "with ProjectCustomField" do + include_examples "drops fields within and across sections", + cf_factory: :project_custom_field, + section_factory: :project_custom_field_section, + section_assoc: :project_custom_field_section + end +end diff --git a/spec/services/custom_fields/move_service_spec.rb b/spec/services/custom_fields/move_service_spec.rb new file mode 100644 index 000000000000..c6b75aeebedf --- /dev/null +++ b/spec/services/custom_fields/move_service_spec.rb @@ -0,0 +1,90 @@ +# 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 CustomFields::MoveService do + let(:admin) { build(:admin) } + + shared_examples "moves fields within a section" do |cf_factory:, section_factory:, section_assoc:| + let(:section) { create(section_factory) } + let!(:cf1) { create(cf_factory, section_assoc => section) } + let!(:cf2) { create(cf_factory, section_assoc => section) } + let!(:cf3) { create(cf_factory, section_assoc => section) } + + subject(:service) { described_class.new(user: admin, custom_field: cf2) } + + it "moves higher" do + result = service.call(move_to: "higher") + expect(result).to be_success + order = section.reload.attribute_order + expect(order.index(cf2.column_name)).to be < order.index(cf1.column_name) + end + + it "moves lower" do + result = service.call(move_to: "lower") + expect(result).to be_success + order = section.reload.attribute_order + expect(order.index(cf2.column_name)).to be > order.index(cf1.column_name) + end + + it "moves to the top" do + service.call(move_to: "highest") + expect(section.reload.attribute_order.first).to eq(cf2.column_name) + end + + it "moves to the bottom" do + service.call(move_to: "lowest") + expect(section.reload.attribute_order.last).to eq(cf2.column_name) + end + + context "when user is not admin" do + let(:admin) { build(:user) } + + it "returns a failure" do + expect(service.call(move_to: "higher")).not_to be_success + end + end + end + + describe "with UserCustomField" do + include_examples "moves fields within a section", + cf_factory: :user_custom_field, + section_factory: :user_custom_field_section, + section_assoc: :user_custom_field_section + end + + describe "with ProjectCustomField" do + include_examples "moves fields within a section", + cf_factory: :project_custom_field, + section_factory: :project_custom_field_section, + section_assoc: :project_custom_field_section + end +end diff --git a/spec/services/project_custom_fields/drop_service_spec.rb b/spec/services/project_custom_fields/drop_service_spec.rb deleted file mode 100644 index 4e6eebc389bf..000000000000 --- a/spec/services/project_custom_fields/drop_service_spec.rb +++ /dev/null @@ -1,87 +0,0 @@ -# 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 ProjectCustomFields::DropService do - let(:admin) { build(:admin) } - let(:section_a) { create(:project_custom_field_section, position: 1) } - let(:section_b) { create(:project_custom_field_section, position: 2) } - let!(:cf1) { create(:project_custom_field, project_custom_field_section: section_a) } - let!(:cf2) { create(:project_custom_field, project_custom_field_section: section_a) } - - subject(:service) { described_class.new(user: admin, project_custom_field: cf2) } - - describe "reordering within the same section" do - it "moves the field to the target position" do - result = service.call(target_id: section_a.id, position: 1) - expect(result).to be_success - expect(section_a.reload.attribute_order).to eq([cf2.column_name, cf1.column_name]) - end - - it "reports section_changed as false" do - result = service.call(target_id: section_a.id, position: 1) - expect(result.result[:section_changed]).to be(false) - end - end - - describe "moving to a different section" do - it "removes the field from the source section" do - service.call(target_id: section_b.id, position: 1) - expect(section_a.reload.attribute_order).not_to include(cf2.column_name) - end - - it "inserts the field at the target position in the new section" do - service.call(target_id: section_b.id, position: 1) - expect(section_b.reload.attribute_order.first).to eq(cf2.column_name) - end - - it "updates the custom field's section" do - service.call(target_id: section_b.id, position: 1) - expect(cf2.reload.custom_field_section_id).to eq(section_b.id) - end - - it "reports section_changed as true with both sections" do - result = service.call(target_id: section_b.id, position: 1) - expect(result.result[:section_changed]).to be(true) - expect(result.result[:current_section]).to eq(section_b) - expect(result.result[:old_section]).to eq(section_a) - end - end - - context "when user is not admin" do - let(:admin) { build(:user) } - - it "returns a failure" do - result = service.call(target_id: section_a.id, position: 1) - expect(result).not_to be_success - end - end -end diff --git a/spec/services/project_custom_fields/move_service_spec.rb b/spec/services/project_custom_fields/move_service_spec.rb deleted file mode 100644 index 79cfc5daeaf7..000000000000 --- a/spec/services/project_custom_fields/move_service_spec.rb +++ /dev/null @@ -1,74 +0,0 @@ -# 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 ProjectCustomFields::MoveService do - let(:admin) { build(:admin) } - let(:section) { create(:project_custom_field_section) } - let!(:cf1) { create(:project_custom_field, project_custom_field_section: section) } - let!(:cf2) { create(:project_custom_field, project_custom_field_section: section) } - let!(:cf3) { create(:project_custom_field, project_custom_field_section: section) } - - subject(:service) { described_class.new(user: admin, project_custom_field: cf2) } - - it "moves the field higher in attribute_order" do - result = service.call(move_to: "higher") - expect(result).to be_success - expect(section.reload.attribute_order.index(cf2.column_name)) - .to be < section.attribute_order.index(cf1.column_name) - end - - it "moves the field lower in attribute_order" do - result = service.call(move_to: "lower") - expect(result).to be_success - expect(section.reload.attribute_order.index(cf2.column_name)) - .to be > section.attribute_order.index(cf3.column_name) - end - - it "moves the field to the top" do - service.call(move_to: "highest") - expect(section.reload.attribute_order.first).to eq(cf2.column_name) - end - - it "moves the field to the bottom" do - service.call(move_to: "lowest") - expect(section.reload.attribute_order.last).to eq(cf2.column_name) - end - - context "when user is not admin" do - let(:admin) { build(:user) } - - it "returns a failure" do - result = service.call(move_to: "higher") - expect(result).not_to be_success - end - end -end diff --git a/spec/services/user_custom_fields/drop_service_spec.rb b/spec/services/user_custom_fields/drop_service_spec.rb deleted file mode 100644 index d2d2e9f7afc3..000000000000 --- a/spec/services/user_custom_fields/drop_service_spec.rb +++ /dev/null @@ -1,87 +0,0 @@ -# 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 UserCustomFields::DropService do - let(:admin) { build(:admin) } - let(:section_a) { create(:user_custom_field_section, position: 1) } - let(:section_b) { create(:user_custom_field_section, position: 2) } - let!(:cf1) { create(:user_custom_field, user_custom_field_section: section_a) } - let!(:cf2) { create(:user_custom_field, user_custom_field_section: section_a) } - - subject(:service) { described_class.new(user: admin, user_custom_field: cf2) } - - describe "reordering within the same section" do - it "moves the field to the target position" do - result = service.call(target_id: section_a.id, position: 1) - expect(result).to be_success - expect(section_a.reload.attribute_order).to eq([cf2.column_name, cf1.column_name]) - end - - it "reports section_changed as false" do - result = service.call(target_id: section_a.id, position: 1) - expect(result.result[:section_changed]).to be(false) - end - end - - describe "moving to a different section" do - it "removes the field from the source section" do - service.call(target_id: section_b.id, position: 1) - expect(section_a.reload.attribute_order).not_to include(cf2.column_name) - end - - it "inserts the field at the target position in the new section" do - service.call(target_id: section_b.id, position: 1) - expect(section_b.reload.attribute_order.first).to eq(cf2.column_name) - end - - it "updates the custom field's section" do - service.call(target_id: section_b.id, position: 1) - expect(cf2.reload.custom_field_section_id).to eq(section_b.id) - end - - it "reports section_changed as true with both sections" do - result = service.call(target_id: section_b.id, position: 1) - expect(result.result[:section_changed]).to be(true) - expect(result.result[:current_section]).to eq(section_b) - expect(result.result[:old_section]).to eq(section_a) - end - end - - context "when user is not admin" do - let(:admin) { build(:user) } - - it "returns a failure" do - result = service.call(target_id: section_a.id, position: 1) - expect(result).not_to be_success - end - end -end diff --git a/spec/services/user_custom_fields/move_service_spec.rb b/spec/services/user_custom_fields/move_service_spec.rb deleted file mode 100644 index 731587e45aa6..000000000000 --- a/spec/services/user_custom_fields/move_service_spec.rb +++ /dev/null @@ -1,74 +0,0 @@ -# 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 UserCustomFields::MoveService do - let(:admin) { build(:admin) } - let(:section) { create(:user_custom_field_section) } - let!(:cf1) { create(:user_custom_field, user_custom_field_section: section) } - let!(:cf2) { create(:user_custom_field, user_custom_field_section: section) } - let!(:cf3) { create(:user_custom_field, user_custom_field_section: section) } - - subject(:service) { described_class.new(user: admin, user_custom_field: cf2) } - - it "moves the field higher in attribute_order" do - result = service.call(move_to: "higher") - expect(result).to be_success - expect(section.reload.attribute_order.index(cf2.column_name)) - .to be < section.attribute_order.index(cf1.column_name) - end - - it "moves the field lower in attribute_order" do - result = service.call(move_to: "lower") - expect(result).to be_success - expect(section.reload.attribute_order.index(cf2.column_name)) - .to be > section.attribute_order.index(cf3.column_name) - end - - it "moves the field to the top" do - service.call(move_to: "highest") - expect(section.reload.attribute_order.first).to eq(cf2.column_name) - end - - it "moves the field to the bottom" do - service.call(move_to: "lowest") - expect(section.reload.attribute_order.last).to eq(cf2.column_name) - end - - context "when user is not admin" do - let(:admin) { build(:user) } - - it "returns a failure" do - result = service.call(move_to: "higher") - expect(result).not_to be_success - end - end -end From 56892bedbcd4fd71837a0955f7f0d133567e45a5 Mon Sep 17 00:00:00 2001 From: David F Date: Mon, 8 Jun 2026 11:52:57 +0200 Subject: [PATCH 11/20] Fix yamllint errors. --- config/locales/en.yml | 106 +++++++++++++++++++++--------------------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index c68308a8c79c..0dd0c1f9aadb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -28,6 +28,7 @@ --- en: + label_user_attributes_plural: "User attributes" no_results_title_text: There is currently nothing to display. activities: @@ -708,6 +709,8 @@ en: gain_role: Will gain role ā€˜%{new_role}’ lose_role: Will lose role ā€˜%{old_role}’ no_change: No changes + multi_value_more: + other: "and %{count} more" text_add_new_custom_field: > To add new custom fields to a project you first need to create them before you can add them to this project. @@ -719,8 +722,6 @@ en: 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?" placeholder_version_select: "Work package or project selection is required first" calculated_field_not_editable: "Non-editable attribute. This value is calculated automatically." - multi_value_more: - other: "and %{count} more" no_role_assigment: "No role assignment" instructions: is_required: @@ -1797,18 +1798,6 @@ en: activerecord: attributes: - work_package_semantic_alias: - identifier: "Identifier" - work_package: "Work package" - jira_import: - projects: "Projects" - "import/jira": - name: "Jira instance name" - url: "Jira instance URL" - personal_access_token: "Personal access token" - "import/jira_open_project_reference": - jira: "Jira" - jira_import: "Jira Migrator" announcements: show_until: "Display until" attachment: @@ -1889,6 +1878,23 @@ en: row_count: "Number of rows" column_count: "Number of columns" widgets: "Widgets" + group: + group_users: "Group users" + identity_url: "Identity URL" + organizational_unit: "Organizational unit" + parent: "Parent group" + group_detail: + organizational_unit: "Organizational unit" + parent: "Parent group" + "import/jira": + name: "Jira instance name" + personal_access_token: "Personal access token" + url: "Jira instance URL" + "import/jira_open_project_reference": + jira: "Jira" + jira_import: "Jira Migrator" + jira_import: + projects: "Projects" journal: notes: "Notes" cause_type: "Cause type" @@ -1917,6 +1923,21 @@ en: resource: "Resource" oauth_client: client: "Client ID" + ordered_persisted_query_entity: + entity: "Entity" + persisted_query: "Persisted query" + position: "Position" + persisted_query: + filters: "Filters" + name: "Name" + orders: "Orders" + selects: "Selects" + views: "Views" + persisted_view: + name: "Name" + parent: "Parent view" + public: "Public" + query: "Query" project: active_value: true: "unarchived" @@ -2063,14 +2084,6 @@ en: new_password: "New password" password_confirmation: "Confirmation" consented_at: "Consented at" - group: - identity_url: "Identity URL" - parent: "Parent group" - organizational_unit: "Organizational unit" - group_users: "Group users" - group_detail: - parent: "Parent group" - organizational_unit: "Organizational unit" user_custom_field: custom_field_section: Section user_preference: @@ -2171,21 +2184,9 @@ en: type: "Type" version: "Version" watcher: "Watcher" - ordered_persisted_query_entity: - persisted_query: "Persisted query" - entity: "Entity" - position: "Position" - persisted_query: - name: "Name" - views: "Views" - filters: "Filters" - orders: "Orders" - selects: "Selects" - persisted_view: - name: "Name" - query: "Query" - parent: "Parent view" - public: "Public" + work_package_semantic_alias: + identifier: "Identifier" + work_package: "Work package" user_card_view: secondary_info: "Secondary info" show_status_badge: "Show status badge" @@ -4587,7 +4588,6 @@ en: label_user_named: "User %{name}" label_user_activity_html: "%{value}'s activity" label_user_anonymous: "Anonymous" - label_user_attributes_plural: "User attributes" label_user_menu: "User menu" label_user_new: "New user" label_user_plural: "Users" @@ -5698,20 +5698,6 @@ en: side_panel: label: "Side panel" description: "Add all the project attributes in a section inside the right side panel in the project overview." - user_attributes: - heading: "User attributes" - heading_description: "These user attributes appear on user profiles and information cards. You can add new attributes, group them into sections and re-order them." - label_built_in: "Built-in" - label_built_in_attribute_actions: "Built-in attribute actions" - label_edit_section: "Edit title" - label_new_attribute: "User attribute" - label_new_section: "Section" - label_no_user_custom_fields: "No user attributes defined in this section" - label_section_actions: "Section actions" - label_untitled_section: "Untitled section" - label_user_custom_field_actions: "User attribute actions" - new: - heading: "New attribute" project_initiation_request: header_description: > OpenProject can generate a step-by-step wizard to help project managers fill out a project initiation request. @@ -5787,8 +5773,22 @@ en: session: "Session" user: default_preferences: "Default preferences" - display_format: "Display format" deletion: "Deletion" + display_format: "Display format" + user_attributes: + heading: "User attributes" + heading_description: "These user attributes appear on user profiles and information cards. You can add new attributes, group them into sections and re-order them." + label_built_in: "Built-in" + label_built_in_attribute_actions: "Built-in attribute actions" + label_edit_section: "Edit title" + label_new_attribute: "User attribute" + label_new_section: "Section" + label_no_user_custom_fields: "No user attributes defined in this section" + label_section_actions: "Section actions" + label_untitled_section: "Untitled section" + label_user_custom_field_actions: "User attribute actions" + new: + heading: "New attribute" working_days: section_work_week: "Work week" section_holidays_and_closures: "Holidays and closures" From b02e4fc603de0865eb69d5f25a07810fa9bc89aa Mon Sep 17 00:00:00 2001 From: David F Date: Tue, 9 Jun 2026 10:39:21 +0200 Subject: [PATCH 12/20] Add 'New attribute' submenu to project section actions. wp/72005 --- .../show_component.html.erb | 21 +++++++++++++++++-- config/locales/en.yml | 1 + .../custom_fields/projects/index_spec.rb | 14 +++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/components/settings/project_custom_field_sections/show_component.html.erb b/app/components/settings/project_custom_field_sections/show_component.html.erb index c499ae729e93..dad90516a72b 100644 --- a/app/components/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/settings/project_custom_field_sections/show_component.html.erb @@ -57,6 +57,23 @@ else disabled_delete_action_item(menu) end + + menu.with_divider + + menu.with_sub_menu_item( + label: t("settings.project_attributes.label_new_attribute"), + content_arguments: { + aria: { label: t("settings.project_attributes.label_add_attribute") }, + data: { test_selector: "new-project-custom-field-in-section-submenu" } + } + ) do |sub_menu| + sub_menu.with_leading_visual_icon(icon: :plus) + OpenProject::CustomFieldFormat.available_for_class_name("Project") + .sort_by(&:name) + .each do |format| + action_menu_item_for_custom_field_format(sub_menu, format) + end + end end end @@ -94,8 +111,8 @@ OpenProject::CustomFieldFormat.available_for_class_name("Project") .sort_by(&:name) - .map do |format| - action_menu_item_for_custom_field_format(menu, format) + .each do |format| + action_menu_item_for_custom_field_format(menu, format) end end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 0dd0c1f9aadb..26e7c975728b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -5675,6 +5675,7 @@ en: passwords: "Passwords" project_attributes: heading: "Project attributes" + label_add_attribute: "Add a project attribute" label_for_all_projects: "All projects" label_new_attribute: "Project attribute" label_new_section: "Section" diff --git a/spec/features/admin/custom_fields/projects/index_spec.rb b/spec/features/admin/custom_fields/projects/index_spec.rb index e184cc3647db..682b079a2949 100644 --- a/spec/features/admin/custom_fields/projects/index_spec.rb +++ b/spec/features/admin/custom_fields/projects/index_spec.rb @@ -358,6 +358,20 @@ def have_cf_admin_link(custom_field) custom_field_section_id: section_for_multi_select_fields.id )) end + + it "allows adding an attribute to a non-empty section via the section actions menu" do + # section_for_input_fields already has custom fields (non-empty), + # so the empty-state button is not shown here — the submenu must be. + within_project_custom_field_section_menu(section_for_input_fields) do + page.find_test_selector("new-project-custom-field-in-section-submenu").click + page.find_test_selector("new-project-custom-field-in-section-button-int").click + end + + expect(page).to have_current_path(new_admin_settings_project_custom_field_path( + field_format: "int", + custom_field_section_id: section_for_input_fields.id + )) + end end end From 5dc6815f913d6cde620c76d726dd457fad44e9b3 Mon Sep 17 00:00:00 2001 From: David F Date: Tue, 9 Jun 2026 10:41:25 +0200 Subject: [PATCH 13/20] Add 'New attribute' submenu to user section actions. wp/72005 --- .../show_component.html.erb | 21 +++++++++++++++++-- config/locales/en.yml | 1 + .../admin/custom_fields/users/index_spec.rb | 14 +++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/components/settings/user_custom_field_sections/show_component.html.erb b/app/components/settings/user_custom_field_sections/show_component.html.erb index 68ce24eaf2c3..c883142991bc 100644 --- a/app/components/settings/user_custom_field_sections/show_component.html.erb +++ b/app/components/settings/user_custom_field_sections/show_component.html.erb @@ -25,6 +25,23 @@ else disabled_delete_action_item(menu) end + + menu.with_divider + + menu.with_sub_menu_item( + label: t("settings.user_attributes.label_new_attribute"), + content_arguments: { + aria: { label: t("settings.user_attributes.label_add_attribute") }, + data: { test_selector: "new-user-custom-field-in-section-submenu" } + } + ) do |sub_menu| + sub_menu.with_leading_visual_icon(icon: :plus) + OpenProject::CustomFieldFormat.available_for_class_name("User") + .sort_by(&:name) + .each do |format| + action_menu_item_for_custom_field_format(sub_menu, format) + end + end end end @@ -62,8 +79,8 @@ OpenProject::CustomFieldFormat.available_for_class_name("User") .sort_by(&:name) - .map do |format| - action_menu_item_for_custom_field_format(menu, format) + .each do |format| + action_menu_item_for_custom_field_format(menu, format) end end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 26e7c975728b..42645840f6e6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -5779,6 +5779,7 @@ en: user_attributes: heading: "User attributes" heading_description: "These user attributes appear on user profiles and information cards. You can add new attributes, group them into sections and re-order them." + label_add_attribute: "Add a user attribute" label_built_in: "Built-in" label_built_in_attribute_actions: "Built-in attribute actions" label_edit_section: "Edit title" diff --git a/spec/features/admin/custom_fields/users/index_spec.rb b/spec/features/admin/custom_fields/users/index_spec.rb index f7dc094b29e0..bf725e78ccb8 100644 --- a/spec/features/admin/custom_fields/users/index_spec.rb +++ b/spec/features/admin/custom_fields/users/index_spec.rb @@ -192,6 +192,20 @@ custom_field_section_id: section_for_input_fields.id )) end + + it "allows adding an attribute to a non-empty section via the section actions menu" do + # section_for_input_fields already has custom fields (non-empty), + # so the empty-state button is not shown here — the submenu must be. + within_user_custom_field_section_menu(section_for_input_fields) do + page.find_test_selector("new-user-custom-field-in-section-submenu").click + page.find_test_selector("new-user-custom-field-in-section-button-int").click + end + + expect(page).to have_current_path(new_admin_settings_user_custom_field_path( + field_format: "int", + custom_field_section_id: section_for_input_fields.id + )) + end end # helper methods From 52e788b520dcb529e74f7447f8e72c0e8844bbab Mon Sep 17 00:00:00 2001 From: David F Date: Tue, 9 Jun 2026 12:11:55 +0200 Subject: [PATCH 14/20] Add a quick filter search input for user attributes. wp/72005 --- .../show_component.html.erb | 6 +++--- .../header_component.html.erb | 18 +++++++++++++++++- .../settings/user_custom_fields/index.html.erb | 2 +- config/locales/en.yml | 4 ++++ 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/app/components/settings/user_custom_field_sections/show_component.html.erb b/app/components/settings/user_custom_field_sections/show_component.html.erb index c883142991bc..1cea1a76b170 100644 --- a/app/components/settings/user_custom_field_sections/show_component.html.erb +++ b/app/components/settings/user_custom_field_sections/show_component.html.erb @@ -60,7 +60,7 @@ end end if @ordered_attributes.empty? - component.with_row(data: { "empty-list-item": true }) do + component.with_row(data: { "empty-list-item": true, "filter--filter-list-target": "searchItem" }) do flex_layout(align_items: :center, justify_content: :space_between) do |empty_list_container| empty_list_container.with_column(ml: 4, mr: 2) do render(Primer::Beta::Text.new(color: :subtle)) { t("settings.user_attributes.label_no_user_custom_fields") } @@ -92,7 +92,7 @@ last = index == @ordered_attributes.size - 1 if UserCustomFieldSection::BUILT_IN_ATTRIBUTES.include?(key) - component.with_row(data: draggable_item_config_for_built_in(key)) do + component.with_row(data: draggable_item_config_for_built_in(key).merge("filter--filter-list-target": "searchItem")) do render( Settings::UserCustomFieldSections::BuiltInAttributeRowComponent.new( section: @user_custom_field_section, @@ -103,7 +103,7 @@ ) end elsif (cf = @custom_fields_by_key[key]) - component.with_row(data: draggable_item_config_for_custom_field(cf)) do + component.with_row(data: draggable_item_config_for_custom_field(cf).merge("filter--filter-list-target": "searchItem")) do render( custom_field_row_component_class.new( user_custom_field: cf, diff --git a/app/components/settings/user_custom_fields/header_component.html.erb b/app/components/settings/user_custom_fields/header_component.html.erb index 329e169e6bc2..d8775830f860 100644 --- a/app/components/settings/user_custom_fields/header_component.html.erb +++ b/app/components/settings/user_custom_fields/header_component.html.erb @@ -8,7 +8,23 @@ %> <%= - render Primer::OpenProject::SubHeader.new do |subheader| + render Primer::OpenProject::SubHeader.new(collapsed_search: true) do |subheader| + subheader.with_filter_input( + name: "attribute-filter-input", + label: t(".attribute_filter.label"), + visually_hide_label: true, + placeholder: t(".attribute_filter.label"), + leading_visual: { + icon: :search, + size: :small + }, + show_clear_button: true, + clear_button_id: "attribute-filter-clear-button", + data: { + action: "input->filter--filter-list#filterLists", + "filter--filter-list-target": "filter" + } + ) subheader.with_action_menu( leading_icon: :plus, trailing_icon: :"triangle-down", diff --git a/app/views/admin/settings/user_custom_fields/index.html.erb b/app/views/admin/settings/user_custom_fields/index.html.erb index f50dbb6087dc..dee8d01c12a4 100644 --- a/app/views/admin/settings/user_custom_fields/index.html.erb +++ b/app/views/admin/settings/user_custom_fields/index.html.erb @@ -28,7 +28,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <% html_title t(:label_administration), t("settings.user_attributes.heading") %> -
+
<%= render(Settings::UserCustomFields::HeaderComponent.new(allow_custom_field_creation: @allow_custom_field_creation)) %> <%= render( Settings::UserCustomFieldSections::IndexComponent.new( diff --git a/config/locales/en.yml b/config/locales/en.yml index 42645840f6e6..f8cb20c6aa76 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -5791,6 +5791,10 @@ en: label_user_custom_field_actions: "User attribute actions" new: heading: "New attribute" + user_custom_fields: + header_component: + attribute_filter: + label: "Filter by attribute name…" working_days: section_work_week: "Work week" section_holidays_and_closures: "Holidays and closures" From 5fa782a108ed7d9de17ebe5b4c9e2289a9f379f8 Mon Sep 17 00:00:00 2001 From: David F Date: Tue, 9 Jun 2026 13:56:42 +0200 Subject: [PATCH 15/20] Namespace user attributes i18n keys to match component code. wp/72005 --- .../built_in_attribute_row_component.html.erb | 4 +-- .../custom_field_row_component.html.erb | 2 +- .../new_section_dialog_component.html.erb | 2 +- .../show_component.html.erb | 16 +++++----- .../show_component.rb | 2 +- .../edit_form_header_component.rb | 2 +- .../header_component.html.erb | 8 ++--- .../user_custom_fields/header_component.rb | 2 +- .../new_form_header_component.rb | 6 ++-- .../form/custom_field_section_component.rb | 2 +- .../profile/attributes_component.html.erb | 2 +- app/forms/custom_fields/details_form.rb | 2 +- .../settings/user_custom_fields/edit.html.erb | 2 +- .../hierarchy/items/edit.html.erb | 2 +- .../hierarchy/items/index.html.erb | 2 +- .../hierarchy/items/new.html.erb | 2 +- .../user_custom_fields/index.html.erb | 2 +- .../user_custom_fields/list_items.html.erb | 2 +- .../settings/user_custom_fields/new.html.erb | 2 +- .../attribute_help_texts/show_user.html.erb | 2 +- config/locales/en.yml | 29 ++++++++++--------- .../profile/attributes_component_spec.rb | 2 +- 22 files changed, 50 insertions(+), 47 deletions(-) diff --git a/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.html.erb b/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.html.erb index 3781f23751d1..2f5ae324e5ee 100644 --- a/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.html.erb +++ b/app/components/settings/user_custom_field_sections/built_in_attribute_row_component.html.erb @@ -13,7 +13,7 @@ end content_container.with_column do render(Primer::Beta::Label.new(scheme: :secondary, size: :medium)) do - t("settings.user_attributes.label_built_in") + t(".label_built_in") end end end @@ -21,7 +21,7 @@ render(Primer::Alpha::ActionMenu.new(data: { test_selector: "built-in-attribute-action-menu" })) do |menu| menu.with_show_button( icon: "kebab-horizontal", - "aria-label": t("settings.user_attributes.label_built_in_attribute_actions"), + "aria-label": t(".label_built_in_attribute_actions"), scheme: :invisible ) move_actions(menu) diff --git a/app/components/settings/user_custom_field_sections/custom_field_row_component.html.erb b/app/components/settings/user_custom_field_sections/custom_field_row_component.html.erb index 85ea73709778..d7382d0fbea9 100644 --- a/app/components/settings/user_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/settings/user_custom_field_sections/custom_field_row_component.html.erb @@ -32,7 +32,7 @@ end main_container.with_column do render(Primer::Alpha::ActionMenu.new(data: { test_selector: "user-custom-field-action-menu" })) do |menu| - menu.with_show_button(icon: "kebab-horizontal", "aria-label": t("settings.user_attributes.label_user_custom_field_actions"), scheme: :invisible) + menu.with_show_button(icon: "kebab-horizontal", "aria-label": t(".label_user_custom_field_actions"), scheme: :invisible) edit_action_item(menu) move_actions(menu) delete_action_item(menu) diff --git a/app/components/settings/user_custom_field_sections/new_section_dialog_component.html.erb b/app/components/settings/user_custom_field_sections/new_section_dialog_component.html.erb index 60b62339aa1a..48fddf2228c8 100644 --- a/app/components/settings/user_custom_field_sections/new_section_dialog_component.html.erb +++ b/app/components/settings/user_custom_field_sections/new_section_dialog_component.html.erb @@ -1,6 +1,6 @@ <%= render( Primer::Alpha::Dialog.new( - title: t("settings.user_attributes.label_new_section"), + title: t("settings.user_custom_fields.label_new_section"), size: :medium_portrait, id: MODAL_ID, data: { "keep-open-on-submit": true } diff --git a/app/components/settings/user_custom_field_sections/show_component.html.erb b/app/components/settings/user_custom_field_sections/show_component.html.erb index 1cea1a76b170..55234ff31a83 100644 --- a/app/components/settings/user_custom_field_sections/show_component.html.erb +++ b/app/components/settings/user_custom_field_sections/show_component.html.erb @@ -9,7 +9,7 @@ end content_container.with_column do render(Primer::Beta::Text.new(font_weight: :bold)) do - @user_custom_field_section.name.presence || t("settings.user_attributes.label_untitled_section") + @user_custom_field_section.name.presence || t("settings.user_custom_fields.label_untitled_section") end end end @@ -17,7 +17,7 @@ section_header_container.with_column(flex_layout: true, justify_content: :flex_end) do |actions_container| actions_container.with_column do render(Primer::Alpha::ActionMenu.new(data: { test_selector: "user-custom-field-section-action-menu" })) do |menu| - menu.with_show_button(icon: "kebab-horizontal", "aria-label": t("settings.user_attributes.label_section_actions"), scheme: :invisible) + menu.with_show_button(icon: "kebab-horizontal", "aria-label": t(".label_section_actions"), scheme: :invisible) edit_action_item(menu) move_actions(menu) if @ordered_attributes.empty? @@ -29,9 +29,9 @@ menu.with_divider menu.with_sub_menu_item( - label: t("settings.user_attributes.label_new_attribute"), + label: t("settings.user_custom_fields.label_new_attribute"), content_arguments: { - aria: { label: t("settings.user_attributes.label_add_attribute") }, + aria: { label: t(".label_add_attribute") }, data: { test_selector: "new-user-custom-field-in-section-submenu" } } ) do |sub_menu| @@ -48,7 +48,7 @@ actions_container.with_column do render( Primer::Alpha::Dialog.new( - id: "user-custom-field-section-dialog#{@user_custom_field_section.id}", title: t("settings.user_attributes.label_new_section"), + id: "user-custom-field-section-dialog#{@user_custom_field_section.id}", title: t("settings.user_custom_fields.label_new_section"), size: :medium_portrait, data: { "keep-open-on-submit": true } ) @@ -63,18 +63,18 @@ component.with_row(data: { "empty-list-item": true, "filter--filter-list-target": "searchItem" }) do flex_layout(align_items: :center, justify_content: :space_between) do |empty_list_container| empty_list_container.with_column(ml: 4, mr: 2) do - render(Primer::Beta::Text.new(color: :subtle)) { t("settings.user_attributes.label_no_user_custom_fields") } + render(Primer::Beta::Text.new(color: :subtle)) { t(".label_no_user_custom_fields") } end empty_list_container.with_column do render(Primer::Alpha::ActionMenu.new(data: { test_selector: "new-user-custom-field-in-section-button" })) do |menu| menu.with_show_button( scheme: :secondary, - "aria-label": t("settings.user_attributes.label_new_attribute") + "aria-label": t("settings.user_custom_fields.label_new_attribute") ) do |btn| btn.with_leading_visual_icon(icon: :plus) btn.with_trailing_visual_icon(icon: :"triangle-down") - t("settings.user_attributes.label_new_attribute") + t("settings.user_custom_fields.label_new_attribute") end OpenProject::CustomFieldFormat.available_for_class_name("User") diff --git a/app/components/settings/user_custom_field_sections/show_component.rb b/app/components/settings/user_custom_field_sections/show_component.rb index c0c431f451c1..a685cb715e9d 100644 --- a/app/components/settings/user_custom_field_sections/show_component.rb +++ b/app/components/settings/user_custom_field_sections/show_component.rb @@ -111,7 +111,7 @@ def disabled_delete_action_item(menu) end def edit_action_item(menu) - menu.with_item(label: t("settings.user_attributes.label_edit_section"), + menu.with_item(label: t(".label_edit_section"), tag: :button, content_arguments: { "data-show-dialog-id": "user-custom-field-section-dialog#{@user_custom_field_section.id}", diff --git a/app/components/settings/user_custom_fields/edit_form_header_component.rb b/app/components/settings/user_custom_fields/edit_form_header_component.rb index ef50115303c5..a2918f2ac7d9 100644 --- a/app/components/settings/user_custom_fields/edit_form_header_component.rb +++ b/app/components/settings/user_custom_fields/edit_form_header_component.rb @@ -78,7 +78,7 @@ def breadcrumbs_items [ { href: admin_index_path, text: t("label_administration") }, { href: admin_settings_user_custom_fields_path, text: t("label_user_and_permission") }, - { href: admin_settings_user_custom_fields_path, text: t("settings.user_attributes.heading") }, + { href: admin_settings_user_custom_fields_path, text: t("settings.user_custom_fields.heading") }, helpers.nested_breadcrumb_element(helpers.label_for_custom_field_format(@custom_field.field_format), @custom_field.attribute_in_database("name")) ] diff --git a/app/components/settings/user_custom_fields/header_component.html.erb b/app/components/settings/user_custom_fields/header_component.html.erb index d8775830f860..441407f59ee4 100644 --- a/app/components/settings/user_custom_fields/header_component.html.erb +++ b/app/components/settings/user_custom_fields/header_component.html.erb @@ -1,8 +1,8 @@ <%= component_wrapper do %> <%= render Primer::OpenProject::PageHeader.new do |header| - header.with_title(variant: :default) { t("settings.user_attributes.heading") } - header.with_description { t("settings.user_attributes.heading_description") } + header.with_title(variant: :default) { t("settings.user_custom_fields.heading") } + header.with_description { t(".heading_description") } header.with_breadcrumbs(breadcrumbs_items) end %> @@ -37,7 +37,7 @@ } ) do |menu| menu.with_item( - label: t("settings.user_attributes.label_new_section"), + label: t("settings.user_custom_fields.label_new_section"), id: "dialog-show-user-custom-field-section-dialog", tag: :a, href: new_link_admin_settings_user_custom_field_sections_path, @@ -46,7 +46,7 @@ if allow_custom_field_creation? menu.with_sub_menu_item( - label: t("settings.user_attributes.label_new_attribute"), + label: t("settings.user_custom_fields.label_new_attribute"), content_arguments: { data: { test_selector: "add-user-custom-field-attribute" } } ) do |sub_menu| OpenProject::CustomFieldFormat.enabled_for_class_name("User") diff --git a/app/components/settings/user_custom_fields/header_component.rb b/app/components/settings/user_custom_fields/header_component.rb index af1beed71163..f58a704a87b7 100644 --- a/app/components/settings/user_custom_fields/header_component.rb +++ b/app/components/settings/user_custom_fields/header_component.rb @@ -45,7 +45,7 @@ def initialize(allow_custom_field_creation:) def breadcrumbs_items [{ href: admin_index_path, text: t("label_administration") }, { href: admin_settings_user_custom_fields_path, text: t("label_user_and_permission") }, - t("settings.user_attributes.heading")] + t("settings.user_custom_fields.heading")] end def allow_custom_field_creation? diff --git a/app/components/settings/user_custom_fields/new_form_header_component.rb b/app/components/settings/user_custom_fields/new_form_header_component.rb index 38bd818ba424..2ce044da1118 100644 --- a/app/components/settings/user_custom_fields/new_form_header_component.rb +++ b/app/components/settings/user_custom_fields/new_form_header_component.rb @@ -32,7 +32,7 @@ module Settings module UserCustomFields class NewFormHeaderComponent < ApplicationComponent def page_title - concat t("settings.user_attributes.new.heading") + concat t("settings.user_custom_fields.new.heading") concat render(Primer::Beta::Text.new(color: :muted)) { " (#{helpers.label_for_custom_field_format(model.field_format)})" } end @@ -40,9 +40,9 @@ def breadcrumb_items [ { href: admin_index_path, text: t("label_administration") }, { href: admin_settings_user_custom_fields_path, text: t("label_user_and_permission") }, - { href: admin_settings_user_custom_fields_path, text: t("settings.user_attributes.heading") }, + { href: admin_settings_user_custom_fields_path, text: t("settings.user_custom_fields.heading") }, helpers.nested_breadcrumb_element(helpers.label_for_custom_field_format(model.field_format), - t("settings.user_attributes.new.heading")) + t("settings.user_custom_fields.new.heading")) ] end end diff --git a/app/components/users/form/custom_field_section_component.rb b/app/components/users/form/custom_field_section_component.rb index d3c0de82b55d..9ab2bf9da8de 100644 --- a/app/components/users/form/custom_field_section_component.rb +++ b/app/components/users/form/custom_field_section_component.rb @@ -41,7 +41,7 @@ def initialize(section:, form:, contract:, user:) end def title - @section.name.presence || I18n.t("settings.user_attributes.label_untitled_section") + @section.name.presence || I18n.t("settings.user_custom_fields.label_untitled_section") end def built_in?(key) diff --git a/app/components/users/profile/attributes_component.html.erb b/app/components/users/profile/attributes_component.html.erb index 2aca2d14ee62..228f88dcb06a 100644 --- a/app/components/users/profile/attributes_component.html.erb +++ b/app/components/users/profile/attributes_component.html.erb @@ -16,7 +16,7 @@ sections_with_fields.each do |custom_field_section, fields| details_container.with_row(mt: 3, font_weight: :bold, color: :subtle) do - custom_field_section.name.presence || t("settings.user_attributes.label_untitled_section") + custom_field_section.name.presence || t("settings.user_custom_fields.label_untitled_section") end fields.each do |custom_field| diff --git a/app/forms/custom_fields/details_form.rb b/app/forms/custom_fields/details_form.rb index 59ec5efdf089..f19a35f6f8e0 100644 --- a/app/forms/custom_fields/details_form.rb +++ b/app/forms/custom_fields/details_form.rb @@ -55,7 +55,7 @@ class DetailsForm < ApplicationForm required: true ) do |list| section_class_for_model.all.each do |cs| # rubocop:disable Rails/FindEach -- ordered by default_scope; find_each would override it - list.option(value: cs.id, label: cs.name.presence || I18n.t("settings.user_attributes.label_untitled_section")) + list.option(value: cs.id, label: cs.name.presence || I18n.t("settings.user_custom_fields.label_untitled_section")) end end end diff --git a/app/views/admin/settings/user_custom_fields/edit.html.erb b/app/views/admin/settings/user_custom_fields/edit.html.erb index a37fef0fb325..5ac672e582ee 100644 --- a/app/views/admin/settings/user_custom_fields/edit.html.erb +++ b/app/views/admin/settings/user_custom_fields/edit.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% html_title t(:label_administration), t("settings.user_attributes.heading"), @custom_field.name %> +<% html_title t(:label_administration), t("settings.user_custom_fields.heading"), @custom_field.name %> <%= render( diff --git a/app/views/admin/settings/user_custom_fields/hierarchy/items/edit.html.erb b/app/views/admin/settings/user_custom_fields/hierarchy/items/edit.html.erb index 1cf89c4ec92e..a493d877c5ed 100644 --- a/app/views/admin/settings/user_custom_fields/hierarchy/items/edit.html.erb +++ b/app/views/admin/settings/user_custom_fields/hierarchy/items/edit.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% html_title t(:label_administration), t("settings.user_attributes.heading"), @custom_field.name, t(:label_item_plural) %> +<% html_title t(:label_administration), t("settings.user_custom_fields.heading"), @custom_field.name, t(:label_item_plural) %> <%= render(Settings::UserCustomFields::EditFormHeaderComponent.new(custom_field: @custom_field, selected: :items)) %> diff --git a/app/views/admin/settings/user_custom_fields/hierarchy/items/index.html.erb b/app/views/admin/settings/user_custom_fields/hierarchy/items/index.html.erb index 6e51fd751655..5442cb0d37bc 100644 --- a/app/views/admin/settings/user_custom_fields/hierarchy/items/index.html.erb +++ b/app/views/admin/settings/user_custom_fields/hierarchy/items/index.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% html_title t(:label_administration), t("settings.user_attributes.heading"), @custom_field.name, t(:label_item_plural) %> +<% html_title t(:label_administration), t("settings.user_custom_fields.heading"), @custom_field.name, t(:label_item_plural) %> <%= render(Settings::UserCustomFields::EditFormHeaderComponent.new(custom_field: @custom_field, selected: :items)) %> diff --git a/app/views/admin/settings/user_custom_fields/hierarchy/items/new.html.erb b/app/views/admin/settings/user_custom_fields/hierarchy/items/new.html.erb index faa3cccd0b4c..ccf51d52126a 100644 --- a/app/views/admin/settings/user_custom_fields/hierarchy/items/new.html.erb +++ b/app/views/admin/settings/user_custom_fields/hierarchy/items/new.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% html_title t(:label_administration), t("settings.user_attributes.heading"), @custom_field.name, t(:label_item_plural) %> +<% html_title t(:label_administration), t("settings.user_custom_fields.heading"), @custom_field.name, t(:label_item_plural) %> <%= render(Settings::UserCustomFields::EditFormHeaderComponent.new(custom_field: @custom_field, selected: :items)) %> diff --git a/app/views/admin/settings/user_custom_fields/index.html.erb b/app/views/admin/settings/user_custom_fields/index.html.erb index dee8d01c12a4..8f69bafa4a96 100644 --- a/app/views/admin/settings/user_custom_fields/index.html.erb +++ b/app/views/admin/settings/user_custom_fields/index.html.erb @@ -26,7 +26,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> -<% html_title t(:label_administration), t("settings.user_attributes.heading") %> +<% html_title t(:label_administration), t("settings.user_custom_fields.heading") %>
<%= render(Settings::UserCustomFields::HeaderComponent.new(allow_custom_field_creation: @allow_custom_field_creation)) %> diff --git a/app/views/admin/settings/user_custom_fields/list_items.html.erb b/app/views/admin/settings/user_custom_fields/list_items.html.erb index 7e3adfb1c150..6596320501d8 100644 --- a/app/views/admin/settings/user_custom_fields/list_items.html.erb +++ b/app/views/admin/settings/user_custom_fields/list_items.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% html_title t(:label_administration), t("settings.user_attributes.heading"), @custom_field.name %> +<% html_title t(:label_administration), t("settings.user_custom_fields.heading"), @custom_field.name %> <%= render( diff --git a/app/views/admin/settings/user_custom_fields/new.html.erb b/app/views/admin/settings/user_custom_fields/new.html.erb index 81b45f52b56e..5b4e53dd66c8 100644 --- a/app/views/admin/settings/user_custom_fields/new.html.erb +++ b/app/views/admin/settings/user_custom_fields/new.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% html_title t(:label_administration), t("settings.user_attributes.heading"), t("settings.user_attributes.new.heading") %> +<% html_title t(:label_administration), t("settings.user_custom_fields.heading"), t("settings.user_custom_fields.new.heading") %> <%= render(Settings::UserCustomFields::NewFormHeaderComponent.new(@custom_field)) %> diff --git a/app/views/custom_fields/attribute_help_texts/show_user.html.erb b/app/views/custom_fields/attribute_help_texts/show_user.html.erb index 51f3a07539fa..52058ce4e3d4 100644 --- a/app/views/custom_fields/attribute_help_texts/show_user.html.erb +++ b/app/views/custom_fields/attribute_help_texts/show_user.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% html_title t(:label_administration), t("settings.user_attributes.heading"), @custom_field.name, AttributeHelpText.human_plural_model_name %> +<% html_title t(:label_administration), t("settings.user_custom_fields.heading"), @custom_field.name, AttributeHelpText.human_plural_model_name %> <%= render( diff --git a/config/locales/en.yml b/config/locales/en.yml index f8cb20c6aa76..566b56d5161a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -5776,25 +5776,28 @@ en: default_preferences: "Default preferences" deletion: "Deletion" display_format: "Display format" - user_attributes: + user_custom_field_sections: + built_in_attribute_row_component: + label_built_in: "Built-in" + label_built_in_attribute_actions: "Built-in attribute actions" + custom_field_row_component: + label_user_custom_field_actions: "User attribute actions" + show_component: + label_add_attribute: "Add a user attribute" + label_edit_section: "Edit title" + label_no_user_custom_fields: "No user attributes defined in this section" + label_section_actions: "Section actions" + user_custom_fields: + header_component: + attribute_filter: + label: "Filter by attribute name…" + heading_description: "These user attributes appear on user profiles and information cards. You can add new attributes, group them into sections and re-order them." heading: "User attributes" - heading_description: "These user attributes appear on user profiles and information cards. You can add new attributes, group them into sections and re-order them." - label_add_attribute: "Add a user attribute" - label_built_in: "Built-in" - label_built_in_attribute_actions: "Built-in attribute actions" - label_edit_section: "Edit title" label_new_attribute: "User attribute" label_new_section: "Section" - label_no_user_custom_fields: "No user attributes defined in this section" - label_section_actions: "Section actions" label_untitled_section: "Untitled section" - label_user_custom_field_actions: "User attribute actions" new: heading: "New attribute" - user_custom_fields: - header_component: - attribute_filter: - label: "Filter by attribute name…" working_days: section_work_week: "Work week" section_holidays_and_closures: "Holidays and closures" diff --git a/spec/components/users/profile/attributes_component_spec.rb b/spec/components/users/profile/attributes_component_spec.rb index 7c9fae872050..d665d308fc78 100644 --- a/spec/components/users/profile/attributes_component_spec.rb +++ b/spec/components/users/profile/attributes_component_spec.rb @@ -78,7 +78,7 @@ let(:section) { create(:user_custom_field_section).tap { |s| s.update_column(:name, nil) } } it "renders the I18n fallback label" do - expect(page).to have_text(I18n.t("settings.user_attributes.label_untitled_section")) + expect(page).to have_text(I18n.t("settings.user_custom_fields.label_untitled_section")) end end From 1afb28cc4c4be5cc51db7475e4f4c8caa2b1530f Mon Sep 17 00:00:00 2001 From: David F Date: Fri, 12 Jun 2026 10:13:09 +0200 Subject: [PATCH 16/20] Remove user custom fields from the legacy custom fields admin. wp/72005 --- app/controllers/custom_fields_controller.rb | 6 +++--- app/helpers/custom_fields_helper.rb | 6 ------ spec/features/admin/custom_fields/users/boolean_spec.rb | 4 ++-- spec/features/admin/custom_fields/users/date_spec.rb | 4 ++-- spec/features/admin/custom_fields/users/float_spec.rb | 4 ++-- spec/features/admin/custom_fields/users/int_spec.rb | 4 ++-- spec/features/admin/custom_fields/users/list_spec.rb | 4 ++-- spec/features/admin/custom_fields/users/long_text_spec.rb | 4 ++-- spec/features/admin/custom_fields/users/text_spec.rb | 4 ++-- 9 files changed, 17 insertions(+), 23 deletions(-) diff --git a/app/controllers/custom_fields_controller.rb b/app/controllers/custom_fields_controller.rb index 70bc7741c7d3..007f530ed82c 100644 --- a/app/controllers/custom_fields_controller.rb +++ b/app/controllers/custom_fields_controller.rb @@ -48,7 +48,7 @@ class CustomFieldsController < ApplicationController def index # loading wp cfs exclicity to allow for eager loading @custom_fields_by_type = CustomField - .where.not(type: ["WorkPackageCustomField", "ProjectCustomField"]) + .where.not(type: ["WorkPackageCustomField", "ProjectCustomField", "UserCustomField"]) .group_by { |f| f.class.name } @custom_fields_by_type["WorkPackageCustomField"] = WorkPackageCustomField.includes(:types).all @@ -89,8 +89,8 @@ def find_custom_field end def check_custom_field - # ProjectCustomFields now managed in a different UI - if @custom_field.nil? || @custom_field.type == "ProjectCustomField" + # ProjectCustomFields and UserCustomFields now managed in a different UI + if @custom_field.nil? || @custom_field.type.in?(%w[ProjectCustomField UserCustomField]) flash[:error] = "Invalid CF type" redirect_to action: :index end diff --git a/app/helpers/custom_fields_helper.rb b/app/helpers/custom_fields_helper.rb index b5f68de591da..0eca25e194b5 100644 --- a/app/helpers/custom_fields_helper.rb +++ b/app/helpers/custom_fields_helper.rb @@ -49,12 +49,6 @@ def custom_fields_tabs path: custom_fields_path(tab: :VersionCustomField), label: :label_version_plural }, - { - name: "UserCustomField", - partial: "custom_fields/tab", - path: custom_fields_path(tab: :UserCustomField), - label: :label_user_plural - }, { name: "GroupCustomField", partial: "custom_fields/tab", diff --git a/spec/features/admin/custom_fields/users/boolean_spec.rb b/spec/features/admin/custom_fields/users/boolean_spec.rb index 0ead4a9ce1c2..086bb36998d2 100644 --- a/spec/features/admin/custom_fields/users/boolean_spec.rb +++ b/spec/features/admin/custom_fields/users/boolean_spec.rb @@ -29,8 +29,8 @@ # ++ require "spec_helper" -require_relative "../shared_custom_field_expectations" +require_relative "format_field_expectations" RSpec.describe "user boolean custom fields", :js do - it_behaves_like "expected fields for the custom field's format", "Users", "Boolean" + it_behaves_like "expected fields for the User custom field's format", "Boolean" end diff --git a/spec/features/admin/custom_fields/users/date_spec.rb b/spec/features/admin/custom_fields/users/date_spec.rb index b26fbdb90dc8..8fc0349e55f6 100644 --- a/spec/features/admin/custom_fields/users/date_spec.rb +++ b/spec/features/admin/custom_fields/users/date_spec.rb @@ -29,8 +29,8 @@ # ++ require "spec_helper" -require_relative "../shared_custom_field_expectations" +require_relative "format_field_expectations" RSpec.describe "user date custom fields", :js do - it_behaves_like "expected fields for the custom field's format", "Users", "Date" + it_behaves_like "expected fields for the User custom field's format", "Date" end diff --git a/spec/features/admin/custom_fields/users/float_spec.rb b/spec/features/admin/custom_fields/users/float_spec.rb index d1e2f730a123..950873e78222 100644 --- a/spec/features/admin/custom_fields/users/float_spec.rb +++ b/spec/features/admin/custom_fields/users/float_spec.rb @@ -29,8 +29,8 @@ # ++ require "spec_helper" -require_relative "../shared_custom_field_expectations" +require_relative "format_field_expectations" RSpec.describe "user float custom fields", :js do - it_behaves_like "expected fields for the custom field's format", "Users", "Float" + it_behaves_like "expected fields for the User custom field's format", "Float" end diff --git a/spec/features/admin/custom_fields/users/int_spec.rb b/spec/features/admin/custom_fields/users/int_spec.rb index 82d6b2586c65..7374c3dc478a 100644 --- a/spec/features/admin/custom_fields/users/int_spec.rb +++ b/spec/features/admin/custom_fields/users/int_spec.rb @@ -29,8 +29,8 @@ # ++ require "spec_helper" -require_relative "../shared_custom_field_expectations" +require_relative "format_field_expectations" RSpec.describe "user int custom fields", :js do - it_behaves_like "expected fields for the custom field's format", "Users", "Integer" + it_behaves_like "expected fields for the User custom field's format", "Integer" end diff --git a/spec/features/admin/custom_fields/users/list_spec.rb b/spec/features/admin/custom_fields/users/list_spec.rb index 7c36fa83b1a3..1525efe2ead1 100644 --- a/spec/features/admin/custom_fields/users/list_spec.rb +++ b/spec/features/admin/custom_fields/users/list_spec.rb @@ -29,7 +29,7 @@ # ++ require "spec_helper" -require_relative "../shared_custom_field_expectations" +require_relative "format_field_expectations" RSpec.describe "users list custom fields", :js do let(:user) { create(:admin) } @@ -93,5 +93,5 @@ expect(page).to have_field("custom_field_custom_options_attributes_2_default_value", checked: false) end - it_behaves_like "expected fields for the custom field's format", "Users", "List" + it_behaves_like "expected fields for the User custom field's format", "List" end diff --git a/spec/features/admin/custom_fields/users/long_text_spec.rb b/spec/features/admin/custom_fields/users/long_text_spec.rb index e7c6fb601640..13e37b125009 100644 --- a/spec/features/admin/custom_fields/users/long_text_spec.rb +++ b/spec/features/admin/custom_fields/users/long_text_spec.rb @@ -29,8 +29,8 @@ # ++ require "spec_helper" -require_relative "../shared_custom_field_expectations" +require_relative "format_field_expectations" RSpec.describe "users long text custom fields", :js do - it_behaves_like "expected fields for the custom field's format", "Users", "Long text" + it_behaves_like "expected fields for the User custom field's format", "Long text" end diff --git a/spec/features/admin/custom_fields/users/text_spec.rb b/spec/features/admin/custom_fields/users/text_spec.rb index 926b822ca2b2..f2edade550f4 100644 --- a/spec/features/admin/custom_fields/users/text_spec.rb +++ b/spec/features/admin/custom_fields/users/text_spec.rb @@ -29,8 +29,8 @@ # ++ require "spec_helper" -require_relative "../shared_custom_field_expectations" +require_relative "format_field_expectations" RSpec.describe "users text custom fields", :js do - it_behaves_like "expected fields for the custom field's format", "Users", "Text" + it_behaves_like "expected fields for the User custom field's format", "Text" end From 4972fceef0c0e30c4c57a3e0f33576a6726a3c6a Mon Sep 17 00:00:00 2001 From: David F Date: Fri, 12 Jun 2026 11:39:59 +0200 Subject: [PATCH 17/20] Improve UI/UX for user custom fields admin and profile. wp/72005 --- .../users/hover_card_component.html.erb | 12 +-- app/components/users/hover_card_component.rb | 17 +++- .../profile/attributes_component.html.erb | 31 +------ .../users/profile/attributes_component.rb | 8 -- .../custom_field_section_component.html.erb | 22 +++++ .../profile/custom_field_section_component.rb | 52 ++++++++++++ .../profile/side_panel_component.html.erb | 6 +- .../users/profile/side_panel_component.rb | 19 +++++ .../name_form.rb | 3 +- .../user_custom_field_sections/name_form.rb | 3 +- .../users/hover_card_component_spec.rb | 11 ++- .../profile/attributes_component_spec.rb | 50 +---------- .../custom_field_section_component_spec.rb | 82 +++++++++++++++++++ 13 files changed, 213 insertions(+), 103 deletions(-) create mode 100644 app/components/users/profile/custom_field_section_component.html.erb create mode 100644 app/components/users/profile/custom_field_section_component.rb create mode 100644 spec/components/users/profile/custom_field_section_component_spec.rb diff --git a/app/components/users/hover_card_component.html.erb b/app/components/users/hover_card_component.html.erb index 911b306552bd..51106f72d318 100644 --- a/app/components/users/hover_card_component.html.erb +++ b/app/components/users/hover_card_component.html.erb @@ -58,12 +58,14 @@ See COPYRIGHT and LICENSE files for more details. end card_sections_with_fields.flat_map(&:last).each do |custom_field| - formatted = format_multi_values(@user.formatted_custom_value_for(custom_field)) + value = @user.formatted_custom_value_for(custom_field) flex.with_row(data: { test_selector: "user-hover-card-custom-field" }) do flex_layout(classes: "op-user-hover-card--custom-field") do |cf| - cf.with_column(font_weight: :bold, mr: 2) { custom_field.name } - cf.with_column { formatted } + cf.with_column(color: :muted, mr: 2) { custom_field.name } + cf.with_column do + custom_field.multi_value? ? render_value_labels(value) : value + end end end end @@ -71,11 +73,11 @@ See COPYRIGHT and LICENSE files for more details. flex.with_row do flex_layout(classes: "op-user-hover-card--group-list") do |f| f.with_column do - render(Primer::Beta::Octicon.new(icon: :people)) + render(Primer::Beta::Octicon.new(icon: :people, color: :muted)) end f.with_column do - render(Primer::Beta::Text.new(color: :muted, data: { test_selector: "user-hover-card-groups" })) do + render(Primer::Beta::Text.new(data: { test_selector: "user-hover-card-groups" })) do group_membership_summary end end diff --git a/app/components/users/hover_card_component.rb b/app/components/users/hover_card_component.rb index cc42976a2660..9cfb4fef999e 100644 --- a/app/components/users/hover_card_component.rb +++ b/app/components/users/hover_card_component.rb @@ -68,14 +68,23 @@ def card_sections_with_fields private - def format_multi_values(value) + # Renders the values of a multi-value field as a row of Primer labels, + # capped at MULTI_VALUE_DISPLAY_LIMIT with an "and N more" overflow hint. + def render_value_labels(value) values = Array(value) - visible = safe_join(values.first(MULTI_VALUE_DISPLAY_LIMIT), ", ") remaining = values.size - MULTI_VALUE_DISPLAY_LIMIT - return visible if remaining <= 0 + labels = values.first(MULTI_VALUE_DISPLAY_LIMIT).map do |item| + render(Primer::Beta::Label.new(scheme: :accent, mr: 1)) { item } + end + + if remaining > 0 + labels << render(Primer::Beta::Text.new) do + t("custom_fields.multi_value_more", count: remaining) + end + end - safe_join([visible, t("custom_fields.multi_value_more", count: remaining)], " ") + safe_join(labels, " ") end def linked_group_names(groups) diff --git a/app/components/users/profile/attributes_component.html.erb b/app/components/users/profile/attributes_component.html.erb index 228f88dcb06a..ed2902c9206d 100644 --- a/app/components/users/profile/attributes_component.html.erb +++ b/app/components/users/profile/attributes_component.html.erb @@ -4,35 +4,12 @@ component_wrapper do flex_layout do |details_container| - if user_is_allowed_to_see_email - details_container.with_row(font_weight: :bold) do - User.human_attribute_name(:mail) - end - - details_container.with_row do - mail_to(escape_javascript(@user.mail), nil, encode: "javascript") - end + details_container.with_row(font_weight: :bold) do + User.human_attribute_name(:mail) end - sections_with_fields.each do |custom_field_section, fields| - details_container.with_row(mt: 3, font_weight: :bold, color: :subtle) do - custom_field_section.name.presence || t("settings.user_custom_fields.label_untitled_section") - end - - fields.each do |custom_field| - details_container.with_row(mt: 2, font_weight: :bold, test_selector: "user-custom-field") do - custom_field.name - end - - details_container.with_row do - value = @user.formatted_custom_value_for(custom_field) - if value.is_a?(Array) - safe_join(value, ", ") - else - value - end - end - end + details_container.with_row do + mail_to(escape_javascript(@user.mail), nil, encode: "javascript") end end end diff --git a/app/components/users/profile/attributes_component.rb b/app/components/users/profile/attributes_component.rb index ed37f49861fb..114fbd1b5628 100644 --- a/app/components/users/profile/attributes_component.rb +++ b/app/components/users/profile/attributes_component.rb @@ -42,14 +42,6 @@ def initialize(user:) end def render? - user_is_allowed_to_see_email || sections_with_fields.any? - end - - def sections_with_fields - @sections_with_fields ||= UserCustomFieldSection.with_filled_fields_for(@user) - end - - def user_is_allowed_to_see_email User.current == @user || User.current.allowed_globally?(:view_user_email) end end diff --git a/app/components/users/profile/custom_field_section_component.html.erb b/app/components/users/profile/custom_field_section_component.html.erb new file mode 100644 index 000000000000..ed359a818c37 --- /dev/null +++ b/app/components/users/profile/custom_field_section_component.html.erb @@ -0,0 +1,22 @@ +<%= + render(Primer::OpenProject::SidePanel::Section.new) do |section| + section.with_title { section_title } + + flex_layout do |fields_container| + @fields.each do |custom_field| + fields_container.with_row(mt: 2, font_weight: :bold, test_selector: "user-custom-field") do + custom_field.name + end + + fields_container.with_row do + value = @user.formatted_custom_value_for(custom_field) + if value.is_a?(Array) + safe_join(value.map { |item| render(Primer::Beta::Label.new(scheme: :accent, mr: 1)) { item } }, " ") + else + value + end + end + end + end + end +%> diff --git a/app/components/users/profile/custom_field_section_component.rb b/app/components/users/profile/custom_field_section_component.rb new file mode 100644 index 000000000000..55e9379a7066 --- /dev/null +++ b/app/components/users/profile/custom_field_section_component.rb @@ -0,0 +1,52 @@ +# 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 Users + module Profile + # Renders a single user custom field section as a side panel section, + # so each section gets its own heading and separator (provided by the + # surrounding SidePanel) consistently with the other profile sections. + class CustomFieldSectionComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + def initialize(section:, fields:, user:) + super() + + @section = section + @fields = fields + @user = user + end + + def section_title + @section.name.presence || t("settings.user_custom_fields.label_untitled_section") + end + end + end +end diff --git a/app/components/users/profile/side_panel_component.html.erb b/app/components/users/profile/side_panel_component.html.erb index 0d426180cdef..d31800be3d9d 100644 --- a/app/components/users/profile/side_panel_component.html.erb +++ b/app/components/users/profile/side_panel_component.html.erb @@ -1,11 +1,7 @@ <%= component_wrapper do render(Primer::OpenProject::SidePanel.new(spacious: true)) do |panel| - [ - Users::Profile::AttributesComponent.new(user: @user), - Users::Profile::GroupsComponent.new(user: @user), - Users::Profile::ProjectsComponent.new(user: @user) - ].each do |component| + sections.each do |component| panel.with_section(component) end end diff --git a/app/components/users/profile/side_panel_component.rb b/app/components/users/profile/side_panel_component.rb index c7accb543c84..b8d8e8676f02 100644 --- a/app/components/users/profile/side_panel_component.rb +++ b/app/components/users/profile/side_panel_component.rb @@ -40,6 +40,25 @@ def initialize(user:) @user = user end + + # Each entry becomes its own SidePanel section, so headings and separators + # are provided consistently by the panel rather than drawn by hand. + def sections + [ + Users::Profile::AttributesComponent.new(user: @user), + *custom_field_section_components, + Users::Profile::GroupsComponent.new(user: @user), + Users::Profile::ProjectsComponent.new(user: @user) + ] + end + + private + + def custom_field_section_components + UserCustomFieldSection.with_filled_fields_for(@user).map do |section, fields| + Users::Profile::CustomFieldSectionComponent.new(section:, fields:, user: @user) + end + end end end end diff --git a/app/forms/project_custom_field_sections/name_form.rb b/app/forms/project_custom_field_sections/name_form.rb index 54d94661962c..2486c805e348 100644 --- a/app/forms/project_custom_field_sections/name_form.rb +++ b/app/forms/project_custom_field_sections/name_form.rb @@ -35,7 +35,8 @@ class ProjectCustomFieldSections::NameForm < ApplicationForm placeholder: ProjectCustomFieldSection.human_attribute_name(:name), label: ProjectCustomFieldSection.human_attribute_name(:name), required: true, - autofocus: true + autofocus: true, + autocomplete: "off" ) end end diff --git a/app/forms/user_custom_field_sections/name_form.rb b/app/forms/user_custom_field_sections/name_form.rb index b3381c123eac..f1bb32ec6367 100644 --- a/app/forms/user_custom_field_sections/name_form.rb +++ b/app/forms/user_custom_field_sections/name_form.rb @@ -35,7 +35,8 @@ class UserCustomFieldSections::NameForm < ApplicationForm placeholder: UserCustomFieldSection.human_attribute_name(:name), label: UserCustomFieldSection.human_attribute_name(:name), required: true, - autofocus: true + autofocus: true, + autocomplete: "off" ) end end diff --git a/spec/components/users/hover_card_component_spec.rb b/spec/components/users/hover_card_component_spec.rb index db9d7b47f0cb..ac3067d4c402 100644 --- a/spec/components/users/hover_card_component_spec.rb +++ b/spec/components/users/hover_card_component_spec.rb @@ -195,9 +195,14 @@ custom_values: multi_cf.possible_values.map { |opt| build(:custom_value, custom_field: multi_cf, value: opt) }) end - it "shows the first 3 values and a count of the rest" do - expect(page).to have_test_selector("user-hover-card-custom-field", text: "Ruby, Rails, React") - expect(page).to have_test_selector("user-hover-card-custom-field", text: "and 2 more") + it "shows the first 3 values as labels and a count of the rest" do + field = find_test_selector("user-hover-card-custom-field") + + expect(field).to have_css("span.Label", text: "Ruby") + expect(field).to have_css("span.Label", text: "Rails") + expect(field).to have_css("span.Label", text: "React") + expect(field).to have_no_css("span.Label", text: "Vue") + expect(field).to have_text("and 2 more") end end end diff --git a/spec/components/users/profile/attributes_component_spec.rb b/spec/components/users/profile/attributes_component_spec.rb index d665d308fc78..9afbf910e586 100644 --- a/spec/components/users/profile/attributes_component_spec.rb +++ b/spec/components/users/profile/attributes_component_spec.rb @@ -52,56 +52,8 @@ it { is_expected.to be(true) } end - context "when user has no view_user_email permission and no filled custom fields" do + context "when user cannot see the email" do it { is_expected.to be(false) } end end - - describe "rendering custom fields" do - let(:section) { create(:user_custom_field_section, name: "Profile info") } - let(:custom_field) { create(:user_custom_field, :string, user_custom_field_section: section) } - let(:user) { create(:user, custom_values: [build(:custom_value, custom_field:, value: "Hello custom field")]) } - - current_user { build(:admin) } - - before { render_inline(component) } - - it "renders the field value" do - expect(page).to have_text("Hello custom field") - end - - it "renders the section name as a heading" do - expect(page).to have_text("Profile info") - end - - context "with an untitled section" do - let(:section) { create(:user_custom_field_section).tap { |s| s.update_column(:name, nil) } } - - it "renders the I18n fallback label" do - expect(page).to have_text(I18n.t("settings.user_custom_fields.label_untitled_section")) - end - end - - context "with a multi-select field" do - let(:custom_field) { create(:user_custom_field, :multi_list, user_custom_field_section: section) } - let(:user) do - create(:user, custom_values: custom_field.possible_values.first(3).map do |v| - build(:custom_value, custom_field:, value: v) - end) - end - - it "renders values as a comma-separated list" do - expect(page).to have_text("A, B, C") - end - end - - context "with a formattable text field" do - let(:custom_field) { create(:user_custom_field, :text, user_custom_field_section: section) } - let(:user) { create(:user, custom_values: [build(:custom_value, custom_field:, value: "This is **formatted** text.")]) } - - it "renders the value as HTML" do - expect(page).to have_css("strong", text: "formatted") - end - end - end end diff --git a/spec/components/users/profile/custom_field_section_component_spec.rb b/spec/components/users/profile/custom_field_section_component_spec.rb new file mode 100644 index 000000000000..8c672c427860 --- /dev/null +++ b/spec/components/users/profile/custom_field_section_component_spec.rb @@ -0,0 +1,82 @@ +# 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 Users::Profile::CustomFieldSectionComponent, type: :component do + let(:section) { create(:user_custom_field_section, name: "Profile info") } + let(:custom_field) { create(:user_custom_field, :string, user_custom_field_section: section) } + let(:user) { create(:user, custom_values: [build(:custom_value, custom_field:, value: "Hello custom field")]) } + let(:component) { described_class.new(section:, fields: [custom_field], user:) } + + current_user { build(:admin) } + + before { render_inline(component) } + + it "renders the field value" do + expect(page).to have_text("Hello custom field") + end + + it "renders the section name as a heading" do + expect(page).to have_text("Profile info") + end + + context "with an untitled section" do + let(:section) { create(:user_custom_field_section).tap { |s| s.update_column(:name, nil) } } + + it "renders the I18n fallback label" do + expect(page).to have_text(I18n.t("settings.user_custom_fields.label_untitled_section")) + end + end + + context "with a multi-select field" do + let(:custom_field) { create(:user_custom_field, :multi_list, user_custom_field_section: section) } + let(:user) do + create(:user, custom_values: custom_field.possible_values.first(3).map do |v| + build(:custom_value, custom_field:, value: v) + end) + end + + it "renders each value as a label" do + expect(page).to have_css("span.Label", text: "A") + expect(page).to have_css("span.Label", text: "B") + expect(page).to have_css("span.Label", text: "C") + end + end + + context "with a formattable text field" do + let(:custom_field) { create(:user_custom_field, :text, user_custom_field_section: section) } + let(:user) { create(:user, custom_values: [build(:custom_value, custom_field:, value: "This is **formatted** text.")]) } + + it "renders the value as HTML" do + expect(page).to have_css("strong", text: "formatted") + end + end +end From 491bf34597aa1c737bb6e236dbe6b009b24154b9 Mon Sep 17 00:00:00 2001 From: David F Date: Fri, 12 Jun 2026 14:27:04 +0200 Subject: [PATCH 18/20] Show built-in attributes on user profile page. wp/72005 --- .../profile/attributes_component.html.erb | 17 --- .../attributes_section_component.html.erb | 21 +++ ...ent.rb => attributes_section_component.rb} | 19 ++- .../custom_field_section_component.html.erb | 22 --- ...utes_component.rb => section_attribute.rb} | 19 ++- .../users/profile/section_attributes.rb | 109 ++++++++++++++ .../users/profile/side_panel_component.rb | 9 +- .../profile/attributes_component_spec.rb | 59 -------- .../attributes_section_component_spec.rb | 116 +++++++++++++++ .../custom_field_section_component_spec.rb | 82 ----------- .../users/profile/section_attributes_spec.rb | 135 ++++++++++++++++++ spec/features/users/show_spec.rb | 24 ++++ 12 files changed, 431 insertions(+), 201 deletions(-) delete mode 100644 app/components/users/profile/attributes_component.html.erb create mode 100644 app/components/users/profile/attributes_section_component.html.erb rename app/components/users/profile/{custom_field_section_component.rb => attributes_section_component.rb} (75%) delete mode 100644 app/components/users/profile/custom_field_section_component.html.erb rename app/components/users/profile/{attributes_component.rb => section_attribute.rb} (78%) create mode 100644 app/components/users/profile/section_attributes.rb delete mode 100644 spec/components/users/profile/attributes_component_spec.rb create mode 100644 spec/components/users/profile/attributes_section_component_spec.rb delete mode 100644 spec/components/users/profile/custom_field_section_component_spec.rb create mode 100644 spec/components/users/profile/section_attributes_spec.rb diff --git a/app/components/users/profile/attributes_component.html.erb b/app/components/users/profile/attributes_component.html.erb deleted file mode 100644 index ed2902c9206d..000000000000 --- a/app/components/users/profile/attributes_component.html.erb +++ /dev/null @@ -1,17 +0,0 @@ -<%= - render(Primer::OpenProject::SidePanel::Section.new) do |section| - section.with_title { t(:label_information) } - - component_wrapper do - flex_layout do |details_container| - details_container.with_row(font_weight: :bold) do - User.human_attribute_name(:mail) - end - - details_container.with_row do - mail_to(escape_javascript(@user.mail), nil, encode: "javascript") - end - end - end - end -%> diff --git a/app/components/users/profile/attributes_section_component.html.erb b/app/components/users/profile/attributes_section_component.html.erb new file mode 100644 index 000000000000..74a455e45c56 --- /dev/null +++ b/app/components/users/profile/attributes_section_component.html.erb @@ -0,0 +1,21 @@ +<%= + render(Primer::OpenProject::SidePanel::Section.new) do |section| + section.with_title { section_title } + + flex_layout do |container| + attributes.each do |attribute| + container.with_row(mt: 2, font_weight: :bold, test_selector: "user-profile-attribute") do + attribute.label + end + + container.with_row do + if attribute.multi_value? + safe_join(attribute.value.map { |item| render(Primer::Beta::Label.new(scheme: :accent, mr: 1)) { item } }, " ") + else + attribute.value + end + end + end + end + end +%> diff --git a/app/components/users/profile/custom_field_section_component.rb b/app/components/users/profile/attributes_section_component.rb similarity index 75% rename from app/components/users/profile/custom_field_section_component.rb rename to app/components/users/profile/attributes_section_component.rb index 55e9379a7066..59ad8b97c5b1 100644 --- a/app/components/users/profile/custom_field_section_component.rb +++ b/app/components/users/profile/attributes_section_component.rb @@ -30,20 +30,27 @@ module Users module Profile - # Renders a single user custom field section as a side panel section, - # so each section gets its own heading and separator (provided by the - # surrounding SidePanel) consistently with the other profile sections. - class CustomFieldSectionComponent < ApplicationComponent + # Renders a single user custom field section (its built-in attributes and custom + # fields, in attribute_order) as a side panel section. Empty sections are skipped + # by the panel via render?. + class AttributesSectionComponent < ApplicationComponent include OpPrimer::ComponentHelpers - def initialize(section:, fields:, user:) + def initialize(section:, user:) super() @section = section - @fields = fields @user = user end + def render? + attributes.any? + end + + def attributes + @attributes ||= SectionAttributes.for(section: @section, user: @user, current_user: User.current) + end + def section_title @section.name.presence || t("settings.user_custom_fields.label_untitled_section") end diff --git a/app/components/users/profile/custom_field_section_component.html.erb b/app/components/users/profile/custom_field_section_component.html.erb deleted file mode 100644 index ed359a818c37..000000000000 --- a/app/components/users/profile/custom_field_section_component.html.erb +++ /dev/null @@ -1,22 +0,0 @@ -<%= - render(Primer::OpenProject::SidePanel::Section.new) do |section| - section.with_title { section_title } - - flex_layout do |fields_container| - @fields.each do |custom_field| - fields_container.with_row(mt: 2, font_weight: :bold, test_selector: "user-custom-field") do - custom_field.name - end - - fields_container.with_row do - value = @user.formatted_custom_value_for(custom_field) - if value.is_a?(Array) - safe_join(value.map { |item| render(Primer::Beta::Label.new(scheme: :accent, mr: 1)) { item } }, " ") - else - value - end - end - end - end - end -%> diff --git a/app/components/users/profile/attributes_component.rb b/app/components/users/profile/section_attribute.rb similarity index 78% rename from app/components/users/profile/attributes_component.rb rename to app/components/users/profile/section_attribute.rb index 114fbd1b5628..21a1bd1b8ba7 100644 --- a/app/components/users/profile/attributes_component.rb +++ b/app/components/users/profile/section_attribute.rb @@ -30,19 +30,18 @@ module Users module Profile - class AttributesComponent < ApplicationComponent - include ApplicationHelper - include OpTurbo::Streamable - include OpPrimer::ComponentHelpers + # A single renderable profile attribute (built-in or custom field). + # `value` is a scalar for single values, or an Array for multi-value custom fields. + class SectionAttribute + attr_reader :label, :value - def initialize(user:) - super() - - @user = user + def initialize(label:, value:) + @label = label + @value = value end - def render? - User.current == @user || User.current.allowed_globally?(:view_user_email) + def multi_value? + value.is_a?(Array) end end end diff --git a/app/components/users/profile/section_attributes.rb b/app/components/users/profile/section_attributes.rb new file mode 100644 index 000000000000..3fdf76bfcafd --- /dev/null +++ b/app/components/users/profile/section_attributes.rb @@ -0,0 +1,109 @@ +# 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 Users + module Profile + # Builds the ordered list of renderable SectionAttribute objects for a section, + # filtered by per-attribute visibility (mirroring API::V3::Users::UserRepresenter) + # and by value presence. Built-in keys resolve to user attributes; other keys + # resolve to the section's custom fields. + class SectionAttributes + include Redmine::I18n + + def self.for(section:, user:, current_user:) + new(section:, user:, current_user:).call + end + + def initialize(section:, user:, current_user:) + @section = section + @user = user + @current_user = current_user + end + + def call + cf_by_key = @section.custom_fields_by_key + + @section.attribute_order.filter_map do |key| + if UserCustomFieldSection::BUILT_IN_ATTRIBUTES.include?(key) + built_in_attribute(key) + elsif (custom_field = cf_by_key[key]) + custom_field_attribute(custom_field) + end + end + end + + private + + def built_in_attribute(key) + return unless built_in_visible?(key) + + value = built_in_value(key) + return if value.blank? + + SectionAttribute.new(label: User.human_attribute_name(key), value:) + end + + def custom_field_attribute(custom_field) + return unless custom_field.visible?(@current_user) + + value = @user.formatted_custom_value_for(custom_field) + return if value.blank? + + SectionAttribute.new(label: custom_field.name, value:) + end + + def built_in_value(key) + if key == "language" + @user.language.presence && translate_language(@user.language).first + else + @user.public_send(key) + end + end + + def built_in_visible?(key) + if key == "mail" + can_view_email? || can_manage? + else + can_manage? + end + end + + def can_manage? + @current_user.allowed_globally?(:manage_user) || + @current_user.allowed_globally?(:create_user) || + @current_user == @user + end + + def can_view_email? + @current_user.allowed_globally?(:view_user_email) + end + end + end +end diff --git a/app/components/users/profile/side_panel_component.rb b/app/components/users/profile/side_panel_component.rb index b8d8e8676f02..86d29c75daea 100644 --- a/app/components/users/profile/side_panel_component.rb +++ b/app/components/users/profile/side_panel_component.rb @@ -45,8 +45,7 @@ def initialize(user:) # are provided consistently by the panel rather than drawn by hand. def sections [ - Users::Profile::AttributesComponent.new(user: @user), - *custom_field_section_components, + *attributes_section_components, Users::Profile::GroupsComponent.new(user: @user), Users::Profile::ProjectsComponent.new(user: @user) ] @@ -54,9 +53,9 @@ def sections private - def custom_field_section_components - UserCustomFieldSection.with_filled_fields_for(@user).map do |section, fields| - Users::Profile::CustomFieldSectionComponent.new(section:, fields:, user: @user) + def attributes_section_components + UserCustomFieldSection.includes(:custom_fields).map do |section| + Users::Profile::AttributesSectionComponent.new(section:, user: @user) end end end diff --git a/spec/components/users/profile/attributes_component_spec.rb b/spec/components/users/profile/attributes_component_spec.rb deleted file mode 100644 index 9afbf910e586..000000000000 --- a/spec/components/users/profile/attributes_component_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -# 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 Users::Profile::AttributesComponent, type: :component do - shared_let(:logged_in_user) { create(:user) } - let(:user) { build(:user) } - let(:component) { described_class.new(user:) } - - current_user { logged_in_user } - - describe "render?" do - subject { component.render? } - - context "when user has view_user_email permission" do - before { create(:standard_global_role) } - - it { is_expected.to be(true) } - end - - context "when user views its own profile" do - current_user { user } - - it { is_expected.to be(true) } - end - - context "when user cannot see the email" do - it { is_expected.to be(false) } - end - end -end diff --git a/spec/components/users/profile/attributes_section_component_spec.rb b/spec/components/users/profile/attributes_section_component_spec.rb new file mode 100644 index 000000000000..38f0fed29c8a --- /dev/null +++ b/spec/components/users/profile/attributes_section_component_spec.rb @@ -0,0 +1,116 @@ +# 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 Users::Profile::AttributesSectionComponent, type: :component do + shared_let(:section) { create(:user_custom_field_section, name: "Profile info") } + let(:component) { described_class.new(section:, user:) } + + current_user { build(:admin) } + + context "with built-in and custom field attributes" do + shared_let(:custom_field) do + create(:user_custom_field, :string, name: "Job title", user_custom_field_section: section) + end + let(:user) do + create(:user, firstname: "Sarah", + custom_values: [build(:custom_value, custom_field:, value: "Developer")]) + end + + before do + section.update_column(:attribute_order, ["firstname", custom_field.column_name]) + render_inline(component) + end + + it "renders the section name as the title" do + expect(page).to have_text("Profile info") + end + + it "renders the built-in label and value" do + expect(page).to have_text(User.human_attribute_name("firstname")) + expect(page).to have_text("Sarah") + end + + it "renders the custom field label and value" do + expect(page).to have_text("Job title") + expect(page).to have_text("Developer") + end + end + + context "with a multi-value custom field" do + shared_let(:custom_field) do + create(:user_custom_field, :multi_list, name: "Skills", user_custom_field_section: section) + end + let(:user) do + create(:user, custom_values: custom_field.possible_values.first(3).map do |v| + build(:custom_value, custom_field:, value: v) + end) + end + + before do + section.update_column(:attribute_order, [custom_field.column_name]) + render_inline(component) + end + + it "renders each value as an accent label" do + expect(page).to have_css("span.Label", text: "A") + expect(page).to have_css("span.Label", text: "B") + expect(page).to have_css("span.Label", text: "C") + end + end + + context "when the section has no visible attributes" do + let(:user) { create(:user) } + + before { section.update_column(:attribute_order, []) } + + it "does not render" do + expect(component.render?).to be(false) + end + end + + context "with an untitled section" do + shared_let(:custom_field) do + create(:user_custom_field, :string, user_custom_field_section: section) + end + let(:user) { create(:user, custom_values: [build(:custom_value, custom_field:, value: "x")]) } + + before do + section.update_column(:name, nil) + section.update_column(:attribute_order, [custom_field.column_name]) + render_inline(component) + end + + it "renders the I18n fallback label" do + expect(page).to have_text(I18n.t("settings.user_custom_fields.label_untitled_section")) + end + end +end diff --git a/spec/components/users/profile/custom_field_section_component_spec.rb b/spec/components/users/profile/custom_field_section_component_spec.rb deleted file mode 100644 index 8c672c427860..000000000000 --- a/spec/components/users/profile/custom_field_section_component_spec.rb +++ /dev/null @@ -1,82 +0,0 @@ -# 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 Users::Profile::CustomFieldSectionComponent, type: :component do - let(:section) { create(:user_custom_field_section, name: "Profile info") } - let(:custom_field) { create(:user_custom_field, :string, user_custom_field_section: section) } - let(:user) { create(:user, custom_values: [build(:custom_value, custom_field:, value: "Hello custom field")]) } - let(:component) { described_class.new(section:, fields: [custom_field], user:) } - - current_user { build(:admin) } - - before { render_inline(component) } - - it "renders the field value" do - expect(page).to have_text("Hello custom field") - end - - it "renders the section name as a heading" do - expect(page).to have_text("Profile info") - end - - context "with an untitled section" do - let(:section) { create(:user_custom_field_section).tap { |s| s.update_column(:name, nil) } } - - it "renders the I18n fallback label" do - expect(page).to have_text(I18n.t("settings.user_custom_fields.label_untitled_section")) - end - end - - context "with a multi-select field" do - let(:custom_field) { create(:user_custom_field, :multi_list, user_custom_field_section: section) } - let(:user) do - create(:user, custom_values: custom_field.possible_values.first(3).map do |v| - build(:custom_value, custom_field:, value: v) - end) - end - - it "renders each value as a label" do - expect(page).to have_css("span.Label", text: "A") - expect(page).to have_css("span.Label", text: "B") - expect(page).to have_css("span.Label", text: "C") - end - end - - context "with a formattable text field" do - let(:custom_field) { create(:user_custom_field, :text, user_custom_field_section: section) } - let(:user) { create(:user, custom_values: [build(:custom_value, custom_field:, value: "This is **formatted** text.")]) } - - it "renders the value as HTML" do - expect(page).to have_css("strong", text: "formatted") - end - end -end diff --git a/spec/components/users/profile/section_attributes_spec.rb b/spec/components/users/profile/section_attributes_spec.rb new file mode 100644 index 000000000000..042b6506d480 --- /dev/null +++ b/spec/components/users/profile/section_attributes_spec.rb @@ -0,0 +1,135 @@ +# 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 Users::Profile::SectionAttributes do + shared_let(:section) { create(:user_custom_field_section, name: "Profile info") } + shared_let(:custom_field) do + create(:user_custom_field, :string, name: "Job title", user_custom_field_section: section) + end + shared_let(:user) do + create(:user, + firstname: "Sarah", + lastname: "Chen", + login: "schen", + mail: "s.chen@example.com", + language: "en", + custom_values: [build(:custom_value, custom_field:, value: "Developer")]) + end + + # attribute_order interleaves built-ins and the custom field + before do + section.update_column(:attribute_order, ["firstname", custom_field.column_name, "mail", "language"]) + end + + subject(:attributes) { described_class.for(section:, user:, current_user:) } + + def labels = attributes.map(&:label) + def values = attributes.map(&:value) + + context "when current_user is the profile owner (self)" do + let(:current_user) { user } + + it "returns built-ins and custom fields in attribute_order" do + expect(labels).to eq([ + User.human_attribute_name("firstname"), + "Job title", + User.human_attribute_name("mail"), + User.human_attribute_name("language") + ]) + expect(values).to eq(["Sarah", "Developer", "s.chen@example.com", "English"]) + end + end + + context "when current_user can manage users" do + let(:current_user) { create(:user) } + + before { mock_permissions_for(current_user) { |mock| mock.allow_globally(:manage_user) } } + + it "shows the manage-gated built-ins and the email" do + expect(labels).to include(User.human_attribute_name("firstname"), + User.human_attribute_name("mail"), + User.human_attribute_name("language")) + end + end + + context "when current_user only has view_user_email" do + let(:current_user) { create(:user) } + + before { mock_permissions_for(current_user) { |mock| mock.allow_globally(:view_user_email) } } + + it "shows the email but not the manage-gated built-ins" do + expect(labels).to include(User.human_attribute_name("mail"), "Job title") + expect(labels).not_to include(User.human_attribute_name("firstname"), + User.human_attribute_name("language")) + end + end + + context "when current_user is a regular viewer" do + let(:current_user) { create(:user) } + + before { mock_permissions_for(current_user) {} } # rubocop:disable Lint/EmptyBlock -- viewer with no global permissions + + it "shows no gated built-ins (only the visible custom field)" do + expect(labels).to eq(["Job title"]) + end + end + + context "with a blank built-in value" do + let(:current_user) { user } + + before { user.update_column(:language, "") } + + it "skips the blank attribute" do + expect(labels).not_to include(User.human_attribute_name("language")) + end + end + + context "with a multi-value custom field" do + shared_let(:multi_cf) do + create(:user_custom_field, :multi_list, name: "Skills", user_custom_field_section: section) + end + let(:current_user) { user } + let(:user) do + create(:user, custom_values: multi_cf.possible_values.first(2).map do |v| + build(:custom_value, custom_field: multi_cf, value: v) + end) + end + + before { section.update_column(:attribute_order, [multi_cf.column_name]) } + + it "exposes the values as an array and flags multi_value?" do + attribute = attributes.first + expect(attribute.value).to eq(%w[A B]) + expect(attribute).to be_multi_value + end + end +end diff --git a/spec/features/users/show_spec.rb b/spec/features/users/show_spec.rb index e46bb56ee476..b90a8f5a301d 100644 --- a/spec/features/users/show_spec.rb +++ b/spec/features/users/show_spec.rb @@ -69,4 +69,28 @@ expect(page).to have_no_text group.name end end + + describe "built-in and custom field attributes in the side panel" do + shared_let(:section) { create(:user_custom_field_section, name: "Public profile") } + shared_let(:custom_field) do + create(:user_custom_field, :string, name: "Job title", user_custom_field_section: section) + end + shared_let(:profile_user) do + create(:user, firstname: "Sarah", mail: "s.chen@example.com", + custom_values: [build(:custom_value, custom_field:, value: "Developer")]) + end + + before do + section.update_column(:attribute_order, ["firstname", "mail", custom_field.column_name]) + visit user_path(profile_user) + end + + it "shows built-in attributes alongside custom fields" do + expect(page).to have_text(User.human_attribute_name("firstname")) + expect(page).to have_text("Sarah") + expect(page).to have_text("s.chen@example.com") + expect(page).to have_text("Job title") + expect(page).to have_text("Developer") + end + end end From 211e72c57f1d3345ecf5bcd814ca42a371a4105a Mon Sep 17 00:00:00 2001 From: David F Date: Fri, 12 Jun 2026 14:50:13 +0200 Subject: [PATCH 19/20] Send back flash messages on section action error. wp/72005 --- .../project_custom_field_sections_controller.rb | 14 +++++++++++--- .../user_custom_field_sections_controller.rb | 17 ++++++++++++++--- config/locales/en.yml | 14 ++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/app/controllers/admin/settings/project_custom_field_sections_controller.rb b/app/controllers/admin/settings/project_custom_field_sections_controller.rb index 54c332141924..3b9f1f1ae167 100644 --- a/app/controllers/admin/settings/project_custom_field_sections_controller.rb +++ b/app/controllers/admin/settings/project_custom_field_sections_controller.rb @@ -74,7 +74,7 @@ def destroy update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) else - # TODO: show error message + render_section_error_via_turbo_stream(call) end respond_with_turbo_streams @@ -88,7 +88,7 @@ def move if call.success? update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) else - # TODO: show error message + render_section_error_via_turbo_stream(call) end respond_with_turbo_streams @@ -103,7 +103,7 @@ def drop update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) else - # TODO: show error message + render_section_error_via_turbo_stream(call) end respond_with_turbo_streams end @@ -114,6 +114,14 @@ def new_link private + # Show a danger toast with the action's hint (resolved relative to the + # controller/action), appending the service's error detail (e.g. why a + # non-empty section cannot be deleted) when present. + def render_section_error_via_turbo_stream(call) + message = [t(".error"), call.message].compact_blank.join(" ") + render_error_flash_message_via_turbo_stream(message:) + end + def set_project_custom_field_section @project_custom_field_section = ProjectCustomFieldSection.find(params[:id]) end diff --git a/app/controllers/admin/settings/user_custom_field_sections_controller.rb b/app/controllers/admin/settings/user_custom_field_sections_controller.rb index bd3168362f75..e057b0167c90 100644 --- a/app/controllers/admin/settings/user_custom_field_sections_controller.rb +++ b/app/controllers/admin/settings/user_custom_field_sections_controller.rb @@ -72,7 +72,8 @@ def destroy if call.success? update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) update_sections_via_turbo_stream(user_custom_field_sections: UserCustomFieldSection.all) - # TODO: show error message on failure + else + render_section_error_via_turbo_stream(call) end respond_with_turbo_streams @@ -85,7 +86,8 @@ def move if call.success? update_sections_via_turbo_stream(user_custom_field_sections: UserCustomFieldSection.all) - # TODO: show error message on failure + else + render_section_error_via_turbo_stream(call) end respond_with_turbo_streams @@ -99,7 +101,8 @@ def drop if call.success? update_header_via_turbo_stream(allow_custom_field_creation: allow_custom_field_creation?) update_sections_via_turbo_stream(user_custom_field_sections: UserCustomFieldSection.all) - # TODO: show error message on failure + else + render_section_error_via_turbo_stream(call) end respond_with_turbo_streams @@ -111,6 +114,14 @@ def new_link private + # Show a danger toast with the action's hint (resolved relative to the + # controller/action), appending the service's error detail (e.g. why a + # non-empty section cannot be deleted) when present. + def render_section_error_via_turbo_stream(call) + message = [t(".error"), call.message].compact_blank.join(" ") + render_error_flash_message_via_turbo_stream(message:) + end + def set_user_custom_field_section @user_custom_field_section = UserCustomFieldSection.find(params.expect(:id)) end diff --git a/config/locales/en.yml b/config/locales/en.yml index 566b56d5161a..b6081df4d241 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -445,6 +445,20 @@ en:

Hello,

A new project has been created: projectValue:name

Thank you

+ project_custom_field_sections: + destroy: + error: "The section could not be deleted." + drop: + error: "The section could not be reordered." + move: + error: "The section could not be reordered." + user_custom_field_sections: + destroy: + error: "The section could not be deleted." + drop: + error: "The section could not be reordered." + move: + error: "The section could not be reordered." work_packages_identifier: page_header: description: Choose between classic numerical work package IDs or semantic project-specific ones that prepend the project identifier to the work package ID. From 415626bdd3cee0412e3ff308d042a9d3b2d2df82 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 23 Jun 2026 16:23:57 +0200 Subject: [PATCH 20/20] Remove usage of now removed feature flag --- .../lib/open_project/ldap_departments/engine.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/ldap_departments/lib/open_project/ldap_departments/engine.rb b/modules/ldap_departments/lib/open_project/ldap_departments/engine.rb index 0c51e5cfb554..84568832ec54 100644 --- a/modules/ldap_departments/lib/open_project/ldap_departments/engine.rb +++ b/modules/ldap_departments/lib/open_project/ldap_departments/engine.rb @@ -18,8 +18,7 @@ class Engine < ::Rails::Engine parent: :authentication, after: :plugin_ldap_groups, caption: ->(*) { I18n.t("ldap_departments.label_menu_item") }, - enterprise_feature: "ldap_groups", - if: ->(*) { OpenProject::FeatureDecisions.departments_active? } + enterprise_feature: "ldap_groups" end add_cron_jobs do