From 3237096fe577a90e6108281fae71eab2aa970ba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 25 Nov 2025 21:11:31 +0100 Subject: [PATCH] Add tab to project custom fields settings --- .../edit_form_header_component.rb | 7 + .../index_page_header_component.html.erb | 2 +- .../index_page_header_component.rb | 2 +- .../edit_form_header_component.rb | 7 + .../project_custom_fields_controller.rb | 52 +++- app/forms/attribute_help_texts/form.rb | 44 +++- app/models/application_record.rb | 8 + .../attribute_help_text.html.erb | 54 ++++ app/views/attribute_help_texts/edit.html.erb | 2 +- app/views/attribute_help_texts/index.html.erb | 2 +- app/views/attribute_help_texts/new.html.erb | 2 +- config/initializers/menus.rb | 2 +- config/locales/en.yml | 5 +- config/routes.rb | 3 + .../attribute_help_text_spec.rb | 235 ++++++++++++++++++ 15 files changed, 405 insertions(+), 22 deletions(-) create mode 100644 app/views/admin/settings/project_custom_fields/attribute_help_text.html.erb create mode 100644 spec/features/admin/settings/project_custom_fields/attribute_help_text_spec.rb diff --git a/app/components/admin/custom_fields/edit_form_header_component.rb b/app/components/admin/custom_fields/edit_form_header_component.rb index c96877ca656..6c54bcd9ffa 100644 --- a/app/components/admin/custom_fields/edit_form_header_component.rb +++ b/app/components/admin/custom_fields/edit_form_header_component.rb @@ -62,6 +62,13 @@ module Admin path: custom_field_projects_path(@custom_field), label: t(:label_project_plural) } + + tabs << + { + name: "attribute_help_text", + path: attribute_help_text_admin_settings_project_custom_field_path(@custom_field), + label: AttributeHelpText.human_plural_model_name + } end tabs diff --git a/app/components/attribute_help_texts/index_page_header_component.html.erb b/app/components/attribute_help_texts/index_page_header_component.html.erb index 64a2f269b66..ee807f61e0d 100644 --- a/app/components/attribute_help_texts/index_page_header_component.html.erb +++ b/app/components/attribute_help_texts/index_page_header_component.html.erb @@ -28,7 +28,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= render(Primer::OpenProject::PageHeader.new) do |header| - header.with_title { I18n.t(:"attribute_help_texts.label_plural") } + header.with_title { I18n.AttributeHelpText.human_plural_model_name } header.with_breadcrumbs(breadcrumb_items, selected_item_font_weight: :normal) helpers.render_tab_header_nav(header, @tabs) diff --git a/app/components/attribute_help_texts/index_page_header_component.rb b/app/components/attribute_help_texts/index_page_header_component.rb index 5c1615f05e6..01b638282e4 100644 --- a/app/components/attribute_help_texts/index_page_header_component.rb +++ b/app/components/attribute_help_texts/index_page_header_component.rb @@ -41,7 +41,7 @@ class AttributeHelpTexts::IndexPageHeaderComponent < ApplicationComponent def breadcrumb_items [ { href: admin_index_path, text: t("label_administration") }, - helpers.nested_breadcrumb_element(t(:"attribute_help_texts.label_plural"), + helpers.nested_breadcrumb_element(AttributeHelpText.human_plural_model_name, I18n.t(currently_selected_tab[:label].to_s)) ] end diff --git a/app/components/settings/project_custom_fields/edit_form_header_component.rb b/app/components/settings/project_custom_fields/edit_form_header_component.rb index 58d6c0c5f47..9ca9bb1410a 100644 --- a/app/components/settings/project_custom_fields/edit_form_header_component.rb +++ b/app/components/settings/project_custom_fields/edit_form_header_component.rb @@ -68,6 +68,13 @@ module Settings label: t(:label_project_mappings) } + tabs << + { + name: "attribute_help_text", + path: attribute_help_text_admin_settings_project_custom_field_path(@custom_field), + label: AttributeHelpText.human_plural_model_name + } + tabs end diff --git a/app/controllers/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb index acdeb0a0f0e..d0693cf59a4 100644 --- a/app/controllers/admin/settings/project_custom_fields_controller.rb +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -41,13 +41,15 @@ module Admin::Settings before_action :set_sections, only: %i[show index edit update move drop] before_action :find_custom_field, only: %i(show edit project_mappings new_link link unlink update destroy delete_option reorder_alphabetical - move drop role_assignment update_role_assignment role_assignment_preview_dialog) + move drop role_assignment update_role_assignment role_assignment_preview_dialog + attribute_help_text update_attribute_help_text) before_action :prepare_custom_option_position, only: %i(update create) before_action :find_custom_option, only: :delete_option before_action :project_custom_field_mappings_query, only: %i[project_mappings unlink] before_action :find_custom_field_projects_to_link, only: :link before_action :find_unlink_project_custom_field_mapping, only: :unlink before_action :prepare_role_assignment_form, only: %i[role_assignment update_role_assignment] + before_action :find_or_initialize_attribute_help_text, only: %i[attribute_help_text update_attribute_help_text] # rubocop:enable Rails/LexicallyScopedActionFilter def index @@ -184,6 +186,24 @@ module Admin::Settings respond_with_turbo_streams end + def attribute_help_text; end + + def update_attribute_help_text + service_class = @attribute_help_text.persisted? ? ::AttributeHelpTexts::UpdateService : ::AttributeHelpTexts::CreateService + call = service_class + .new(user: current_user, model: @attribute_help_text) + .call(attribute_help_text_params_with_attachments) + + if call.success? + flash[:notice] = t(:notice_successful_update) + redirect_to attribute_help_text_admin_settings_project_custom_field_path(@custom_field) + else + @attribute_help_text = call.result + flash.now[:error] = call.message || I18n.t("notice_internal_server_error") + render :attribute_help_text, status: :unprocessable_entity + end + end + private def prepare_role_assignment_form @@ -266,5 +286,35 @@ module Admin::Settings def role_assignment_params params.expect(custom_field: [:role_id]) end + + def find_or_initialize_attribute_help_text + @attribute_help_text = AttributeHelpText::Project.find_or_initialize_by( + attribute_name: "custom_field_#{@custom_field.id}" + ) + end + + def attribute_help_text_params + params + .require(:attribute_help_text) + .permit(:help_text, :caption, :type, :attribute_name) + .merge( + type: "AttributeHelpText::Project", + attribute_name: "custom_field_#{@custom_field.id}" + ) + end + + def attribute_help_text_params_with_attachments + attribute_help_text_params.merge(attachment_params_for_help_text) + end + + def attachment_params_for_help_text + attachment_params = permitted_params.attachments.to_h + + if attachment_params.any? + { attachment_ids: attachment_params.values.map(&:values).flatten } + else + {} + end + end end end diff --git a/app/forms/attribute_help_texts/form.rb b/app/forms/attribute_help_texts/form.rb index 0cae4d2fbc3..8a2c3b542ae 100644 --- a/app/forms/attribute_help_texts/form.rb +++ b/app/forms/attribute_help_texts/form.rb @@ -36,17 +36,25 @@ module AttributeHelpTexts value: model.type ) - attribute_form.select_list( - name: :attribute_name, - label: attribute_name(:attribute_name), - required: true, - disabled: model.persisted? - ) do |list| - selectable_attributes.each do |label, value| - list.option( - label:, - value: - ) + if hide_attribute_name? + attribute_form.hidden( + name: :attribute_name, + value: model.attribute_name + ) + else + attribute_form.select_list( + name: :attribute_name, + label: attribute_name(:attribute_name), + required: true, + disabled: model.persisted? + ) do |list| + selectable_attributes.each do |label, value| + list.option( + label:, + value:, + selected: value == model.attribute_name + ) + end end end @@ -80,6 +88,14 @@ module AttributeHelpTexts ) end + def initialize(hide_attribute_name: false) + super() + + @hide_attribute_name = hide_attribute_name + end + + def hide_attribute_name? = @hide_attribute_name + private def selectable_attributes @@ -87,8 +103,10 @@ module AttributeHelpTexts available = model.class.available_attributes used = AttributeHelpText.used_attributes(model.type) - available - .reject { |key,| used.include? key } + # Always include the current attribute_name if it's set, even if it's "used" + filtered = available.reject { |key,| used.include?(key) && key != model.attribute_name } + + filtered .map { |key, label| [label, key] } .sort_by { |label, _key| label.downcase } end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 5442d918742..40850150039 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -83,6 +83,14 @@ class ApplicationRecord < ActiveRecord::Base ActiveRecord::Base.connection.select_value(union_query) end + def self.human_model_name + model_name.human + end + + def self.human_plural_model_name(count: 2) + model_name.human(count:) + end + # Returns all the attribute names as symbols. # @return [Array] def attribute_keys diff --git a/app/views/admin/settings/project_custom_fields/attribute_help_text.html.erb b/app/views/admin/settings/project_custom_fields/attribute_help_text.html.erb new file mode 100644 index 00000000000..6d8076ba265 --- /dev/null +++ b/app/views/admin/settings/project_custom_fields/attribute_help_text.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.project_attributes.heading"), @custom_field.name, AttributeHelpText.human_plural_model_name %> + +<%= + render( + Settings::ProjectCustomFields::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_project_custom_field_path(@custom_field), + method: :put + ) do |f| + render AttributeHelpTexts::Form.new(f, hide_attribute_name: true) + end + %> +
diff --git a/app/views/attribute_help_texts/edit.html.erb b/app/views/attribute_help_texts/edit.html.erb index 3bd692afedb..2db1ed8f56d 100644 --- a/app/views/attribute_help_texts/edit.html.erb +++ b/app/views/attribute_help_texts/edit.html.erb @@ -34,7 +34,7 @@ See COPYRIGHT and LICENSE files for more details. header.with_title { @attribute_help_text.attribute_field_name } header.with_breadcrumbs( [{ href: admin_index_path, text: t(:label_administration) }, - { href: attribute_help_texts_path, text: t(:"attribute_help_texts.label_plural") }, + { href: attribute_help_texts_path, text: AttributeHelpText.human_plural_model_name }, { href: attribute_help_texts_path(tab: @attribute_help_text.attribute_scope), text: @attribute_help_text.type_caption }, @attribute_help_text.attribute_field_name] ) diff --git a/app/views/attribute_help_texts/index.html.erb b/app/views/attribute_help_texts/index.html.erb index 25df5b291bb..4f1facb39c1 100644 --- a/app/views/attribute_help_texts/index.html.erb +++ b/app/views/attribute_help_texts/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(:"attribute_help_texts.label_plural") %> +<% html_title t(:label_administration), AttributeHelpText.human_plural_model_name %> <% tabs = [ { diff --git a/app/views/attribute_help_texts/new.html.erb b/app/views/attribute_help_texts/new.html.erb index 0d7751e04a4..96add48f95a 100644 --- a/app/views/attribute_help_texts/new.html.erb +++ b/app/views/attribute_help_texts/new.html.erb @@ -34,7 +34,7 @@ See COPYRIGHT and LICENSE files for more details. header.with_title { t(:"attribute_help_texts.add_new") } header.with_breadcrumbs( [{ href: admin_index_path, text: t(:label_administration) }, - { href: attribute_help_texts_path, text: t(:"attribute_help_texts.label_plural") }, + { href: attribute_help_texts_path, text: AttributeHelpText.human_plural_model_name }, { href: attribute_help_texts_path(tab: @attribute_help_text.attribute_scope), text: @attribute_help_text.type_caption }, t(:"attribute_help_texts.add_new")] ) diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index f11c653427a..04f94e2bccd 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -470,7 +470,7 @@ Redmine::MenuManager.map :admin_menu do |menu| menu.push :attribute_help_texts, { controller: "/attribute_help_texts" }, - caption: :"attribute_help_texts.label_plural", + caption: AttributeHelpText.human_plural_model_name, icon: "question", if: ->(_) { User.current.allowed_globally?(:edit_attribute_help_texts) } diff --git a/config/locales/en.yml b/config/locales/en.yml index d3c0b5f27d3..4eba2d10b84 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -208,7 +208,6 @@ en: caption: "This short version will be displayed as caption of the attribute." note_public: "Any text and images you add to this field is publicly visible to all logged in users." text_overview: "In this view, you can create custom help texts for attributes view. When defined, these texts can be shown by clicking the help icon next to its belonging attribute." - label_plural: "Attribute help texts" show_preview: "Preview text" add_new: "Add help text" edit: "Edit help text for %{attribute_field_name}" @@ -1932,7 +1931,9 @@ en: models: attachment: "File" - attribute_help_text: "Attribute help text" + attribute_help_text: + one: "Attribute help text" + other: "Attribute help texts" auth_provider: one: "Authentication provider" other: "Authentication providers" diff --git a/config/routes.rb b/config/routes.rb index ed83d327a04..6b8f01e2d68 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -674,6 +674,9 @@ Rails.application.routes.draw do get :role_assignment post :update_role_assignment get :role_assignment_preview_dialog + + get :attribute_help_text + put :update_attribute_help_text end resources :items, controller: "/admin/settings/project_custom_fields/hierarchy/items" do member do diff --git a/spec/features/admin/settings/project_custom_fields/attribute_help_text_spec.rb b/spec/features/admin/settings/project_custom_fields/attribute_help_text_spec.rb new file mode 100644 index 00000000000..6d06a17381e --- /dev/null +++ b/spec/features/admin/settings/project_custom_fields/attribute_help_text_spec.rb @@ -0,0 +1,235 @@ +# 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 "Project custom field attribute help text", :js do + shared_let(:admin) { create(:admin) } + shared_let(:project_custom_field_section) { create(:project_custom_field_section) } + shared_let(:project_custom_field) do + create(:project_custom_field, + name: "Project Rating", + field_format: "text", + project_custom_field_section:) + end + + let(:editor) { Components::WysiwygEditor.new } + let(:image_fixture) { UploadedFile.load_from("spec/fixtures/files/image.png") } + + before do + login_as(admin) + end + + describe "creating attribute help text for project custom field" do + it "allows creating help text from the custom field edit page" do + visit edit_admin_settings_project_custom_field_path(project_custom_field) + + # Navigate to attribute help text tab + click_on AttributeHelpText.human_plural_model_name + + expect(page).to have_current_path( + attribute_help_text_admin_settings_project_custom_field_path(project_custom_field) + ) + + # Verify attribute field is hidden + expect(page).to have_no_css("#attribute_help_text_attribute_name") + + # Fill in caption + fill_in "Caption", with: "Rating help" + + # Fill in help text + editor.set_markdown("Please rate this project from 1-5 stars") + + # Save + click_button "Save" + + # Should return to the attribute help text tab + expect(page).to have_current_path( + attribute_help_text_admin_settings_project_custom_field_path(project_custom_field) + ) + expect(page).to have_text("Successful update") + + # Verify the help text was created + help_text = AttributeHelpText::Project.find_by(attribute_name: "custom_field_#{project_custom_field.id}") + expect(help_text).to be_present + expect(help_text.caption).to eq("Rating help") + expect(help_text.help_text).to include("Please rate this project from 1-5 stars") + end + + it "allows creating help text with attachments" do + visit attribute_help_text_admin_settings_project_custom_field_path(project_custom_field) + + # Fill in caption + fill_in "Caption", with: "Rating with image" + + # Fill in help text + editor.set_markdown("Rating guidelines below") + + # Add an image + editor.drag_attachment image_fixture.path, "Rating guideline image" + editor.attachments_list.expect_attached("image.png") + + # Save + click_button "Save" + + expect(page).to have_text("Successful update") + + # Verify the help text was created with attachment + help_text = AttributeHelpText::Project.find_by(attribute_name: "custom_field_#{project_custom_field.id}") + expect(help_text).to be_present + expect(help_text.help_text).to include("Rating guidelines below") + expect(help_text.help_text).to match(/\/api\/v3\/attachments\/\d+\/content/) + expect(help_text.attachments).to be_present + end + end + + describe "editing attribute help text for project custom field" do + let!(:existing_help_text) do + create(:project_help_text, + attribute_name: "custom_field_#{project_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_project_custom_field_path(project_custom_field) + + # Should show existing values + expect(page).to have_field("Caption", with: "Original caption") + + # Update caption + fill_in "Caption", with: "Updated caption" + + # Update help text + editor.clear + editor.set_markdown("Updated help text with **bold** text") + + # Save + click_button "Save" + + expect(page).to have_text("Successful update") + + # Verify the help text was updated + existing_help_text.reload + expect(existing_help_text.caption).to eq("Updated caption") + expect(existing_help_text.help_text).to eq("Updated help text with **bold** text") + end + + it "shows validation errors when clearing help text" do + visit attribute_help_text_admin_settings_project_custom_field_path(project_custom_field) + + editor.clear + editor.set_markdown(" ") + click_button "Save" + expect(page).to have_text("Help text can't be blank") + end + + it "persists caption as optional field" do + visit attribute_help_text_admin_settings_project_custom_field_path(project_custom_field) + + # Clear caption but keep help text + fill_in "Caption", with: "" + editor.clear + editor.set_markdown("Help text without caption") + + # Save + click_button "Save" + + expect(page).to have_text("Successful update") + + # Verify caption is nil but help text is saved + existing_help_text.reload + expect(existing_help_text.caption).to be_blank + expect(existing_help_text.help_text).to eq("Help text without caption") + end + end + + describe "navigation between tabs" do + it "maintains tab context when navigating" do + visit edit_admin_settings_project_custom_field_path(project_custom_field) + + # Navigate to attribute help text tab + click_on AttributeHelpText.human_plural_model_name + + expect(page).to have_current_path( + attribute_help_text_admin_settings_project_custom_field_path(project_custom_field) + ) + + # Navigate back to details tab + click_on "Details" + + expect(page).to have_current_path( + edit_admin_settings_project_custom_field_path(project_custom_field) + ) + + # Navigate back to attribute help text tab + click_on AttributeHelpText.human_plural_model_name + + expect(page).to have_current_path( + attribute_help_text_admin_settings_project_custom_field_path(project_custom_field) + ) + end + end + + describe "help text display uniqueness" do + it "creates separate help texts for different custom fields" do + other_custom_field = create(:project_custom_field, + name: "Project Priority", + field_format: "text", + project_custom_field_section:) + + # Create help text for first custom field + visit attribute_help_text_admin_settings_project_custom_field_path(project_custom_field) + fill_in "Caption", with: "Rating help" + editor.set_markdown("Help for rating") + click_button "Save" + + # Create help text for second custom field + visit attribute_help_text_admin_settings_project_custom_field_path(other_custom_field) + fill_in "Caption", with: "Priority help" + editor.set_markdown("Help for priority") + click_button "Save" + + # Verify both help texts exist and are different + rating_help = AttributeHelpText::Project.find_by( + attribute_name: "custom_field_#{project_custom_field.id}" + ) + priority_help = AttributeHelpText::Project.find_by( + attribute_name: "custom_field_#{other_custom_field.id}" + ) + + expect(rating_help).to be_present + expect(priority_help).to be_present + expect(rating_help.id).not_to eq(priority_help.id) + expect(rating_help.caption).to eq("Rating help") + expect(priority_help.caption).to eq("Priority help") + end + end +end