Add tab to project custom fields settings

This commit is contained in:
Oliver Günther
2025-11-25 21:11:31 +01:00
parent ac721dbed4
commit 3237096fe5
15 changed files with 405 additions and 22 deletions
@@ -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
@@ -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)
@@ -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
@@ -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
@@ -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
+31 -13
View File
@@ -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
+8
View File
@@ -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<Symbol>]
def attribute_keys
@@ -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" %>
<div class="op-admin-settings-form-wrapper">
<%=
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
%>
</div>
+1 -1
View File
@@ -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]
)
@@ -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 = [
{
+1 -1
View File
@@ -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")]
)
+1 -1
View File
@@ -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) }
+3 -2
View File
@@ -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"
+3
View File
@@ -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
@@ -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